Compare commits
374 Commits
Author | SHA1 | Date |
---|---|---|
Emmanuel Viennet | bb23cdcea7 | |
Emmanuel Viennet | 3ca5636454 | |
Emmanuel Viennet | 42882154d5 | |
Emmanuel Viennet | 489acb26d2 | |
Emmanuel Viennet | 8ee373db7d | |
Emmanuel Viennet | 8e56dc2418 | |
Emmanuel Viennet | b3331bd886 | |
Emmanuel Viennet | 89afb672af | |
Emmanuel Viennet | 8f25284038 | |
Emmanuel Viennet | f29002a57d | |
Emmanuel Viennet | 69780b3f24 | |
Emmanuel Viennet | fbff151be0 | |
Emmanuel Viennet | 3b436fa0f3 | |
Emmanuel Viennet | 8847a1f008 | |
Emmanuel Viennet | ac882e9ccd | |
Emmanuel Viennet | 000e016985 | |
Emmanuel Viennet | 22d90215a0 | |
Emmanuel Viennet | 043985bff6 | |
Emmanuel Viennet | d20ada1797 | |
Iziram | 778fecabb6 | |
Iziram | fa6f83722e | |
Emmanuel Viennet | baa0412071 | |
Emmanuel Viennet | d51a47b71a | |
Lyanis Souidi | f21ef41de6 | |
Lyanis Souidi | 2d673e7a5d | |
Emmanuel Viennet | 3e43495831 | |
Iziram | a4db8c4ff8 | |
Iziram | 1ac35d04c2 | |
Iziram | 687ac3cf13 | |
Emmanuel Viennet | 18b1f00586 | |
Iziram | 6b985620e9 | |
Iziram | 4d234ba353 | |
Iziram | 5d45fcf656 | |
Iziram | 0a5919b788 | |
Iziram | 09f4525e66 | |
Emmanuel Viennet | 0bc57807de | |
Emmanuel Viennet | 87aaf12d27 | |
Emmanuel Viennet | c8ab9b9b6c | |
Emmanuel Viennet | ad7b48e110 | |
Emmanuel Viennet | f2ce16f161 | |
Emmanuel Viennet | 1ddf9b6ab8 | |
Emmanuel Viennet | 0a2e39cae1 | |
Emmanuel Viennet | a194b4b6e0 | |
Emmanuel Viennet | cbe85dfb7d | |
Emmanuel Viennet | beba69bfe4 | |
Emmanuel Viennet | 41fec29452 | |
Emmanuel Viennet | 9bd05ea241 | |
Emmanuel Viennet | 58b831513d | |
Emmanuel Viennet | b861aba6a3 | |
Emmanuel Viennet | c2443c361f | |
Emmanuel Viennet | ab4731bd43 | |
Emmanuel Viennet | c17bc8b61b | |
Emmanuel Viennet | e44a5ee55d | |
Emmanuel Viennet | a747ed22e2 | |
Emmanuel Viennet | 5d0a932634 | |
Emmanuel Viennet | 2b150cf521 | |
Emmanuel Viennet | 5a5ddcacd7 | |
Emmanuel Viennet | 3f6e65b9da | |
Emmanuel Viennet | 5eba6170a5 | |
Emmanuel Viennet | bd9bf87112 | |
Emmanuel Viennet | a0e2af481f | |
Emmanuel Viennet | 42e8f97441 | |
Emmanuel Viennet | 8ec0171ca0 | |
Emmanuel Viennet | 6dfab2d843 | |
Emmanuel Viennet | 523ec59833 | |
Emmanuel Viennet | bde6325391 | |
Emmanuel Viennet | 0577347622 | |
Emmanuel Viennet | 28d46e413d | |
Emmanuel Viennet | 126ea0741a | |
Emmanuel Viennet | a5b5f49f76 | |
Iziram | b7ab10bf4e | |
Emmanuel Viennet | 3e0b19c4a8 | |
Emmanuel Viennet | 1dd5187fae | |
Iziram | 9a3a7d33b2 | |
Iziram | a7569fe4f5 | |
Iziram | 79e973f06d | |
Emmanuel Viennet | b6940e4882 | |
Emmanuel Viennet | 1f24095c57 | |
Emmanuel Viennet | 0ed2455028 | |
Emmanuel Viennet | b841b2f708 | |
Iziram | 0fa1478138 | |
Iziram | 85ad7b5f29 | |
Emmanuel Viennet | 6bfd461bf2 | |
Emmanuel Viennet | e1f1a95a14 | |
Emmanuel Viennet | 70e3006981 | |
Emmanuel Viennet | bae46c2794 | |
Iziram | b1055a4ebe | |
Iziram | b2ef6a4c53 | |
Iziram | a7c7bd655d | |
Iziram | 1309043a98 | |
Iziram | a75b41ca5f | |
Emmanuel Viennet | 8df25ca02f | |
Emmanuel Viennet | 61f9dddeb6 | |
Emmanuel Viennet | a1f5340935 | |
Emmanuel Viennet | 68128c27d5 | |
Emmanuel Viennet | 8ecaa2bed0 | |
Emmanuel Viennet | 7c61dd8d63 | |
Emmanuel Viennet | f493ba344f | |
Emmanuel Viennet | f5079d9aef | |
Emmanuel Viennet | 55add2ffb3 | |
Emmanuel Viennet | 5865b67652 | |
Emmanuel Viennet | 3c8b088d5e | |
Emmanuel Viennet | 2da359ae41 | |
Emmanuel Viennet | 09ec53f573 | |
Emmanuel Viennet | 3787e0145a | |
Emmanuel Viennet | edf989ee04 | |
Emmanuel Viennet | 203f3a5342 | |
Emmanuel Viennet | 161f8476ca | |
Emmanuel Viennet | d419d75515 | |
Emmanuel Viennet | f23630d7fd | |
Emmanuel Viennet | fa0417f0b1 | |
Emmanuel Viennet | 12256dc3d4 | |
Emmanuel Viennet | 46529917ea | |
Emmanuel Viennet | 2367984848 | |
Emmanuel Viennet | 46c86d2928 | |
Emmanuel Viennet | 715e4f94ee | |
Iziram | b2e6ef63b9 | |
Iziram | 30560e5860 | |
Iziram | 0fbcfb1124 | |
Iziram | 2daae1c9c5 | |
Emmanuel Viennet | 635269ff36 | |
Emmanuel Viennet | 4aa30a40bd | |
Emmanuel Viennet | 03c03f3725 | |
Emmanuel Viennet | 29eb8c297b | |
Emmanuel Viennet | 38032a8c09 | |
Emmanuel Viennet | 2f2d98954c | |
Emmanuel Viennet | 2e5d94f048 | |
Emmanuel Viennet | 1b1b8ebdc4 | |
Emmanuel Viennet | 9c6db169f3 | |
Iziram | 8ded16b94f | |
Iziram | 5d10ee467e | |
Emmanuel Viennet | 763f60fb3d | |
Iziram | 7af0dd1e1e | |
Emmanuel Viennet | dece9a82d1 | |
Emmanuel Viennet | 0262b6e2ac | |
Emmanuel Viennet | f8f47e05ff | |
Iziram | b74d525c28 | |
Iziram | c617ee321a | |
Iziram | 56ec4ba43d | |
Iziram | d14f7e21b7 | |
Iziram | c3cb1da561 | |
Iziram | cce60d432d | |
Iziram | 4386994f7d | |
Iziram | fddfddfa7b | |
Iziram | 39dca32d2e | |
Iziram | e2b9cd3ded | |
Iziram | be227f4a2f | |
Emmanuel Viennet | 959a98d0a2 | |
Emmanuel Viennet | 35a038fd3a | |
Emmanuel Viennet | b46556c189 | |
Iziram | 71f90f5261 | |
Iziram | 1b037d6c7c | |
Emmanuel Viennet | 60a97b7baf | |
Iziram | 0332553587 | |
Iziram | 958cf435c8 | |
Iziram | c69e9c34a0 | |
Iziram | 17f8771b0b | |
Iziram | 7eb41fb2eb | |
Iziram | a79ca4a17d | |
Emmanuel Viennet | 411ef8ae0d | |
Emmanuel Viennet | 169bf17fdd | |
Emmanuel Viennet | 75d4c110a8 | |
Emmanuel Viennet | 9003a2ca87 | |
Emmanuel Viennet | 55ecaa45a9 | |
Emmanuel Viennet | ab39454a0d | |
Iziram | 5158bd0c8f | |
Iziram | 21b2e0f582 | |
Emmanuel Viennet | e56cbfc5a2 | |
Emmanuel Viennet | 9cdab8d1ed | |
Cléo Baras | 7cdba43e86 | |
Iziram | 079348bb87 | |
Iziram | c882e0d6a0 | |
Cléo Baras | 9c7576154c | |
Cléo Baras | ce0d5ec9fd | |
Iziram | 3d6be2f200 | |
Cléo Baras | e675064cae | |
Iziram | 185e061f01 | |
Iziram | e4c889ec8a | |
Emmanuel Viennet | 7ef45e0bac | |
Iziram | f242fee5ff | |
Emmanuel Viennet | c960d943d2 | |
Emmanuel Viennet | 741168a065 | |
Emmanuel Viennet | 5c9126d263 | |
Emmanuel Viennet | ce63b7f2f5 | |
Emmanuel Viennet | 5e5cb015d0 | |
Cléo Baras | 5fc1800f70 | |
Cléo Baras | 2459356245 | |
Cléo Baras | b1602f0cf3 | |
Cléo Baras | ba28d5f3c8 | |
Cléo Baras | b9b9a172c7 | |
Cléo Baras | c2a66b607f | |
Cléo Baras | 802e8f4648 | |
Iziram | 3184d5d92e | |
Cléo Baras | cf7d7d2db8 | |
Cléo Baras | 5ea65433be | |
Cléo Baras | 35a20c3307 | |
Cléo Baras | 8acd9a12d4 | |
Cléo Baras | 2020114c1b | |
Cléo Baras | a93aa19449 | |
Iziram | c620c3b0e1 | |
Iziram | c2e77846b9 | |
Cléo Baras | 28b25ad681 | |
Cléo Baras | 5ea79c03a3 | |
Emmanuel Viennet | fdcf6388f5 | |
Iziram | 9dcaf70e18 | |
Emmanuel Viennet | 20d4b4e1b3 | |
Emmanuel Viennet | aaaf41250a | |
Iziram | b3b47a755f | |
Emmanuel Viennet | bc5292b165 | |
Emmanuel Viennet | ee601071f5 | |
Emmanuel Viennet | 0cf3b0a782 | |
Emmanuel Viennet | 49a5ec488d | |
Cléo Baras | a50bbe9223 | |
Cléo Baras | 57d616da1a | |
Emmanuel Viennet | c0a965d774 | |
Emmanuel Viennet | 1c01d987be | |
Cléo Baras | 21a794a760 | |
Emmanuel Viennet | 41944bcd29 | |
Cléo Baras | 960f8a3462 | |
Cléo Baras | 6821a02956 | |
Emmanuel Viennet | 47a42d897e | |
Emmanuel Viennet | 7f32f1fb99 | |
Cléo Baras | eb56182407 | |
Cléo Baras | 02b057ca5a | |
Cléo Baras | eff28d64f9 | |
Emmanuel Viennet | 81fab97018 | |
Emmanuel Viennet | a8a711b30a | |
Emmanuel Viennet | 46cdaf75b8 | |
Emmanuel Viennet | d1d89cc427 | |
Emmanuel Viennet | 61d35ddac0 | |
Emmanuel Viennet | c492cf550a | |
Emmanuel Viennet | 2dd7154036 | |
Emmanuel Viennet | 13e7bd4512 | |
Emmanuel Viennet | f1ce70e6de | |
Emmanuel Viennet | a8ff540e95 | |
Emmanuel Viennet | cc3f5d393f | |
Emmanuel Viennet | 7c794c01d1 | |
Cléo Baras | 746314b2fb | |
Emmanuel Viennet | 624ea39edd | |
Emmanuel Viennet | 853bc31422 | |
Emmanuel Viennet | 09d59848d6 | |
Emmanuel Viennet | f31eca97bb | |
Emmanuel Viennet | 3844ae46d1 | |
Emmanuel Viennet | fae9fbdd09 | |
Cléo Baras | 40a57a9b86 | |
Cléo Baras | b5125fa3d7 | |
Cléo Baras | 0f446fe0d3 | |
Cléo Baras | 5f656b431b | |
Cléo Baras | 83059cd995 | |
Cléo Baras | 8de1a44583 | |
Cléo Baras | 491d600bd4 | |
Emmanuel Viennet | 56aa5fbba3 | |
Cléo Baras | d6a75b176e | |
Emmanuel Viennet | e6d61fcd8a | |
Cléo Baras | 70f399e8b7 | |
Cléo Baras | 68bd20f8de | |
Cléo Baras | 1716daafde | |
Cléo Baras | 5e49384a90 | |
Cléo Baras | 828c619c74 | |
Cléo Baras | b8cb592ac9 | |
Cléo Baras | d8381884dc | |
Cléo Baras | 883028216f | |
Emmanuel Viennet | d140240909 | |
Cléo Baras | 267dbb6460 | |
Cléo Baras | 02a73de04d | |
Cléo Baras | e78a2d3ffe | |
Emmanuel Viennet | a200be586a | |
Emmanuel Viennet | 607604f91e | |
Emmanuel Viennet | 8eedac0f03 | |
Emmanuel Viennet | aea2204d9e | |
Emmanuel Viennet | 9c15cbe647 | |
Emmanuel Viennet | 6761f5a620 | |
Emmanuel Viennet | 69a53adb55 | |
Emmanuel Viennet | b30ea5f5fd | |
Emmanuel Viennet | 052fb3c7b9 | |
Lyanis Souidi | dbd0124c2c | |
Lyanis Souidi | e989a4ffa8 | |
Lyanis Souidi | 6ae2b0eb5f | |
Emmanuel Viennet | d7f3376103 | |
Lyanis Souidi | 677415fbfc | |
Emmanuel Viennet | bcb801662a | |
Emmanuel Viennet | 6cbeeedb1c | |
Emmanuel Viennet | 39e7ad3ad6 | |
Emmanuel Viennet | 177d38428e | |
Emmanuel Viennet | f4c1d00046 | |
Emmanuel Viennet | 86c12dee08 | |
Emmanuel Viennet | 8cf85f78a8 | |
Emmanuel Viennet | 9ec0ef27ba | |
Emmanuel Viennet | c8ac796347 | |
Cléo Baras | 2212990788 | |
Cléo Baras | 719d14673d | |
Cléo Baras | 98eb7699a0 | |
Cléo Baras | 7b22d26095 | |
Cléo Baras | 371d7eff64 | |
Cléo Baras | 0adcbb7c0b | |
Cléo Baras | f10d46c230 | |
Emmanuel Viennet | 4f41ef7050 | |
Emmanuel Viennet | ef4c2fa64b | |
Cléo Baras | be39245e25 | |
Cléo Baras | 196dbab298 | |
Emmanuel Viennet | 0594a659fa | |
Emmanuel Viennet | 072d013590 | |
Cléo Baras | 9c4e2627ba | |
Emmanuel Viennet | bbdf5da2e8 | |
Cléo Baras | 5828d4aaaf | |
Emmanuel Viennet | e6a544906e | |
Emmanuel Viennet | bacd734ab5 | |
Emmanuel Viennet | e611fa4bfc | |
Emmanuel Viennet | 128b282186 | |
Emmanuel Viennet | 57d36927ac | |
Emmanuel Viennet | d5fdd5b8b8 | |
Emmanuel Viennet | 7162d83f39 | |
Emmanuel Viennet | 7805a6cab9 | |
Emmanuel Viennet | 2c840b7803 | |
Emmanuel Viennet | 0645db8ab0 | |
Emmanuel Viennet | 9e13b51669 | |
Emmanuel Viennet | 034800ab9a | |
Emmanuel Viennet | 4b2e88c678 | |
Cléo Baras | c9af2345fb | |
Cléo Baras | 0bf0311f2f | |
Emmanuel Viennet | 5bbdc567f3 | |
Emmanuel Viennet | 027f11e494 | |
Emmanuel Viennet | ef171364a6 | |
Cléo Baras | 9b9d7b611b | |
Cléo Baras | 02bfb626cb | |
Cléo Baras | 597a28f86d | |
Emmanuel Viennet | 2915f4e981 | |
Emmanuel Viennet | af659d5f09 | |
Emmanuel Viennet | 838ae7cf7e | |
Emmanuel Viennet | e4c8637c41 | |
Emmanuel Viennet | 0bf3c22cd0 | |
Cléo Baras | 6700687e96 | |
Cléo Baras | b8e20b6be8 | |
Cléo Baras | 78eeb9c67f | |
Cléo Baras | 66fbb0afbc | |
Cléo Baras | 387af40b65 | |
Emmanuel Viennet | 952132695f | |
Emmanuel Viennet | 0b6a4b5c7e | |
Emmanuel Viennet | 556725b3ef | |
Emmanuel Viennet | 90bf31fc03 | |
Emmanuel Viennet | f7e41dc7fe | |
Emmanuel Viennet | eefbe70944 | |
Emmanuel Viennet | 5446ac0ed2 | |
Emmanuel Viennet | 1f6f3620a2 | |
Emmanuel Viennet | 04d1fbe272 | |
Emmanuel Viennet | c270c24c5b | |
Emmanuel Viennet | 8b751608e1 | |
Emmanuel Viennet | 0fb45fc9ca | |
Emmanuel Viennet | 8652ef2e7b | |
Emmanuel Viennet | 9be77e4f37 | |
Emmanuel Viennet | a00e2da461 | |
Emmanuel Viennet | 3481f7c1c2 | |
Emmanuel Viennet | 64d7e1ed42 | |
Cléo Baras | d310304e9e | |
Cléo Baras | fce23aa066 | |
Cléo Baras | 9c6d988fc3 | |
Cléo Baras | cb5df2fffd | |
Cléo Baras | 3550e4290a | |
Emmanuel Viennet | 787e514dca | |
Emmanuel Viennet | e25f7d4fc9 | |
Cléo Baras | f87902d1ac | |
Emmanuel Viennet | 39b3cd9e05 | |
Emmanuel Viennet | d1074a8227 | |
Cléo Baras | 1b18034adb | |
Cléo Baras | 871f5c1d61 | |
Emmanuel Viennet | 79f07deac0 | |
Emmanuel Viennet | 431dd20911 | |
Emmanuel Viennet | 4985182b9a | |
Emmanuel Viennet | 4681294cb8 | |
Emmanuel Viennet | f6051f930f | |
Cléo Baras | cf415763b3 | |
Cléo Baras | 769f6c0ea0 | |
Emmanuel Viennet | 33f2afb04b | |
Cléo Baras | 02bccb58aa |
152
README.md
152
README.md
|
@ -2,7 +2,7 @@
|
|||
|
||||
(c) Emmanuel Viennet 1999 - 2024 (voir LICENCE.txt).
|
||||
|
||||
Installation: voir instructions à jour sur <https://scodoc.org/GuideInstallDebian11>
|
||||
Installation: voir instructions à jour sur <https://scodoc.org/GuideInstallDebian12>
|
||||
|
||||
Documentation utilisateur: <https://scodoc.org>
|
||||
|
||||
|
@ -23,7 +23,7 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes).
|
|||
|
||||
### Lignes de commandes
|
||||
|
||||
Voir [https://scodoc.org/GuideConfig](le guide de configuration).
|
||||
Voir [le guide de configuration](https://scodoc.org/GuideConfig).
|
||||
|
||||
## Organisation des fichiers
|
||||
|
||||
|
@ -41,45 +41,41 @@ Ils ne doivent pas être modifiés à la main, sauf certains fichiers de configu
|
|||
Le répertoire `/opt/scodoc-data` doit être régulièrement sauvegardé.
|
||||
|
||||
Principaux contenus:
|
||||
|
||||
/opt/scodoc-data
|
||||
/opt/scodoc-data/log # Fichiers de log ScoDoc
|
||||
/opt/scodoc-data/config # Fichiers de configuration
|
||||
.../config/logos # Logos de l'établissement
|
||||
.../config/depts # un fichier par département
|
||||
/opt/scodoc-data/photos # Photos des étudiants
|
||||
/opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants
|
||||
|
||||
```
|
||||
/opt/scodoc-data
|
||||
/opt/scodoc-data/log # Fichiers de log ScoDoc
|
||||
/opt/scodoc-data/config # Fichiers de configuration
|
||||
.../config/logos # Logos de l'établissement
|
||||
.../config/depts # un fichier par département
|
||||
/opt/scodoc-data/photos # Photos des étudiants
|
||||
/opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants
|
||||
```
|
||||
## Pour les développeurs
|
||||
|
||||
### Installation du code
|
||||
|
||||
Installer ScoDoc 9 normalement ([voir la doc](https://scodoc.org/GuideInstallDebian11)).
|
||||
Installer ScoDoc 9 normalement ([voir la doc](https://scodoc.org/GuideInstallDebian12)).
|
||||
|
||||
Puis remplacer `/opt/scodoc` par un clone du git.
|
||||
```bash
|
||||
sudo su
|
||||
mv /opt/scodoc /opt/off-scodoc # ou ce que vous voulez
|
||||
apt-get install git # si besoin
|
||||
git clone https://scodoc.org/git/ScoDoc/ScoDoc.git /opt/scodoc
|
||||
# (ou bien utiliser votre clone gitea si vous l'avez déjà créé !)
|
||||
|
||||
sudo su
|
||||
mv /opt/scodoc /opt/off-scodoc # ou ce que vous voulez
|
||||
apt-get install git # si besoin
|
||||
cd /opt
|
||||
git clone https://scodoc.org/git/viennet/ScoDoc.git
|
||||
# (ou bien utiliser votre clone gitea si vous l'avez déjà créé !)
|
||||
|
||||
# Renommer le répertoire:
|
||||
mv ScoDoc scodoc
|
||||
|
||||
# Et donner ce répertoire à l'utilisateur scodoc:
|
||||
chown -R scodoc.scodoc /opt/scodoc
|
||||
|
||||
# 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:
|
||||
mv /opt/off-scodoc/venv /opt/scodoc
|
||||
|
||||
```bash
|
||||
# Le plus simple est de piquer le virtualenv configuré par l'installeur:
|
||||
mv /opt/off-scodoc/venv /opt/scodoc
|
||||
```
|
||||
Et la config:
|
||||
|
||||
ln -s /opt/scodoc-data/.env /opt/scodoc
|
||||
|
||||
```bash
|
||||
ln -s /opt/scodoc-data/.env /opt/scodoc
|
||||
```
|
||||
Cette dernière commande utilise le `.env` crée lors de l'install, ce qui
|
||||
n'est pas toujours le plus judicieux: vous pouvez modifier son contenu, par
|
||||
exemple pour travailler en mode "développement" avec `FLASK_ENV=development`.
|
||||
|
@ -88,11 +84,11 @@ exemple pour travailler en mode "développement" avec `FLASK_ENV=development`.
|
|||
|
||||
Les tests unitaires utilisent normalement la base postgresql `SCODOC_TEST`.
|
||||
Avant le premier lancement, créer cette base ainsi:
|
||||
|
||||
./tools/create_database.sh SCODOC_TEST
|
||||
export FLASK_ENV=test
|
||||
flask db upgrade
|
||||
|
||||
```bash
|
||||
./tools/create_database.sh SCODOC_TEST
|
||||
export FLASK_ENV=test
|
||||
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
|
||||
migrations (changements de schéma) ont eu lieu dans le code.
|
||||
|
@ -100,17 +96,17 @@ 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
|
||||
scripts de tests:
|
||||
Lancer au préalable:
|
||||
|
||||
flask delete-dept -fy TEST00 && flask create-dept TEST00
|
||||
|
||||
```bash
|
||||
flask delete-dept -fy TEST00 && flask create-dept TEST00
|
||||
```
|
||||
Puis dérouler les tests unitaires:
|
||||
|
||||
pytest tests/unit
|
||||
|
||||
```bash
|
||||
pytest tests/unit
|
||||
```
|
||||
Ou avec couverture (`pip install pytest-cov`)
|
||||
|
||||
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
|
||||
|
||||
```bash
|
||||
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
|
||||
|
@ -119,43 +115,43 @@ développement dans un état connu, par exemple pour éviter de recréer à la m
|
|||
|
||||
Il suffit de positionner une variable d'environnement indiquant la BD utilisée
|
||||
par les tests:
|
||||
|
||||
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
pytest tests/unit/test_sco_basic.py
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
flask user-password admin
|
||||
|
||||
```bash
|
||||
flask user-password admin
|
||||
```
|
||||
**Attention:** les tests unitaires **effacent** complètement le contenu de la
|
||||
base de données (tous les départements, et les utilisateurs) avant de commencer !
|
||||
|
||||
#### Modification du schéma de la base
|
||||
|
||||
On utilise SQLAlchemy avec Alembic et Flask-Migrate.
|
||||
|
||||
flask db migrate -m "message explicatif....."
|
||||
flask db upgrade
|
||||
|
||||
```bash
|
||||
flask db migrate -m "message explicatif....."
|
||||
flask db upgrade
|
||||
```
|
||||
Ne pas oublier de d'ajouter le script de migration à git (`git add migrations/...`).
|
||||
|
||||
**Mémo**: séquence re-création d'une base (vérifiez votre `.env`
|
||||
ou variables d'environnement pour interroger la bonne base !).
|
||||
```bash
|
||||
dropdb SCODOC_DEV
|
||||
tools/create_database.sh SCODOC_DEV # créé base SQL
|
||||
flask db upgrade # créé les tables à partir des migrations
|
||||
flask sco-db-init # ajoute au besoin les constantes (fait en migration 0)
|
||||
|
||||
dropdb SCODOC_DEV
|
||||
tools/create_database.sh SCODOC_DEV # créé base SQL
|
||||
flask db upgrade # créé les tables à partir des migrations
|
||||
flask sco-db-init # ajoute au besoin les constantes (fait en migration 0)
|
||||
|
||||
# puis imports:
|
||||
flask import-scodoc7-users
|
||||
flask import-scodoc7-dept STID SCOSTID
|
||||
|
||||
# puis imports:
|
||||
flask import-scodoc7-users
|
||||
flask import-scodoc7-dept STID SCOSTID
|
||||
```
|
||||
Si la base utilisée pour les dev n'est plus en phase avec les scripts de
|
||||
migration, utiliser les commandes `flask db history`et `flask db stamp`pour se
|
||||
positionner à la bonne étape.
|
||||
|
@ -163,23 +159,23 @@ positionner à la bonne étape.
|
|||
### Profiling
|
||||
|
||||
Sur une machine de DEV, lancer
|
||||
|
||||
flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data
|
||||
|
||||
```bash
|
||||
flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data
|
||||
```
|
||||
le fichier `.prof` sera alors écrit dans `/opt/scodoc-data` (on peut aussi utiliser `/tmp`).
|
||||
|
||||
Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bien:
|
||||
|
||||
pip install snakeviz
|
||||
|
||||
```bash
|
||||
pip install snakeviz
|
||||
```
|
||||
puis
|
||||
|
||||
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
|
||||
|
||||
```bash
|
||||
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
|
||||
```
|
||||
## Paquet Debian 12
|
||||
|
||||
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
|
||||
important est `postinst`qui se charge de configurer le système (install ou
|
||||
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
|
||||
|
|
|
@ -315,12 +315,6 @@ def create_app(config_class=DevConfig):
|
|||
app.register_error_handler(503, postgresql_server_error)
|
||||
app.register_error_handler(APIInvalidParams, handle_invalid_usage)
|
||||
|
||||
# Add some globals
|
||||
# previously in Flask-Bootstrap:
|
||||
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
|
||||
field, HiddenField
|
||||
)
|
||||
|
||||
from app.auth import bp as auth_bp
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix="/auth")
|
||||
|
@ -338,8 +332,15 @@ def create_app(config_class=DevConfig):
|
|||
from app.api import api_bp
|
||||
from app.api import api_web_bp
|
||||
|
||||
# Jinja2 configuration
|
||||
# Enable autoescaping of all templates, including .j2
|
||||
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
|
||||
app.jinja_env.trim_blocks = True
|
||||
app.jinja_env.lstrip_blocks = True
|
||||
# previously in Flask-Bootstrap:
|
||||
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
|
||||
field, HiddenField
|
||||
)
|
||||
|
||||
# https://scodoc.fr/ScoDoc
|
||||
app.register_blueprint(scodoc_bp)
|
||||
|
@ -636,14 +637,12 @@ def critical_error(msg):
|
|||
import app.scodoc.sco_utils as scu
|
||||
|
||||
log(f"\n*** CRITICAL ERROR: {msg}")
|
||||
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
|
||||
subject = f"CRITICAL ERROR: {msg}".strip()[:68]
|
||||
send_scodoc_alarm(subject, 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}
|
||||
Une erreur est survenue, veuillez ré-essayer.
|
||||
|
||||
{msg}
|
||||
"""
|
||||
|
|
|
@ -3,14 +3,15 @@
|
|||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
"""ScoDoc 9 API : Assiduités
|
||||
"""
|
||||
"""ScoDoc 9 API : Assiduités"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask import g, request
|
||||
from flask_json import as_json
|
||||
from flask_login import current_user, login_required
|
||||
from flask_sqlalchemy.query import Query
|
||||
from sqlalchemy.orm.exc import ObjectDeletedError
|
||||
|
||||
from app import db, log, set_sco_dept
|
||||
import app.scodoc.sco_assiduites as scass
|
||||
|
@ -337,7 +338,7 @@ def assiduites_group(with_query: bool = False):
|
|||
try:
|
||||
etuds = [int(etu) for etu in etuds]
|
||||
except ValueError:
|
||||
return json_error(404, "Le champs etudids n'est pas correctement formé")
|
||||
return json_error(404, "Le champ etudids n'est pas correctement formé")
|
||||
|
||||
# Vérification que tous les étudiants sont du même département
|
||||
query = Identite.query.filter(Identite.id.in_(etuds))
|
||||
|
@ -858,7 +859,10 @@ def assiduite_edit(assiduite_id: int):
|
|||
msg=f"assiduite: modif {assiduite_unique}",
|
||||
)
|
||||
db.session.commit()
|
||||
scass.simple_invalidate_cache(assiduite_unique.to_dict())
|
||||
try:
|
||||
scass.simple_invalidate_cache(assiduite_unique.to_dict())
|
||||
except ObjectDeletedError:
|
||||
return json_error(404, "Assiduité supprimée / inexistante")
|
||||
|
||||
return {"OK": True}
|
||||
|
||||
|
|
|
@ -295,7 +295,7 @@ def dept_formsemestres_courants_by_id(dept_id: int):
|
|||
if date_courante:
|
||||
test_date = datetime.fromisoformat(date_courante)
|
||||
else:
|
||||
test_date = app.db.func.now()
|
||||
test_date = db.func.current_date()
|
||||
# Les semestres en cours de ce département
|
||||
formsemestres = FormSemestre.query.filter(
|
||||
FormSemestre.dept_id == dept.id,
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
from datetime import datetime
|
||||
from operator import attrgetter
|
||||
|
||||
from flask import g, request
|
||||
from flask import g, request, Response
|
||||
from flask_json import as_json
|
||||
from flask_login import current_user
|
||||
from flask_login import login_required
|
||||
|
@ -18,7 +18,7 @@ from sqlalchemy import desc, func, or_
|
|||
from sqlalchemy.dialects.postgresql import VARCHAR
|
||||
|
||||
import app
|
||||
from app import db
|
||||
from app import db, log
|
||||
from app.api import api_bp as bp, api_web_bp
|
||||
from app.api import tools
|
||||
from app.but import bulletin_but_court
|
||||
|
@ -26,6 +26,7 @@ from app.decorators import scodoc, permission_required
|
|||
from app.models import (
|
||||
Admission,
|
||||
Departement,
|
||||
EtudAnnotation,
|
||||
FormSemestreInscription,
|
||||
FormSemestre,
|
||||
Identite,
|
||||
|
@ -54,6 +55,32 @@ import app.scodoc.sco_utils as scu
|
|||
#
|
||||
|
||||
|
||||
def _get_etud_by_code(
|
||||
code_type: str, code: str, dept: Departement
|
||||
) -> tuple[bool, Response | Identite]:
|
||||
"""Get etud, using etudid, NIP or INE
|
||||
Returns True, etud if ok, or False, error response.
|
||||
"""
|
||||
if code_type == "nip":
|
||||
query = Identite.query.filter_by(code_nip=code)
|
||||
elif code_type == "etudid":
|
||||
try:
|
||||
etudid = int(code)
|
||||
except ValueError:
|
||||
return False, json_error(404, "invalid etudid type")
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
elif code_type == "ine":
|
||||
query = Identite.query.filter_by(code_ine=code)
|
||||
else:
|
||||
return False, json_error(404, "invalid code_type")
|
||||
if dept:
|
||||
query = query.filter_by(dept_id=dept.id)
|
||||
etud = query.first()
|
||||
if etud is None:
|
||||
return False, json_error(404, message="etudiant inexistant")
|
||||
return True, etud
|
||||
|
||||
|
||||
@bp.route("/etudiants/courants", defaults={"long": False})
|
||||
@bp.route("/etudiants/courants/long", defaults={"long": True})
|
||||
@api_web_bp.route("/etudiants/courants", defaults={"long": False})
|
||||
|
@ -105,7 +132,9 @@ def etudiants_courants(long=False):
|
|||
)
|
||||
if long:
|
||||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
data = [etud.to_dict_api(restrict=restrict) for etud in etuds]
|
||||
data = [
|
||||
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in etuds
|
||||
]
|
||||
else:
|
||||
data = [etud.to_dict_short() for etud in etuds]
|
||||
return data
|
||||
|
@ -140,7 +169,7 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
|
|||
message="étudiant inconnu",
|
||||
)
|
||||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
return etud.to_dict_api(restrict=restrict)
|
||||
return etud.to_dict_api(restrict=restrict, with_annotations=True)
|
||||
|
||||
|
||||
@bp.route("/etudiant/etudid/<int:etudid>/photo")
|
||||
|
@ -253,7 +282,9 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
|
|||
or_(Departement.acronym == acronym for acronym in allowed_depts)
|
||||
)
|
||||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
return [etud.to_dict_api(restrict=restrict) for etud in query]
|
||||
return [
|
||||
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in query
|
||||
]
|
||||
|
||||
|
||||
@bp.route("/etudiants/name/<string:start>")
|
||||
|
@ -383,30 +414,24 @@ def bulletin(
|
|||
if version == "pdf":
|
||||
version = "long"
|
||||
pdf = True
|
||||
if version not in scu.BULLETINS_VERSIONS_BUT:
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
if version not in (
|
||||
scu.BULLETINS_VERSIONS_BUT
|
||||
if formsemestre.formation.is_apc()
|
||||
else scu.BULLETINS_VERSIONS
|
||||
):
|
||||
return json_error(404, "version invalide")
|
||||
# return f"{code_type}={code}, version={version}, pdf={pdf}"
|
||||
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
|
||||
if formsemestre.bul_hide_xml and pdf:
|
||||
return json_error(403, "bulletin non disponible")
|
||||
# note: la version json est réduite si bul_hide_xml
|
||||
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
|
||||
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
|
||||
return json_error(404, "formsemestre inexistant")
|
||||
app.set_sco_dept(dept.acronym)
|
||||
|
||||
if code_type == "nip":
|
||||
query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
|
||||
elif code_type == "etudid":
|
||||
try:
|
||||
etudid = int(code)
|
||||
except ValueError:
|
||||
return json_error(404, "invalid etudid type")
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
elif code_type == "ine":
|
||||
query = Identite.query.filter_by(code_ine=code, dept_id=dept.id)
|
||||
else:
|
||||
return json_error(404, "invalid code_type")
|
||||
etud = query.first()
|
||||
if etud is None:
|
||||
return json_error(404, message="etudiant inexistant")
|
||||
ok, etud = _get_etud_by_code(code_type, code, dept)
|
||||
if not ok:
|
||||
return etud # json error
|
||||
|
||||
if version == "butcourt":
|
||||
if pdf:
|
||||
|
@ -558,26 +583,15 @@ def etudiant_create(force=False):
|
|||
@api_web_bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
|
||||
@scodoc
|
||||
@permission_required(Permission.EtudInscrit)
|
||||
@as_json
|
||||
def etudiant_edit(
|
||||
code_type: str = "etudid",
|
||||
code: str = None,
|
||||
):
|
||||
"""Edition des données étudiant (identité, admission, adresses)"""
|
||||
if code_type == "nip":
|
||||
query = Identite.query.filter_by(code_nip=code)
|
||||
elif code_type == "etudid":
|
||||
try:
|
||||
etudid = int(code)
|
||||
except ValueError:
|
||||
return json_error(404, "invalid etudid type")
|
||||
query = Identite.query.filter_by(id=etudid)
|
||||
elif code_type == "ine":
|
||||
query = Identite.query.filter_by(code_ine=code)
|
||||
else:
|
||||
return json_error(404, "invalid code_type")
|
||||
if g.scodoc_dept:
|
||||
query = query.filter_by(dept_id=g.scodoc_dept_id)
|
||||
etud: Identite = query.first()
|
||||
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
|
||||
if not ok:
|
||||
return etud # json error
|
||||
#
|
||||
args = request.get_json(force=True) # may raise 400 Bad Request
|
||||
etud.from_dict(args)
|
||||
|
@ -600,3 +614,67 @@ def etudiant_edit(
|
|||
restrict = not current_user.has_permission(Permission.ViewEtudData)
|
||||
r = etud.to_dict_api(restrict=restrict)
|
||||
return r
|
||||
|
||||
|
||||
@bp.route("/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"])
|
||||
@api_web_bp.route(
|
||||
"/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"]
|
||||
)
|
||||
@scodoc
|
||||
@permission_required(Permission.EtudInscrit) # il faut en plus ViewEtudData
|
||||
@as_json
|
||||
def etudiant_annotation(
|
||||
code_type: str = "etudid",
|
||||
code: str = None,
|
||||
):
|
||||
"""Ajout d'une annotation sur un étudiant"""
|
||||
if not current_user.has_permission(Permission.ViewEtudData):
|
||||
return json_error(403, "non autorisé (manque ViewEtudData)")
|
||||
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
|
||||
if not ok:
|
||||
return etud # json error
|
||||
#
|
||||
args = request.get_json(force=True) # may raise 400 Bad Request
|
||||
comment = args.get("comment", None)
|
||||
if not isinstance(comment, str):
|
||||
return json_error(404, "invalid comment (expected string)")
|
||||
if len(comment) > scu.MAX_TEXT_LEN:
|
||||
return json_error(404, "invalid comment (too large)")
|
||||
annotation = EtudAnnotation(comment=comment, author=current_user.user_name)
|
||||
etud.annotations.append(annotation)
|
||||
db.session.add(etud)
|
||||
db.session.commit()
|
||||
log(f"etudiant_annotation/{etud.id}/{annotation.id}")
|
||||
return annotation.to_dict()
|
||||
|
||||
|
||||
@bp.route(
|
||||
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
|
||||
methods=["POST"],
|
||||
)
|
||||
@api_web_bp.route(
|
||||
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
|
||||
methods=["POST"],
|
||||
)
|
||||
@login_required
|
||||
@scodoc
|
||||
@as_json
|
||||
@permission_required(Permission.EtudInscrit)
|
||||
def etudiant_annotation_delete(
|
||||
code_type: str = "etudid", code: str = None, annotation_id: int = None
|
||||
):
|
||||
"""
|
||||
Suppression d'une annotation
|
||||
"""
|
||||
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
|
||||
if not ok:
|
||||
return etud # json error
|
||||
annotation = EtudAnnotation.query.filter_by(
|
||||
etudid=etud.id, id=annotation_id
|
||||
).first()
|
||||
if annotation is None:
|
||||
return json_error(404, "annotation not found")
|
||||
log(f"etudiant_annotation_delete/{etud.id}/{annotation.id}")
|
||||
db.session.delete(annotation)
|
||||
db.session.commit()
|
||||
return "ok"
|
||||
|
|
|
@ -52,7 +52,8 @@ def formations():
|
|||
@as_json
|
||||
def formations_ids():
|
||||
"""
|
||||
Retourne la liste de toutes les id de formations (tous départements)
|
||||
Retourne la liste de toutes les id de formations
|
||||
(tous départements, ou du département indiqué dans la route)
|
||||
|
||||
Exemple de résultat : [ 17, 99, 32 ]
|
||||
"""
|
||||
|
@ -328,6 +329,8 @@ def desassoc_ue_niveau(ue_id: int):
|
|||
ue.niveau_competence = None
|
||||
db.session.add(ue)
|
||||
db.session.commit()
|
||||
# Invalidation du cache
|
||||
ue.formation.invalidate_cached_sems()
|
||||
log(f"desassoc_ue_niveau: {ue}")
|
||||
if g.scodoc_dept:
|
||||
# "usage web"
|
||||
|
|
|
@ -12,7 +12,7 @@ from operator import attrgetter, itemgetter
|
|||
from flask import g, make_response, request
|
||||
from flask_json import as_json
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
import sqlalchemy as sa
|
||||
import app
|
||||
from app import db
|
||||
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
|
||||
|
@ -38,7 +38,7 @@ from app.scodoc import sco_groups
|
|||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.tables.recap import TableRecap
|
||||
from app.tables.recap import TableRecap, RowRecap
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>")
|
||||
|
@ -171,6 +171,44 @@ def formsemestres_query():
|
|||
]
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
|
||||
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
|
||||
@scodoc
|
||||
@permission_required(Permission.EditFormSemestre)
|
||||
@as_json
|
||||
def formsemestre_edit(formsemestre_id: int):
|
||||
"""Modifie les champs d'un formsemestre."""
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
args = request.get_json(force=True) # may raise 400 Bad Request
|
||||
editable_keys = {
|
||||
"semestre_id",
|
||||
"titre",
|
||||
"date_debut",
|
||||
"date_fin",
|
||||
"edt_id",
|
||||
"etat",
|
||||
"modalite",
|
||||
"gestion_compensation",
|
||||
"bul_hide_xml",
|
||||
"block_moyennes",
|
||||
"block_moyenne_generale",
|
||||
"mode_calcul_moyennes",
|
||||
"gestion_semestrielle",
|
||||
"bul_bgcolor",
|
||||
"resp_can_edit",
|
||||
"resp_can_change_ens",
|
||||
"ens_can_edit_eval",
|
||||
"elt_sem_apo",
|
||||
"elt_annee_apo",
|
||||
}
|
||||
formsemestre.from_dict({k: v for (k, v) in args.items() if k in editable_keys})
|
||||
try:
|
||||
db.session.commit()
|
||||
except sa.exc.StatementError as exc:
|
||||
return json_error(404, f"invalid argument(s): {exc.args[0]}")
|
||||
return formsemestre.to_dict_api()
|
||||
|
||||
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
|
||||
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
|
||||
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
|
||||
|
@ -426,7 +464,7 @@ def etat_evals(formsemestre_id: int):
|
|||
for modimpl_id in nt.modimpls_results:
|
||||
modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl_id]
|
||||
modimpl: ModuleImpl = ModuleImpl.query.get_or_404(modimpl_id)
|
||||
modimpl_dict = modimpl.to_dict(convert_objects=True)
|
||||
modimpl_dict = modimpl.to_dict(convert_objects=True, with_module=False)
|
||||
|
||||
list_eval = []
|
||||
for evaluation_id in modimpl_results.evaluations_etat:
|
||||
|
@ -468,13 +506,13 @@ def etat_evals(formsemestre_id: int):
|
|||
date_mediane = notes_sorted[len(notes_sorted) // 2].date
|
||||
|
||||
eval_dict["saisie_notes"] = {
|
||||
"datetime_debut": date_debut.isoformat()
|
||||
if date_debut is not None
|
||||
else None,
|
||||
"datetime_debut": (
|
||||
date_debut.isoformat() if date_debut is not None else None
|
||||
),
|
||||
"datetime_fin": date_fin.isoformat() if date_fin is not None else None,
|
||||
"datetime_mediane": date_mediane.isoformat()
|
||||
if date_mediane is not None
|
||||
else None,
|
||||
"datetime_mediane": (
|
||||
date_mediane.isoformat() if date_mediane is not None else None
|
||||
),
|
||||
}
|
||||
|
||||
list_eval.append(eval_dict)
|
||||
|
@ -505,16 +543,30 @@ def formsemestre_resultat(formsemestre_id: int):
|
|||
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
|
||||
app.set_sco_dept(formsemestre.departement.acronym)
|
||||
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
|
||||
table = TableRecap(
|
||||
res, convert_values=convert_values, include_evaluations=False, mode_jury=False
|
||||
)
|
||||
# Supprime les champs inutiles (mise en forme)
|
||||
rows = table.to_list()
|
||||
# Ajoute le groupe de chaque partition:
|
||||
# Ajoute le groupe de chaque partition,
|
||||
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
|
||||
for row in rows:
|
||||
row["partitions"] = etud_groups.get(row["etudid"], {})
|
||||
|
||||
class RowRecapAPI(RowRecap):
|
||||
"""Pour table avec partitions et sort_key"""
|
||||
|
||||
def add_etud_cols(self):
|
||||
"""Ajoute colonnes étudiant: codes, noms"""
|
||||
super().add_etud_cols()
|
||||
self.add_cell("partitions", "partitions", etud_groups.get(self.etud.id, {}))
|
||||
self.add_cell("sort_key", "sort_key", self.etud.sort_key)
|
||||
|
||||
table = TableRecap(
|
||||
res,
|
||||
convert_values=convert_values,
|
||||
include_evaluations=False,
|
||||
mode_jury=False,
|
||||
row_class=RowRecapAPI,
|
||||
)
|
||||
|
||||
rows = table.to_list()
|
||||
|
||||
# for row in rows:
|
||||
# row["partitions"] = etud_groups.get(row["etudid"], {})
|
||||
return rows
|
||||
|
||||
|
||||
|
|
|
@ -20,14 +20,8 @@ from app.api import api_bp as bp
|
|||
from app.api import api_web_bp
|
||||
from app.api import get_model_api_object, tools
|
||||
from app.decorators import permission_required, scodoc
|
||||
from app.models import (
|
||||
Identite,
|
||||
Justificatif,
|
||||
Departement,
|
||||
FormSemestre,
|
||||
)
|
||||
from app.models import Identite, Justificatif, Departement, FormSemestre, Scolog
|
||||
from app.models.assiduites import (
|
||||
compute_assiduites_justified,
|
||||
get_formsemestre_from_data,
|
||||
)
|
||||
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
|
||||
|
@ -315,7 +309,6 @@ def justif_create(etudid: int = None, nip=None, ine=None):
|
|||
|
||||
errors: list[dict] = []
|
||||
success: list[dict] = []
|
||||
justifs: list[Justificatif] = []
|
||||
|
||||
# énumération des justificatifs
|
||||
for i, data in enumerate(create_list):
|
||||
|
@ -327,11 +320,9 @@ def justif_create(etudid: int = None, nip=None, ine=None):
|
|||
errors.append({"indice": i, "message": obj})
|
||||
else:
|
||||
success.append({"indice": i, "message": obj})
|
||||
justifs.append(justi)
|
||||
justi.justifier_assiduites()
|
||||
scass.simple_invalidate_cache(data, etud.id)
|
||||
|
||||
# Actualisation des assiduités justifiées en fonction de tous les nouveaux justificatifs
|
||||
compute_assiduites_justified(etud.etudid, justifs)
|
||||
return {"errors": errors, "success": success}
|
||||
|
||||
|
||||
|
@ -500,9 +491,16 @@ def justif_edit(justif_id: int):
|
|||
return json_error(404, err)
|
||||
|
||||
# Mise à jour du justificatif
|
||||
justificatif_unique.dejustifier_assiduites()
|
||||
db.session.add(justificatif_unique)
|
||||
db.session.commit()
|
||||
|
||||
Scolog.logdb(
|
||||
method="edit_justificatif",
|
||||
etudid=justificatif_unique.etudiant.id,
|
||||
msg=f"justificatif modif: {justificatif_unique}",
|
||||
)
|
||||
|
||||
# Génération du dictionnaire de retour
|
||||
# La couverture correspond
|
||||
# - aux assiduités précédemment justifiées par le justificatif
|
||||
|
@ -510,11 +508,7 @@ def justif_edit(justif_id: int):
|
|||
retour = {
|
||||
"couverture": {
|
||||
"avant": avant_ids,
|
||||
"apres": compute_assiduites_justified(
|
||||
justificatif_unique.etudid,
|
||||
[justificatif_unique],
|
||||
True,
|
||||
),
|
||||
"apres": justificatif_unique.justifier_assiduites(),
|
||||
}
|
||||
}
|
||||
# Invalide le cache
|
||||
|
@ -591,14 +585,10 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
|
|||
|
||||
# On invalide le cache
|
||||
scass.simple_invalidate_cache(justificatif_unique.to_dict())
|
||||
# On actualise les assiduités justifiées de l'étudiant concerné
|
||||
justificatif_unique.dejustifier_assiduites()
|
||||
# On supprime le justificatif
|
||||
db.session.delete(justificatif_unique)
|
||||
# On actualise les assiduités justifiées de l'étudiant concerné
|
||||
compute_assiduites_justified(
|
||||
justificatif_unique.etudid,
|
||||
Justificatif.query.filter_by(etudid=justificatif_unique.etudid).all(),
|
||||
True,
|
||||
)
|
||||
|
||||
return (200, "OK")
|
||||
|
||||
|
@ -699,7 +689,6 @@ def justif_export(justif_id: int | None = None, filename: str | None = None):
|
|||
@as_json
|
||||
@permission_required(Permission.AbsChange)
|
||||
def justif_remove(justif_id: int = None):
|
||||
# XXX TODO pas de test unitaire
|
||||
"""
|
||||
Supression d'un fichier ou d'une archive
|
||||
{
|
||||
|
|
|
@ -311,6 +311,13 @@ def group_create(partition_id: int): # partition-group-create
|
|||
args["group_name"] = args["group_name"].strip()
|
||||
if not GroupDescr.check_name(partition, args["group_name"]):
|
||||
return json_error(API_CLIENT_ERROR, "invalid group_name")
|
||||
|
||||
# le numero est optionnel
|
||||
numero = args.get("numero")
|
||||
if numero is None:
|
||||
numeros = [gr.numero or 0 for gr in partition.groups]
|
||||
numero = (max(numeros) + 1) if numeros else 0
|
||||
args["numero"] = numero
|
||||
args["partition_id"] = partition_id
|
||||
try:
|
||||
group = GroupDescr(**args)
|
||||
|
|
|
@ -102,6 +102,8 @@ class User(UserMixin, ScoDocModel):
|
|||
token = db.Column(db.Text(), index=True, unique=True)
|
||||
token_expiration = db.Column(db.DateTime)
|
||||
|
||||
# Define the back reference from User to ModuleImpl
|
||||
modimpls = db.relationship("ModuleImpl", back_populates="responsable")
|
||||
roles = db.relationship("Role", secondary="user_role", viewonly=True)
|
||||
Permission = Permission
|
||||
|
||||
|
@ -245,24 +247,26 @@ class User(UserMixin, ScoDocModel):
|
|||
def to_dict(self, include_email=True):
|
||||
"""l'utilisateur comme un dict, avec des champs supplémentaires"""
|
||||
data = {
|
||||
"date_expiration": self.date_expiration.isoformat() + "Z"
|
||||
if self.date_expiration
|
||||
else None,
|
||||
"date_modif_passwd": self.date_modif_passwd.isoformat() + "Z"
|
||||
if self.date_modif_passwd
|
||||
else None,
|
||||
"date_created": self.date_created.isoformat() + "Z"
|
||||
if self.date_created
|
||||
else None,
|
||||
"date_expiration": (
|
||||
self.date_expiration.isoformat() + "Z" if self.date_expiration else None
|
||||
),
|
||||
"date_modif_passwd": (
|
||||
self.date_modif_passwd.isoformat() + "Z"
|
||||
if self.date_modif_passwd
|
||||
else None
|
||||
),
|
||||
"date_created": (
|
||||
self.date_created.isoformat() + "Z" if self.date_created else None
|
||||
),
|
||||
"dept": self.dept,
|
||||
"id": self.id,
|
||||
"active": self.active,
|
||||
"cas_id": self.cas_id,
|
||||
"cas_allow_login": self.cas_allow_login,
|
||||
"cas_allow_scodoc_login": self.cas_allow_scodoc_login,
|
||||
"cas_last_login": self.cas_last_login.isoformat() + "Z"
|
||||
if self.cas_last_login
|
||||
else None,
|
||||
"cas_last_login": (
|
||||
self.cas_last_login.isoformat() + "Z" if self.cas_last_login else None
|
||||
),
|
||||
"edt_id": self.edt_id,
|
||||
"status_txt": "actif" if self.active else "fermé",
|
||||
"last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None,
|
||||
|
@ -477,8 +481,8 @@ class User(UserMixin, ScoDocModel):
|
|||
return f"{nom} {scu.format_prenom(self.prenom)} ({self.user_name})"
|
||||
|
||||
@staticmethod
|
||||
def get_user_id_from_nomplogin(nomplogin: str) -> Optional[int]:
|
||||
"""Returns id from the string "Dupont Pierre (dupont)"
|
||||
def get_user_from_nomplogin(nomplogin: str) -> Optional["User"]:
|
||||
"""Returns User instance from the string "Dupont Pierre (dupont)"
|
||||
or None if user does not exist
|
||||
"""
|
||||
match = re.match(r".*\((.*)\)", nomplogin.strip())
|
||||
|
@ -486,7 +490,7 @@ class User(UserMixin, ScoDocModel):
|
|||
user_name = match.group(1)
|
||||
u = User.query.filter_by(user_name=user_name).first()
|
||||
if u:
|
||||
return u.id
|
||||
return u
|
||||
return None
|
||||
|
||||
def get_nom_fmt(self):
|
||||
|
@ -599,8 +603,19 @@ class Role(db.Model):
|
|||
"""Create default roles if missing, then, if reset_permissions,
|
||||
reset their permissions to default values.
|
||||
"""
|
||||
Role.reset_roles_permissions(
|
||||
SCO_ROLES_DEFAULTS, reset_permissions=reset_permissions
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def reset_roles_permissions(roles_perms: dict[str, tuple], reset_permissions=True):
|
||||
"""Ajoute les permissions aux roles
|
||||
roles_perms : { "role_name" : (permission, ...) }
|
||||
reset_permissions : si vrai efface permissions déja existantes
|
||||
Si le role n'existe pas, il est (re) créé.
|
||||
"""
|
||||
default_role = "Observateur"
|
||||
for role_name, permissions in SCO_ROLES_DEFAULTS.items():
|
||||
for role_name, permissions in roles_perms.items():
|
||||
role = Role.query.filter_by(name=role_name).first()
|
||||
if role is None:
|
||||
role = Role(name=role_name)
|
||||
|
|
|
@ -21,7 +21,7 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
|
|||
return ""
|
||||
ref_comp = ue.formation.referentiel_competence
|
||||
if ref_comp is None:
|
||||
return f"""<div class="ue_advanced">
|
||||
return f"""<div class="scobox ue_advanced">
|
||||
<div class="warning">Pas de référentiel de compétence associé à cette formation !</div>
|
||||
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
|
||||
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)
|
||||
|
@ -31,19 +31,28 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
|
|||
|
||||
H = [
|
||||
"""
|
||||
<div class="ue_advanced">
|
||||
<h3>Parcours du BUT</h3>
|
||||
<div class="scobox ue_advanced">
|
||||
<div class="scobox-title">Parcours du BUT</div>
|
||||
"""
|
||||
]
|
||||
# Choix des parcours
|
||||
ue_pids = [p.id for p in ue.parcours]
|
||||
H.append("""<form id="choix_parcours">""")
|
||||
H.append(
|
||||
"""
|
||||
<div class="help">
|
||||
Cocher tous les parcours dans lesquels cette UE est utilisée,
|
||||
même si vous n'offrez pas ce parcours dans votre département.
|
||||
Sans quoi, les UEs de Tronc Commun ne seront pas reconnues.
|
||||
Ne cocher aucun parcours est équivalent à tous les cocher.
|
||||
</div>
|
||||
<form id="choix_parcours" style="margin-top: 12px;">
|
||||
"""
|
||||
)
|
||||
|
||||
ects_differents = {
|
||||
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours
|
||||
} != {None}
|
||||
for parcour in ref_comp.parcours:
|
||||
ects_parcour = ue.get_ects(parcour)
|
||||
ects_parcour_txt = (
|
||||
f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else ""
|
||||
)
|
||||
|
|
|
@ -9,12 +9,14 @@
|
|||
|
||||
import collections
|
||||
import datetime
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from flask import g, has_request_context, url_for
|
||||
|
||||
from app import db
|
||||
from app.comp.moy_mod import ModuleImplResults
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
from app.models import Evaluation, FormSemestre, Identite
|
||||
from app.models import Evaluation, FormSemestre, Identite, ModuleImpl
|
||||
from app.models.groups import GroupDescr
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc import sco_bulletins, sco_utils as scu
|
||||
|
@ -104,9 +106,11 @@ class BulletinBUT:
|
|||
"competence": None, # XXX TODO lien avec référentiel
|
||||
"moyenne": None,
|
||||
# Le bonus sport appliqué sur cette UE
|
||||
"bonus": fmt_note(res.bonus_ues[ue.id][etud.id])
|
||||
if res.bonus_ues is not None and ue.id in res.bonus_ues
|
||||
else fmt_note(0.0),
|
||||
"bonus": (
|
||||
fmt_note(res.bonus_ues[ue.id][etud.id])
|
||||
if res.bonus_ues is not None and ue.id in res.bonus_ues
|
||||
else fmt_note(0.0)
|
||||
),
|
||||
"malus": fmt_note(res.malus[ue.id][etud.id]),
|
||||
"capitalise": None, # "AAAA-MM-JJ" TODO #sco93
|
||||
"ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
|
||||
|
@ -181,14 +185,16 @@ class BulletinBUT:
|
|||
"is_external": ue_capitalisee.is_external,
|
||||
"date_capitalisation": ue_capitalisee.event_date,
|
||||
"formsemestre_id": ue_capitalisee.formsemestre_id,
|
||||
"bul_orig_url": url_for(
|
||||
"notes.formsemestre_bulletinetud",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etud.id,
|
||||
formsemestre_id=ue_capitalisee.formsemestre_id,
|
||||
)
|
||||
if ue_capitalisee.formsemestre_id
|
||||
else None,
|
||||
"bul_orig_url": (
|
||||
url_for(
|
||||
"notes.formsemestre_bulletinetud",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etud.id,
|
||||
formsemestre_id=ue_capitalisee.formsemestre_id,
|
||||
)
|
||||
if ue_capitalisee.formsemestre_id
|
||||
else None
|
||||
),
|
||||
"ressources": {}, # sans détail en BUT
|
||||
"saes": {},
|
||||
}
|
||||
|
@ -225,15 +231,17 @@ class BulletinBUT:
|
|||
if res.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit
|
||||
d[modimpl.module.code] = {
|
||||
"id": modimpl.id,
|
||||
"titre": modimpl.module.titre,
|
||||
"titre": modimpl.module.titre_str(),
|
||||
"code_apogee": modimpl.module.code_apogee,
|
||||
"url": url_for(
|
||||
"notes.moduleimpl_status",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
moduleimpl_id=modimpl.id,
|
||||
)
|
||||
if has_request_context()
|
||||
else "na",
|
||||
"url": (
|
||||
url_for(
|
||||
"notes.moduleimpl_status",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
moduleimpl_id=modimpl.id,
|
||||
)
|
||||
if has_request_context()
|
||||
else "na"
|
||||
),
|
||||
"moyenne": {
|
||||
# # moyenne indicative de module: moyenne des UE,
|
||||
# # ignorant celles sans notes (nan)
|
||||
|
@ -242,68 +250,115 @@ class BulletinBUT:
|
|||
# "max": fmt_note(moyennes_etuds.max()),
|
||||
# "moy": fmt_note(moyennes_etuds.mean()),
|
||||
},
|
||||
"evaluations": [
|
||||
self.etud_eval_results(etud, e)
|
||||
for e in modimpl.evaluations
|
||||
if (e.visibulletin or version == "long")
|
||||
and (e.id in modimpl_results.evaluations_etat)
|
||||
and (
|
||||
modimpl_results.evaluations_etat[e.id].is_complete
|
||||
or self.prefs["bul_show_all_evals"]
|
||||
"evaluations": (
|
||||
self.etud_list_modimpl_evaluations(
|
||||
etud, modimpl, modimpl_results, version
|
||||
)
|
||||
]
|
||||
if version != "short"
|
||||
else [],
|
||||
if version != "short"
|
||||
else []
|
||||
),
|
||||
}
|
||||
return d
|
||||
|
||||
def etud_eval_results(self, etud, e: Evaluation) -> dict:
|
||||
def etud_list_modimpl_evaluations(
|
||||
self,
|
||||
etud: Identite,
|
||||
modimpl: ModuleImpl,
|
||||
modimpl_results: ModuleImplResults,
|
||||
version: str,
|
||||
) -> list[dict]:
|
||||
"""Liste des résultats aux évaluations de ce modimpl à montrer pour cet étudiant"""
|
||||
evaluation: Evaluation
|
||||
eval_results = []
|
||||
for evaluation in modimpl.evaluations:
|
||||
if (
|
||||
(evaluation.visibulletin or version == "long")
|
||||
and (evaluation.id in modimpl_results.evaluations_etat)
|
||||
and (
|
||||
modimpl_results.evaluations_etat[evaluation.id].is_complete
|
||||
or self.prefs["bul_show_all_evals"]
|
||||
)
|
||||
):
|
||||
eval_notes = self.res.modimpls_results[modimpl.id].evals_notes[
|
||||
evaluation.id
|
||||
]
|
||||
|
||||
if (evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE) or (
|
||||
not np.isnan(eval_notes[etud.id])
|
||||
):
|
||||
eval_results.append(
|
||||
self.etud_eval_results(etud, evaluation, eval_notes)
|
||||
)
|
||||
return eval_results
|
||||
|
||||
def etud_eval_results(
|
||||
self, etud: Identite, evaluation: Evaluation, eval_notes: pd.DataFrame
|
||||
) -> dict:
|
||||
"dict resultats d'un étudiant à une évaluation"
|
||||
# eval_notes est une pd.Series avec toutes les notes des étudiants inscrits
|
||||
eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id]
|
||||
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
|
||||
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
|
||||
modimpls_evals_poids = self.res.modimpls_evals_poids[evaluation.moduleimpl_id]
|
||||
try:
|
||||
etud_ues_ids = self.res.etud_ues_ids(etud.id)
|
||||
poids = {
|
||||
ue.acronyme: modimpls_evals_poids[ue.id][e.id]
|
||||
ue.acronyme: modimpls_evals_poids[ue.id][evaluation.id]
|
||||
for ue in self.res.ues
|
||||
if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
|
||||
}
|
||||
except KeyError:
|
||||
poids = collections.defaultdict(lambda: 0.0)
|
||||
d = {
|
||||
"id": e.id,
|
||||
"coef": fmt_note(e.coefficient)
|
||||
if e.evaluation_type == scu.EVALUATION_NORMALE
|
||||
else None,
|
||||
"date_debut": e.date_debut.isoformat() if e.date_debut else None,
|
||||
"date_fin": e.date_fin.isoformat() if e.date_fin else None,
|
||||
"description": e.description,
|
||||
"evaluation_type": e.evaluation_type,
|
||||
"note": {
|
||||
"value": fmt_note(
|
||||
eval_notes[etud.id],
|
||||
note_max=e.note_max,
|
||||
),
|
||||
"min": fmt_note(notes_ok.min(), note_max=e.note_max),
|
||||
"max": fmt_note(notes_ok.max(), note_max=e.note_max),
|
||||
"moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
|
||||
},
|
||||
"id": evaluation.id,
|
||||
"coef": (
|
||||
fmt_note(evaluation.coefficient)
|
||||
if evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE
|
||||
else None
|
||||
),
|
||||
"date_debut": (
|
||||
evaluation.date_debut.isoformat() if evaluation.date_debut else None
|
||||
),
|
||||
"date_fin": (
|
||||
evaluation.date_fin.isoformat() if evaluation.date_fin else None
|
||||
),
|
||||
"description": evaluation.description,
|
||||
"evaluation_type": evaluation.evaluation_type,
|
||||
"note": (
|
||||
{
|
||||
"value": fmt_note(
|
||||
eval_notes[etud.id],
|
||||
note_max=evaluation.note_max,
|
||||
),
|
||||
"min": fmt_note(notes_ok.min(), note_max=evaluation.note_max),
|
||||
"max": fmt_note(notes_ok.max(), note_max=evaluation.note_max),
|
||||
"moy": fmt_note(notes_ok.mean(), note_max=evaluation.note_max),
|
||||
}
|
||||
if not evaluation.is_blocked()
|
||||
else {}
|
||||
),
|
||||
"poids": poids,
|
||||
"url": url_for(
|
||||
"notes.evaluation_listenotes",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
evaluation_id=e.id,
|
||||
)
|
||||
if has_request_context()
|
||||
else "na",
|
||||
"url": (
|
||||
url_for(
|
||||
"notes.evaluation_listenotes",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
evaluation_id=evaluation.id,
|
||||
)
|
||||
if has_request_context()
|
||||
else "na"
|
||||
),
|
||||
# deprecated (supprimer avant #sco9.7)
|
||||
"date": e.date_debut.isoformat() if e.date_debut else None,
|
||||
"heure_debut": e.date_debut.time().isoformat("minutes")
|
||||
if e.date_debut
|
||||
else None,
|
||||
"heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None,
|
||||
"date": (
|
||||
evaluation.date_debut.isoformat() if evaluation.date_debut else None
|
||||
),
|
||||
"heure_debut": (
|
||||
evaluation.date_debut.time().isoformat("minutes")
|
||||
if evaluation.date_debut
|
||||
else None
|
||||
),
|
||||
"heure_fin": (
|
||||
evaluation.date_fin.time().isoformat("minutes")
|
||||
if evaluation.date_fin
|
||||
else None
|
||||
),
|
||||
}
|
||||
return d
|
||||
|
||||
|
@ -343,25 +398,18 @@ class BulletinBUT:
|
|||
"short" : ne descend pas plus bas que les modules.
|
||||
|
||||
- Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai
|
||||
(bulletins non publiés).
|
||||
(bulletins non publiés sur la passerelle).
|
||||
"""
|
||||
if version not in scu.BULLETINS_VERSIONS_BUT:
|
||||
raise ScoValueError("bulletin_etud: version de bulletin demandée invalide")
|
||||
res = self.res
|
||||
formsemestre = res.formsemestre
|
||||
etat_inscription = etud.inscription_etat(formsemestre.id)
|
||||
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
|
||||
published = (not formsemestre.bul_hide_xml) or force_publishing
|
||||
if formsemestre.formation.referentiel_competence is None:
|
||||
etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
|
||||
else:
|
||||
etud_ues_ids = res.etud_ues_ids(etud.id)
|
||||
|
||||
d = {
|
||||
"version": "0",
|
||||
"type": "BUT",
|
||||
"date": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
"publie": not formsemestre.bul_hide_xml,
|
||||
"etat_inscription": etud.inscription_etat(formsemestre.id),
|
||||
"etudiant": etud.to_dict_bul(),
|
||||
"formation": {
|
||||
"id": formsemestre.formation.id,
|
||||
|
@ -370,15 +418,21 @@ class BulletinBUT:
|
|||
"titre": formsemestre.formation.titre,
|
||||
},
|
||||
"formsemestre_id": formsemestre.id,
|
||||
"etat_inscription": etat_inscription,
|
||||
"options": sco_preferences.bulletin_option_affichage(
|
||||
formsemestre, self.prefs
|
||||
),
|
||||
}
|
||||
if not published:
|
||||
published = (not formsemestre.bul_hide_xml) or force_publishing
|
||||
if not published or d["etat_inscription"] is False:
|
||||
return d
|
||||
|
||||
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
|
||||
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
|
||||
if formsemestre.formation.referentiel_competence is None:
|
||||
etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
|
||||
else:
|
||||
etud_ues_ids = res.etud_ues_ids(etud.id)
|
||||
|
||||
nbabsnj, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
|
||||
etud_groups = sco_groups.get_etud_formsemestre_groups(
|
||||
etud, formsemestre, only_to_show=True
|
||||
)
|
||||
|
@ -393,7 +447,7 @@ class BulletinBUT:
|
|||
}
|
||||
if self.prefs["bul_show_abs"]:
|
||||
semestre_infos["absences"] = {
|
||||
"injustifie": nbabs - nbabsjust,
|
||||
"injustifie": nbabsnj,
|
||||
"total": nbabs,
|
||||
"metrique": {
|
||||
"H.": "Heure(s)",
|
||||
|
@ -410,7 +464,7 @@ class BulletinBUT:
|
|||
semestre_infos.update(
|
||||
sco_bulletins_json.dict_decision_jury(etud, formsemestre)
|
||||
)
|
||||
if etat_inscription == scu.INSCRIT:
|
||||
if d["etat_inscription"] == scu.INSCRIT:
|
||||
# moyenne des moyennes générales du semestre
|
||||
semestre_infos["notes"] = {
|
||||
"value": fmt_note(res.etud_moy_gen[etud.id]),
|
||||
|
@ -510,7 +564,7 @@ class BulletinBUT:
|
|||
d["demission"] = ""
|
||||
|
||||
# --- Absences
|
||||
d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id)
|
||||
_, d["nbabsjust"], d["nbabs"] = self.res.formsemestre.get_abs_count(etud.id)
|
||||
|
||||
# --- Decision Jury
|
||||
infos, _ = sco_bulletins.etud_descr_situation_semestre(
|
||||
|
@ -525,9 +579,9 @@ class BulletinBUT:
|
|||
|
||||
d.update(infos)
|
||||
# --- Rangs
|
||||
d[
|
||||
"rang_nt"
|
||||
] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
|
||||
d["rang_nt"] = (
|
||||
f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
|
||||
)
|
||||
d["rang_txt"] = "Rang " + d["rang_nt"]
|
||||
|
||||
d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))
|
||||
|
|
|
@ -35,7 +35,6 @@ from app.decorators import (
|
|||
permission_required,
|
||||
)
|
||||
from app.models import FormSemestre, FormSemestreInscription, Identite
|
||||
from app.scodoc import sco_bulletins_pdf
|
||||
from app.scodoc.codes_cursus import UE_STANDARD
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
|
||||
from app.scodoc.sco_logos import find_logo
|
||||
|
@ -120,6 +119,14 @@ def _build_bulletin_but_infos(
|
|||
refcomp = formsemestre.formation.referentiel_competence
|
||||
if refcomp is None:
|
||||
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
|
||||
|
||||
warn_html = cursus_but.formsemestre_warning_apc_setup(
|
||||
formsemestre, bulletins_sem.res
|
||||
)
|
||||
if warn_html:
|
||||
raise ScoValueError(
|
||||
"<b>Formation mal configurée pour le BUT</b>" + warn_html, safe=True
|
||||
)
|
||||
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
|
||||
refcomp, etud
|
||||
)
|
||||
|
|
|
@ -31,6 +31,7 @@ from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
|
|||
from app.scodoc.sco_logos import Logo
|
||||
from app.scodoc.sco_pdf import PDFLOCK, SU
|
||||
from app.scodoc.sco_preferences import SemPreferences
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
def make_bulletin_but_court_pdf(
|
||||
|
@ -194,7 +195,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
"""Génère la partie "titre" du bulletin de notes.
|
||||
Renvoie une liste d'objets platypus
|
||||
"""
|
||||
# comme les bulletins standard, mais avec notre préférence
|
||||
# comme les bulletins standards, mais avec notre préférence
|
||||
return super().bul_title_pdf(preference_field=preference_field)
|
||||
|
||||
def bul_part_below(self, fmt="pdf") -> list:
|
||||
|
@ -343,9 +344,11 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
for mod in self.bul[mod_type]:
|
||||
row = [mod, bul[mod_type][mod]["titre"]]
|
||||
row += [
|
||||
bul["ues"][ue][mod_type][mod]["moyenne"]
|
||||
if mod in bul["ues"][ue][mod_type]
|
||||
else ""
|
||||
(
|
||||
bul["ues"][ue][mod_type][mod]["moyenne"]
|
||||
if mod in bul["ues"][ue][mod_type]
|
||||
else ""
|
||||
)
|
||||
for ue in self.ues_acronyms
|
||||
]
|
||||
rows.append(row)
|
||||
|
@ -406,6 +409,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
|
||||
def boite_identite(self) -> list:
|
||||
"Les informations sur l'identité et l'inscription de l'étudiant"
|
||||
parcour = self.formsemestre.etuds_inscriptions[self.etud.id].parcour
|
||||
|
||||
return [
|
||||
Paragraph(
|
||||
SU(f"""{self.etud.nomprenom}"""),
|
||||
|
@ -416,7 +421,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
f"""
|
||||
<b>{self.bul["demission"]}</b><br/>
|
||||
Formation: {self.formsemestre.titre_num()}<br/>
|
||||
Année scolaire: {self.formsemestre.annee_scolaire_str()}<br/>
|
||||
{'Parcours ' + parcour.code + '<br/>' if parcour else ''}
|
||||
Année universitaire: {self.formsemestre.annee_scolaire_str()}<br/>
|
||||
"""
|
||||
),
|
||||
style=self.style_base,
|
||||
|
@ -520,7 +526,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
|
|||
if self.bul["semestre"].get("decision_annee", None):
|
||||
txt += f"""
|
||||
Décision saisie le {
|
||||
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime("%d/%m/%Y")
|
||||
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime(scu.DATE_FMT)
|
||||
}, année BUT{self.bul["semestre"]["decision_annee"]["ordre"]}
|
||||
<b>{self.bul["semestre"]["decision_annee"]["code"]}</b>.
|
||||
<br/>
|
||||
|
|
|
@ -24,7 +24,7 @@ from reportlab.lib.colors import blue
|
|||
from reportlab.lib.units import cm, mm
|
||||
from reportlab.platypus import Paragraph, Spacer
|
||||
|
||||
from app.models import ScoDocSiteConfig
|
||||
from app.models import Evaluation, ScoDocSiteConfig
|
||||
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
|
||||
from app.scodoc import gen_tables
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
|
@ -73,6 +73,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
html_class="notes_bulletin",
|
||||
html_class_ignore_default=True,
|
||||
html_with_td_classes=True,
|
||||
table_id="bul-table",
|
||||
)
|
||||
table_objects = table.gen(fmt=fmt)
|
||||
objects += table_objects
|
||||
|
@ -269,7 +270,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
date_capitalisation = ue.get("date_capitalisation")
|
||||
if date_capitalisation:
|
||||
fields_bmr.append(
|
||||
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
|
||||
f"""Capitalisée le {date_capitalisation.strftime(scu.DATE_FMT)}"""
|
||||
)
|
||||
t = {
|
||||
"titre": " - ".join(fields_bmr),
|
||||
|
@ -422,16 +423,22 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
|
|||
def evaluations_rows(self, rows, evaluations: list[dict], ue_acros=()):
|
||||
"lignes des évaluations"
|
||||
for e in evaluations:
|
||||
coef = e["coef"] if e["evaluation_type"] == scu.EVALUATION_NORMALE else "*"
|
||||
coef = (
|
||||
e["coef"]
|
||||
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
|
||||
else "*"
|
||||
)
|
||||
note_value = e["note"].get("value", "")
|
||||
t = {
|
||||
"titre": f"{e['description'] or ''}",
|
||||
"moyenne": e["note"]["value"],
|
||||
"_moyenne_pdf": Paragraph(
|
||||
f"""<para align=right>{e["note"]["value"]}</para>"""
|
||||
),
|
||||
"moyenne": note_value,
|
||||
"_moyenne_pdf": Paragraph(f"""<para align=right>{note_value}</para>"""),
|
||||
"coef": coef,
|
||||
"_coef_pdf": Paragraph(
|
||||
f"<para align=right fontSize={self.small_fontsize}><i>{coef}</i></para>"
|
||||
f"""<para align=right fontSize={self.small_fontsize}><i>{
|
||||
coef if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
|
||||
else "bonus"
|
||||
}</i></para>"""
|
||||
),
|
||||
"_pdf_style": [
|
||||
(
|
||||
|
|
|
@ -38,14 +38,11 @@ import datetime
|
|||
from xml.etree import ElementTree
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app import log
|
||||
from app import db, log
|
||||
from app.but import bulletin_but
|
||||
from app.models import BulAppreciations, FormSemestre, Identite
|
||||
from app.models import BulAppreciations, FormSemestre, Identite, UniteEns
|
||||
import app.scodoc.sco_utils as scu
|
||||
import app.scodoc.notesdb as ndb
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_edit_ue
|
||||
from app.scodoc import sco_etud
|
||||
from app.scodoc import sco_photos
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_xml
|
||||
|
@ -202,12 +199,12 @@ def bulletin_but_xml_compat(
|
|||
if e.visibulletin or version == "long":
|
||||
x_eval = Element(
|
||||
"evaluation",
|
||||
date_debut=e.date_debut.isoformat()
|
||||
if e.date_debut
|
||||
else "",
|
||||
date_fin=e.date_fin.isoformat()
|
||||
if e.date_debut
|
||||
else "",
|
||||
date_debut=(
|
||||
e.date_debut.isoformat() if e.date_debut else ""
|
||||
),
|
||||
date_fin=(
|
||||
e.date_fin.isoformat() if e.date_debut else ""
|
||||
),
|
||||
coefficient=str(e.coefficient),
|
||||
# pas les poids en XML compat
|
||||
evaluation_type=str(e.evaluation_type),
|
||||
|
@ -215,9 +212,9 @@ def bulletin_but_xml_compat(
|
|||
# notes envoyées sur 20, ceci juste pour garder trace:
|
||||
note_max_origin=str(e.note_max),
|
||||
# --- deprecated
|
||||
jour=e.date_debut.isoformat()
|
||||
if e.date_debut
|
||||
else "",
|
||||
jour=(
|
||||
e.date_debut.isoformat() if e.date_debut else ""
|
||||
),
|
||||
heure_debut=e.heure_debut(),
|
||||
heure_fin=e.heure_fin(),
|
||||
)
|
||||
|
@ -244,7 +241,7 @@ def bulletin_but_xml_compat(
|
|||
|
||||
# --- Absences
|
||||
if sco_preferences.get_preference("bul_show_abs", formsemestre_id):
|
||||
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
|
||||
_, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
|
||||
doc.append(Element("absences", nbabs=str(nbabs), nbabsjust=str(nbabsjust)))
|
||||
|
||||
# -------- LA SUITE EST COPIEE SANS MODIF DE sco_bulletins_xml.py ---------
|
||||
|
@ -294,17 +291,18 @@ def bulletin_but_xml_compat(
|
|||
"decisions_ue"
|
||||
]: # and sco_preferences.get_preference( 'bul_show_uevalid', formsemestre_id): always publish (car utile pour export Apogee)
|
||||
for ue_id in decision["decisions_ue"].keys():
|
||||
ue = sco_edit_ue.ue_list({"ue_id": ue_id})[0]
|
||||
doc.append(
|
||||
Element(
|
||||
"decision_ue",
|
||||
ue_id=str(ue["ue_id"]),
|
||||
numero=quote_xml_attr(ue["numero"]),
|
||||
acronyme=quote_xml_attr(ue["acronyme"]),
|
||||
titre=quote_xml_attr(ue["titre"]),
|
||||
code=decision["decisions_ue"][ue_id]["code"],
|
||||
ue = db.session.get(UniteEns, ue_id)
|
||||
if ue:
|
||||
doc.append(
|
||||
Element(
|
||||
"decision_ue",
|
||||
ue_id=str(ue.id),
|
||||
numero=quote_xml_attr(ue.numero),
|
||||
acronyme=quote_xml_attr(ue.acronyme),
|
||||
titre=quote_xml_attr(ue.titre or ""),
|
||||
code=decision["decisions_ue"][ue_id]["code"],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
for aut in decision["autorisations"]:
|
||||
doc.append(
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Code expérimental: si deux référentiel sont presques identiques
|
||||
(mêmes compétences, niveaux, parcours)
|
||||
essaie de changer une formation de référentiel.
|
||||
"""
|
||||
|
||||
from app import clear_scodoc_cache, db
|
||||
|
||||
from app.models import (
|
||||
ApcParcours,
|
||||
ApcReferentielCompetences,
|
||||
ApcValidationRCUE,
|
||||
Formation,
|
||||
FormSemestreInscription,
|
||||
UniteEns,
|
||||
)
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
|
||||
|
||||
def formation_change_referentiel(
|
||||
formation: Formation, new_ref: ApcReferentielCompetences
|
||||
):
|
||||
"""Try to change ref."""
|
||||
if not formation.referentiel_competence:
|
||||
raise ScoValueError("formation non associée à un référentiel")
|
||||
if not isinstance(new_ref, ApcReferentielCompetences):
|
||||
raise ScoValueError("nouveau référentiel invalide")
|
||||
|
||||
r = formation.referentiel_competence.map_to_other_referentiel(new_ref)
|
||||
if isinstance(r, str):
|
||||
raise ScoValueError(f"référentiels incompatibles: {r}")
|
||||
parcours_map, competences_map, niveaux_map = r
|
||||
|
||||
formation.referentiel_competence = new_ref
|
||||
db.session.add(formation)
|
||||
# UEs - Niveaux et UEs - parcours
|
||||
for ue in formation.ues:
|
||||
if ue.niveau_competence:
|
||||
ue.niveau_competence_id = niveaux_map[ue.niveau_competence_id]
|
||||
db.session.add(ue)
|
||||
if ue.parcours:
|
||||
new_list = [ApcParcours.query.get(parcours_map[p.id]) for p in ue.parcours]
|
||||
ue.parcours.clear()
|
||||
ue.parcours.extend(new_list)
|
||||
db.session.add(ue)
|
||||
# Modules / parcours et app_critiques
|
||||
for module in formation.modules:
|
||||
if module.parcours:
|
||||
new_list = [
|
||||
ApcParcours.query.get(parcours_map[p.id]) for p in module.parcours
|
||||
]
|
||||
module.parcours.clear()
|
||||
module.parcours.extend(new_list)
|
||||
db.session.add(module)
|
||||
if module.app_critiques: # efface les apprentissages critiques
|
||||
module.app_critiques.clear()
|
||||
db.session.add(module)
|
||||
# ApcValidationRCUE
|
||||
for valid_rcue in ApcValidationRCUE.query.join(
|
||||
UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id
|
||||
).filter_by(formation_id=formation.id):
|
||||
if valid_rcue.parcour:
|
||||
valid_rcue.parcour_id = parcours_map[valid_rcue.parcour.id]
|
||||
db.session.add(valid_rcue)
|
||||
for valid_rcue in ApcValidationRCUE.query.join(
|
||||
UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id
|
||||
).filter_by(formation_id=formation.id):
|
||||
if valid_rcue.parcour:
|
||||
valid_rcue.parcour_id = parcours_map[valid_rcue.parcour.id]
|
||||
db.session.add(valid_rcue)
|
||||
# FormSemestre / parcours_formsemestre
|
||||
for formsemestre in formation.formsemestres:
|
||||
new_list = [
|
||||
ApcParcours.query.get(parcours_map[p.id]) for p in formsemestre.parcours
|
||||
]
|
||||
formsemestre.parcours.clear()
|
||||
formsemestre.parcours.extend(new_list)
|
||||
db.session.add(formsemestre)
|
||||
# FormSemestreInscription.parcour_id
|
||||
for inscr in FormSemestreInscription.query.filter_by(
|
||||
formsemestre_id=formsemestre.id
|
||||
).filter(FormSemestreInscription.parcour_id != None):
|
||||
if inscr.parcour_id is not None:
|
||||
inscr.parcour_id = parcours_map[inscr.parcour_id]
|
||||
#
|
||||
db.session.commit()
|
||||
clear_scodoc_cache()
|
|
@ -23,29 +23,21 @@ from app.comp.res_but import ResultatsSemestreBUT
|
|||
from app.comp.res_compat import NotesTableCompat
|
||||
|
||||
from app.models.but_refcomp import (
|
||||
ApcAnneeParcours,
|
||||
ApcCompetence,
|
||||
ApcNiveau,
|
||||
ApcParcours,
|
||||
ApcParcoursNiveauCompetence,
|
||||
ApcReferentielCompetences,
|
||||
)
|
||||
from app.models import Scolog, ScolarAutorisationInscription
|
||||
from app.models.but_validations import (
|
||||
ApcValidationAnnee,
|
||||
ApcValidationRCUE,
|
||||
)
|
||||
from app.models.ues import UEParcours
|
||||
from app.models.but_validations import ApcValidationRCUE
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.formations import Formation
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.ues import UniteEns
|
||||
from app.models.validations import ScolarFormSemestreValidation
|
||||
from app.scodoc import codes_cursus as sco_codes
|
||||
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
|
||||
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
|
||||
|
||||
from app.scodoc import sco_cursus_dut
|
||||
|
||||
|
||||
|
@ -119,8 +111,15 @@ class EtudCursusBUT:
|
|||
|
||||
self.validation_par_competence_et_annee = {}
|
||||
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
|
||||
validation_rcue: ApcValidationRCUE
|
||||
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
|
||||
niveau = validation_rcue.niveau()
|
||||
if niveau is None:
|
||||
raise ScoValueError(
|
||||
"""UE d'un RCUE non associée à un niveau de compétence.
|
||||
Vérifiez la formation et les associations de ses UEs.
|
||||
"""
|
||||
)
|
||||
if not niveau.competence.id in self.validation_par_competence_et_annee:
|
||||
self.validation_par_competence_et_annee[niveau.competence.id] = {}
|
||||
previous_validation = self.validation_par_competence_et_annee.get(
|
||||
|
@ -436,15 +435,38 @@ def formsemestre_warning_apc_setup(
|
|||
"""
|
||||
if not formsemestre.formation.is_apc():
|
||||
return ""
|
||||
url_formation = url_for(
|
||||
"notes.ue_table",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formation_id=formsemestre.formation.id,
|
||||
semestre_idx=formsemestre.semestre_id,
|
||||
)
|
||||
if formsemestre.formation.referentiel_competence is None:
|
||||
return f"""<div class="formsemestre_status_warning">
|
||||
La <a class="stdlink" href="{
|
||||
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
|
||||
}">formation n'est pas associée à un référentiel de compétence.</a>
|
||||
La <a class="stdlink" href="{url_formation}">formation
|
||||
n'est pas associée à un référentiel de compétence.</a>
|
||||
</div>
|
||||
"""
|
||||
# Vérifie les niveaux de chaque parcours
|
||||
H = []
|
||||
# Le semestre n'a pas de parcours, mais les UE ont des parcours ?
|
||||
if not formsemestre.parcours:
|
||||
nb_ues_sans_parcours = len(
|
||||
formsemestre.formation.query_ues_parcour(None)
|
||||
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
|
||||
.all()
|
||||
)
|
||||
nb_ues_tot = (
|
||||
UniteEns.query.filter_by(formation=formsemestre.formation, type=UE_STANDARD)
|
||||
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
|
||||
.count()
|
||||
)
|
||||
if nb_ues_sans_parcours != nb_ues_tot:
|
||||
H.append(
|
||||
"""Le semestre n'est associé à aucun parcours,
|
||||
mais les UEs de la formation ont des parcours
|
||||
"""
|
||||
)
|
||||
# Vérifie les niveaux de chaque parcours
|
||||
for parcour in formsemestre.parcours or [None]:
|
||||
annee = (formsemestre.semestre_id + 1) // 2
|
||||
niveaux_ids = {
|
||||
|
@ -469,7 +491,8 @@ def formsemestre_warning_apc_setup(
|
|||
if not H:
|
||||
return ""
|
||||
return f"""<div class="formsemestre_status_warning">
|
||||
Problème dans la configuration de la formation:
|
||||
Problème dans la
|
||||
<a class="stdlink" href="{url_formation}">configuration de la formation</a>:
|
||||
<ul>
|
||||
<li>{ '</li><li>'.join(H) }</li>
|
||||
</ul>
|
||||
|
@ -482,6 +505,79 @@ def formsemestre_warning_apc_setup(
|
|||
"""
|
||||
|
||||
|
||||
def formation_semestre_niveaux_warning(formation: Formation, semestre_idx: int) -> str:
|
||||
"""Vérifie que tous les niveaux de compétences de cette année de formation
|
||||
ont bien des UEs.
|
||||
Afin de ne pas générer trop de messages, on ne considère que les parcours
|
||||
du référentiel de compétences pour lesquels au moins une UE a été associée.
|
||||
|
||||
Renvoie fragment de html
|
||||
"""
|
||||
annee = (semestre_idx - 1) // 2 + 1 # année BUT
|
||||
ref_comp: ApcReferentielCompetences = formation.referentiel_competence
|
||||
if not ref_comp:
|
||||
return "" # détecté ailleurs...
|
||||
niveaux_sans_ue_by_parcour = {} # { parcour.code : [ ue ... ] }
|
||||
parcours_ids = {
|
||||
uep.parcours_id
|
||||
for uep in UEParcours.query.join(UniteEns).filter_by(
|
||||
formation_id=formation.id, type=UE_STANDARD
|
||||
)
|
||||
}
|
||||
for parcour in ref_comp.parcours:
|
||||
if parcour.id not in parcours_ids:
|
||||
continue # saute parcours associés à aucune UE (tous semestres)
|
||||
niveaux_sans_ue = []
|
||||
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
|
||||
# print(f"\n# Parcours {parcour.code} : {len(niveaux)} niveaux")
|
||||
for niveau in niveaux:
|
||||
ues = [ue for ue in formation.ues if ue.niveau_competence_id == niveau.id]
|
||||
if not ues:
|
||||
niveaux_sans_ue.append(niveau)
|
||||
# print( niveau.competence.titre + " " + str(niveau.ordre) + "\t" + str(ue) )
|
||||
if niveaux_sans_ue:
|
||||
niveaux_sans_ue_by_parcour[parcour.code] = niveaux_sans_ue
|
||||
#
|
||||
H = []
|
||||
for parcour_code, niveaux in niveaux_sans_ue_by_parcour.items():
|
||||
H.append(
|
||||
f"""<li>Parcours {parcour_code} : {
|
||||
len(niveaux)} niveaux sans UEs :
|
||||
<span class="niveau-nom"><span>
|
||||
{ '</span>, <span>'.join( f'{niveau.competence.titre} {niveau.ordre}'
|
||||
for niveau in niveaux
|
||||
)
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
"""
|
||||
)
|
||||
# Combien de compétences de tronc commun ?
|
||||
_, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
|
||||
nb_niveaux_tc = len(niveaux_by_parcours["TC"])
|
||||
nb_ues_tc = len(
|
||||
formation.query_ues_parcour(None)
|
||||
.filter(UniteEns.semestre_idx == semestre_idx)
|
||||
.all()
|
||||
)
|
||||
if nb_niveaux_tc != nb_ues_tc:
|
||||
H.append(
|
||||
f"""<li>{nb_niveaux_tc} niveaux de compétences de tronc commun,
|
||||
mais {nb_ues_tc} UEs de tronc commun ! (c'est normal si
|
||||
vous avez des UEs différenciées par parcours)</li>"""
|
||||
)
|
||||
|
||||
if H:
|
||||
return f"""<div class="formation_semestre_niveaux_warning">
|
||||
<div>Problèmes détectés à corriger :</div>
|
||||
<ul>
|
||||
{"".join(H)}
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
return "" # no problem detected
|
||||
|
||||
|
||||
def ue_associee_au_niveau_du_parcours(
|
||||
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
|
||||
) -> UniteEns:
|
||||
|
|
|
@ -10,9 +10,11 @@
|
|||
from flask_wtf import FlaskForm
|
||||
from flask_wtf.file import FileField, FileAllowed
|
||||
from wtforms import SelectField, SubmitField
|
||||
from wtforms.validators import DataRequired
|
||||
|
||||
|
||||
class FormationRefCompForm(FlaskForm):
|
||||
"Choix d'un référentiel"
|
||||
referentiel_competence = SelectField(
|
||||
"Choisir parmi les référentiels déjà chargés :"
|
||||
)
|
||||
|
@ -21,6 +23,7 @@ class FormationRefCompForm(FlaskForm):
|
|||
|
||||
|
||||
class RefCompLoadForm(FlaskForm):
|
||||
"Upload d'un référentiel"
|
||||
referentiel_standard = SelectField(
|
||||
"Choisir un référentiel de compétences officiel BUT"
|
||||
)
|
||||
|
@ -47,3 +50,12 @@ class RefCompLoadForm(FlaskForm):
|
|||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class FormationChangeRefCompForm(FlaskForm):
|
||||
"choix d'un nouveau ref. comp. pour une formation"
|
||||
object_select = SelectField(
|
||||
"Choisir le nouveau référentiel", validators=[DataRequired()]
|
||||
)
|
||||
submit = SubmitField("Changer le référentiel de la formation")
|
||||
cancel = SubmitField("Annuler")
|
||||
|
|
|
@ -23,9 +23,12 @@ from app.models.but_refcomp import (
|
|||
from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError
|
||||
|
||||
|
||||
def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
|
||||
def orebut_import_refcomp(
|
||||
xml_data: str, dept_id: int, orig_filename=None
|
||||
) -> ApcReferentielCompetences:
|
||||
"""Importation XML Orébut
|
||||
peut lever TypeError ou ScoFormatError
|
||||
L'objet créé est ajouté et commité.
|
||||
Résultat: instance de ApcReferentielCompetences
|
||||
"""
|
||||
# Vérifie que le même fichier n'a pas déjà été chargé:
|
||||
|
@ -41,7 +44,7 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
|
|||
try:
|
||||
root = ElementTree.XML(xml_data)
|
||||
except ElementTree.ParseError as exc:
|
||||
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}")
|
||||
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}") from exc
|
||||
if root.tag != "referentiel_competence":
|
||||
raise ScoFormatError("élément racine 'referentiel_competence' manquant")
|
||||
args = ApcReferentielCompetences.attr_from_xml(root.attrib)
|
||||
|
@ -60,7 +63,8 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
|
|||
# ne devrait plus se produire car pas d'unicité de l'id: donc inutile
|
||||
db.session.rollback()
|
||||
raise ScoValueError(
|
||||
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({competence.attrib["id"]})
|
||||
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({
|
||||
competence.attrib["id"]})
|
||||
"""
|
||||
) from exc
|
||||
ref.competences.append(c)
|
||||
|
|
|
@ -77,7 +77,7 @@ from app.models.but_refcomp import (
|
|||
ApcNiveau,
|
||||
ApcParcours,
|
||||
)
|
||||
from app.models import Scolog, ScolarAutorisationInscription
|
||||
from app.models import Evaluation, ModuleImpl, Scolog, ScolarAutorisationInscription
|
||||
from app.models.but_validations import (
|
||||
ApcValidationAnnee,
|
||||
ApcValidationRCUE,
|
||||
|
@ -260,11 +260,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
else []
|
||||
)
|
||||
# ---- Niveaux et RCUEs
|
||||
niveaux_by_parcours = (
|
||||
formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
|
||||
self.annee_but, [self.parcour] if self.parcour else None
|
||||
)[1]
|
||||
)
|
||||
niveaux_by_parcours = formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
|
||||
self.annee_but, [self.parcour] if self.parcour else None
|
||||
)[
|
||||
1
|
||||
]
|
||||
self.niveaux_competences = niveaux_by_parcours["TC"] + (
|
||||
niveaux_by_parcours[self.parcour.id] if self.parcour else []
|
||||
)
|
||||
|
@ -358,13 +358,17 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
# self.codes = [] # pas de décision annuelle sur semestres impairs
|
||||
elif self.inscription_etat != scu.INSCRIT:
|
||||
self.codes = [
|
||||
sco_codes.DEM
|
||||
if self.inscription_etat == scu.DEMISSION
|
||||
else sco_codes.DEF,
|
||||
(
|
||||
sco_codes.DEM
|
||||
if self.inscription_etat == scu.DEMISSION
|
||||
else sco_codes.DEF
|
||||
),
|
||||
# propose aussi d'autres codes, au cas où...
|
||||
sco_codes.DEM
|
||||
if self.inscription_etat != scu.DEMISSION
|
||||
else sco_codes.DEF,
|
||||
(
|
||||
sco_codes.DEM
|
||||
if self.inscription_etat != scu.DEMISSION
|
||||
else sco_codes.DEF
|
||||
),
|
||||
sco_codes.ABAN,
|
||||
sco_codes.ABL,
|
||||
sco_codes.EXCLU,
|
||||
|
@ -409,15 +413,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
# Si validée par niveau supérieur:
|
||||
if self.code_valide == sco_codes.ADSUP:
|
||||
self.codes.insert(0, sco_codes.ADSUP)
|
||||
self.explanation = f"<div>{explanation}</div>"
|
||||
self.explanation = f'<div class="deca-expl">{explanation}</div>'
|
||||
messages = self.descr_pb_coherence()
|
||||
if messages:
|
||||
self.explanation += (
|
||||
'<div class="warning">'
|
||||
+ '</div><div class="warning">'.join(messages)
|
||||
'<div class="warning warning-info">'
|
||||
+ '</div><div class="warning warning-info">'.join(messages)
|
||||
+ "</div>"
|
||||
)
|
||||
self.codes = [self.codes[0]] + sorted(self.codes[1:])
|
||||
self.codes = [self.codes[0]] + sorted((c or "") for c in self.codes[1:])
|
||||
|
||||
def passage_de_droit_en_but3(self) -> tuple[bool, str]:
|
||||
"""Vérifie si les conditions supplémentaires de passage BUT2 vers BUT3 sont satisfaites"""
|
||||
|
@ -529,6 +533,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
Si l'origine est impair, S_impair est l'origine et S_pair est None
|
||||
Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur
|
||||
suivi par cet étudiant (ou None).
|
||||
|
||||
Note: si l'option "block_moyennes" est activée, ne prend pas en compte le semestre.
|
||||
"""
|
||||
if not formsemestre.formation.is_apc(): # garde fou
|
||||
return None, None
|
||||
|
@ -549,6 +555,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
inscr.formsemestre.formation.referentiel_competence
|
||||
== formsemestre.formation.referentiel_competence
|
||||
)
|
||||
# Non bloqué
|
||||
and not inscr.formsemestre.block_moyennes
|
||||
# L'autre semestre
|
||||
and (inscr.formsemestre.semestre_id == idx_autre)
|
||||
# de la même année scolaire
|
||||
|
@ -591,11 +599,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
# Ordonne par numéro d'UE
|
||||
niv_rcue = sorted(
|
||||
self.rcue_by_niveau.items(),
|
||||
key=lambda x: x[1].ue_1.numero
|
||||
if x[1].ue_1
|
||||
else x[1].ue_2.numero
|
||||
if x[1].ue_2
|
||||
else 0,
|
||||
key=lambda x: (
|
||||
x[1].ue_1.numero if x[1].ue_1 else x[1].ue_2.numero if x[1].ue_2 else 0
|
||||
),
|
||||
)
|
||||
return {
|
||||
niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
|
||||
|
@ -790,16 +796,33 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
if self.formsemestre_pair is not None:
|
||||
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
|
||||
|
||||
def has_notes_en_attente(self) -> bool:
|
||||
"Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
|
||||
res = (
|
||||
def _get_current_res(self) -> ResultatsSemestreBUT:
|
||||
"Les res. du semestre d'origine du deca"
|
||||
return (
|
||||
self.res_pair
|
||||
if self.formsemestre_pair
|
||||
and (self.formsemestre.id == self.formsemestre_pair.id)
|
||||
else self.res_impair
|
||||
)
|
||||
|
||||
def has_notes_en_attente(self) -> bool:
|
||||
"Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
|
||||
res = self._get_current_res()
|
||||
return res and self.etud.id in res.get_etudids_attente()
|
||||
|
||||
def get_modimpls_attente(self) -> list[ModuleImpl]:
|
||||
"Liste des ModuleImpl dans lesquels l'étudiant à au moins une note en ATTente"
|
||||
res = self._get_current_res()
|
||||
modimpls_results = [
|
||||
modimpl_result
|
||||
for modimpl_result in res.modimpls_results.values()
|
||||
if self.etud.id in modimpl_result.etudids_attente
|
||||
]
|
||||
modimpls = [
|
||||
db.session.get(ModuleImpl, mr.moduleimpl_id) for mr in modimpls_results
|
||||
]
|
||||
return sorted(modimpls, key=lambda mi: (mi.module.numero, mi.module.code))
|
||||
|
||||
def record_all(self, only_validantes: bool = False) -> bool:
|
||||
"""Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
|
||||
et sont donc en mode "automatique".
|
||||
|
@ -812,9 +835,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
Return: True si au moins un code modifié et enregistré.
|
||||
"""
|
||||
modif = False
|
||||
# Vérification notes en attente dans formsemestre origine
|
||||
if only_validantes and self.has_notes_en_attente():
|
||||
return False
|
||||
if only_validantes:
|
||||
if self.has_notes_en_attente():
|
||||
# notes en attente dans formsemestre origine
|
||||
return False
|
||||
if Evaluation.get_evaluations_blocked_for_etud(
|
||||
self.formsemestre, self.etud
|
||||
):
|
||||
# évaluation(s) qui seront débloquées dans le futur
|
||||
return False
|
||||
|
||||
# Toujours valider dans l'ordre UE, RCUE, Année
|
||||
annee_scolaire = self.formsemestre.annee_scolaire()
|
||||
|
@ -985,19 +1014,23 @@ class DecisionsProposeesAnnee(DecisionsProposees):
|
|||
if dec_ue.code_valide not in CODES_UE_VALIDES:
|
||||
if (
|
||||
dec_ue.ue_status
|
||||
and dec_ue.ue_status["was_capitalized"]
|
||||
and dec_ue.ue_status["is_capitalized"]
|
||||
):
|
||||
messages.append(
|
||||
f"Information: l'UE {ue.acronyme} capitalisée est utilisée pour un RCUE cette année"
|
||||
)
|
||||
else:
|
||||
messages.append(
|
||||
f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !"
|
||||
f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est (probablement une validation antérieure)"
|
||||
)
|
||||
else:
|
||||
messages.append(
|
||||
f"L'UE {ue.acronyme} n'a pas décision (???)"
|
||||
)
|
||||
# Voyons si on est dispensé de cette ue ?
|
||||
res = self.res_impair if ue.semestre_idx % 2 else self.res_pair
|
||||
if res and (self.etud.id, ue.id) in res.dispense_ues:
|
||||
messages.append(f"Pas (ré)inscrit à l'UE {ue.acronyme}")
|
||||
return messages
|
||||
|
||||
def valide_diplome(self) -> bool:
|
||||
|
@ -1484,9 +1517,11 @@ class DecisionsProposeesUE(DecisionsProposees):
|
|||
self.validation = None # cache toute validation
|
||||
self.explanation = "non inscrit (dem. ou déf.)"
|
||||
self.codes = [
|
||||
sco_codes.DEM
|
||||
if res.get_etud_etat(etud.id) == scu.DEMISSION
|
||||
else sco_codes.DEF
|
||||
(
|
||||
sco_codes.DEM
|
||||
if res.get_etud_etat(etud.id) == scu.DEMISSION
|
||||
else sco_codes.DEF
|
||||
)
|
||||
]
|
||||
return
|
||||
|
||||
|
@ -1500,7 +1535,7 @@ class DecisionsProposeesUE(DecisionsProposees):
|
|||
|
||||
def __repr__(self) -> str:
|
||||
return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide
|
||||
} codes={self.codes} explanation={self.explanation}>"""
|
||||
} codes={self.codes} explanation="{self.explanation}">"""
|
||||
|
||||
def compute_codes(self):
|
||||
"""Calcul des .codes attribuables et de l'explanation associée"""
|
||||
|
|
|
@ -55,11 +55,21 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
|
|||
else:
|
||||
line_sep = "\n"
|
||||
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep)
|
||||
|
||||
if fmt.startswith("xls"):
|
||||
titles.update(
|
||||
{
|
||||
"etudid": "etudid",
|
||||
"code_nip": "nip",
|
||||
"code_ine": "ine",
|
||||
"ects_but": "Total ECTS BUT",
|
||||
"civilite": "Civ.",
|
||||
"nom": "Nom",
|
||||
"prenom": "Prénom",
|
||||
}
|
||||
)
|
||||
# Style excel... passages à la ligne sur \n
|
||||
xls_style_base = sco_excel.excel_make_style()
|
||||
xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top")
|
||||
|
||||
tab = GenTable(
|
||||
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
|
||||
caption=title,
|
||||
|
@ -116,7 +126,7 @@ def pvjury_table_but(
|
|||
"pas de référentiel de compétences associé à la formation de ce semestre !"
|
||||
)
|
||||
titles = {
|
||||
"nom": "Code" if anonymous else "Nom",
|
||||
"nom_pv": "Code" if anonymous else "Nom",
|
||||
"cursus": "Cursus",
|
||||
"ects": "ECTS",
|
||||
"ues": "UE validées",
|
||||
|
@ -144,33 +154,47 @@ def pvjury_table_but(
|
|||
except ScoValueError:
|
||||
deca = None
|
||||
|
||||
ects_but_valides = but_ects_valides(etud, referentiel_competence_id)
|
||||
row = {
|
||||
"nom": etud.code_ine or etud.code_nip or etud.id
|
||||
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
|
||||
else etud.etat_civil_pv(
|
||||
line_sep=line_sep, with_paragraph=with_paragraph_nom
|
||||
"nom_pv": (
|
||||
etud.code_ine or etud.code_nip or etud.id
|
||||
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
|
||||
else etud.etat_civil_pv(
|
||||
line_sep=line_sep, with_paragraph=with_paragraph_nom
|
||||
)
|
||||
),
|
||||
"_nom_order": etud.sort_key,
|
||||
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
|
||||
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
|
||||
"_nom_target": url_for(
|
||||
"_nom_pv_order": etud.sort_key,
|
||||
"_nom_pv_target_attrs": f'class="etudinfo" id="{etud.id}"',
|
||||
"_nom_pv_td_attrs": f'id="{etud.id}" class="etudinfo"',
|
||||
"_nom_pv_target": url_for(
|
||||
"scolar.fiche_etud",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
etudid=etud.id,
|
||||
),
|
||||
"cursus": _descr_cursus_but(etud),
|
||||
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {but_ects_valides(etud, referentiel_competence_id ):g}""",
|
||||
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {ects_but_valides:g}""",
|
||||
"_ects_xls": deca.ects_annee(),
|
||||
"ects_but": ects_but_valides,
|
||||
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
|
||||
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
|
||||
if deca
|
||||
else "-",
|
||||
"niveaux": (
|
||||
deca.descr_niveaux_validation(line_sep=line_sep) if deca else "-"
|
||||
),
|
||||
"decision_but": deca.code_valide if deca else "",
|
||||
"devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
|
||||
if deca
|
||||
else "",
|
||||
"devenir": (
|
||||
", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
|
||||
if deca
|
||||
else ""
|
||||
),
|
||||
# pour exports excel seulement:
|
||||
"civilite": etud.civilite_etat_civil_str,
|
||||
"nom": etud.nom,
|
||||
"prenom": etud.prenom_etat_civil or etud.prenom or "",
|
||||
"etudid": etud.id,
|
||||
"code_nip": etud.code_nip,
|
||||
"code_ine": etud.code_ine,
|
||||
}
|
||||
if deca.valide_diplome() or not only_diplome:
|
||||
rows.append(row)
|
||||
|
||||
rows.sort(key=lambda x: x["_nom_order"])
|
||||
rows.sort(key=lambda x: x["_nom_pv_order"])
|
||||
return rows, titles
|
||||
|
|
|
@ -16,8 +16,8 @@ from app.scodoc.sco_exceptions import ScoValueError
|
|||
|
||||
|
||||
def formsemestre_validation_auto_but(
|
||||
formsemestre: FormSemestre, only_adm: bool = True
|
||||
) -> int:
|
||||
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False
|
||||
) -> tuple[int, list[jury_but.DecisionsProposeesAnnee]]:
|
||||
"""Calcul automatique des décisions de jury sur une "année" BUT.
|
||||
|
||||
- N'enregistre jamais de décisions de l'année scolaire précédente, même
|
||||
|
@ -27,16 +27,22 @@ def formsemestre_validation_auto_but(
|
|||
En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
|
||||
(mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
|
||||
|
||||
Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
|
||||
Returns:
|
||||
- En mode normal, (nombre d'étudiants pour lesquels on a enregistré au moins un code, []])
|
||||
- En mode dry_run, (0, list[DecisionsProposeesAnnee])
|
||||
"""
|
||||
if not formsemestre.formation.is_apc():
|
||||
raise ScoValueError("fonction réservée aux formations BUT")
|
||||
nb_etud_modif = 0
|
||||
decas = []
|
||||
with sco_cache.DeferredSemCacheManager():
|
||||
for etudid in formsemestre.etuds_inscriptions:
|
||||
etud = Identite.get_etud(etudid)
|
||||
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
|
||||
nb_etud_modif += deca.record_all(only_validantes=only_adm)
|
||||
if not dry_run:
|
||||
nb_etud_modif += deca.record_all(only_validantes=only_adm)
|
||||
else:
|
||||
decas.append(deca)
|
||||
|
||||
db.session.commit()
|
||||
ScolarNews.add(
|
||||
|
@ -49,4 +55,4 @@ def formsemestre_validation_auto_but(
|
|||
formsemestre_id=formsemestre.id,
|
||||
),
|
||||
)
|
||||
return nb_etud_modif
|
||||
return nb_etud_modif, decas
|
||||
|
|
|
@ -21,8 +21,6 @@ from app.but.jury_but import (
|
|||
DecisionsProposeesRCUE,
|
||||
DecisionsProposeesUE,
|
||||
)
|
||||
from app.comp import res_sem
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
from app.models import (
|
||||
ApcNiveau,
|
||||
FormSemestre,
|
||||
|
@ -33,11 +31,8 @@ from app.models import (
|
|||
ScolarFormSemestreValidation,
|
||||
ScolarNews,
|
||||
)
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.scodoc import html_sco_header
|
||||
from app.scodoc import codes_cursus as sco_codes
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
|
@ -97,7 +92,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
|||
<span class="avertissement_redoublement">{formsemestre_2.annee_scolaire_str()
|
||||
if formsemestre_2 else ""}</span>
|
||||
</div>
|
||||
<div class="titre">RCUE</div>
|
||||
<div class="titre" title="Décisions sur RCUEs enregistrées sur l'ensemble du cursus">RCUE</div>
|
||||
"""
|
||||
)
|
||||
for dec_rcue in deca.get_decisions_rcues_annee():
|
||||
|
@ -109,23 +104,32 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
|||
</div>"""
|
||||
)
|
||||
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2
|
||||
# Les UEs à afficher,
|
||||
# qui
|
||||
ues_ro = [
|
||||
# Les UEs à afficher : on regarde si read only et si dispense (non ré-inscription à l'UE)
|
||||
# tuples (UniteEns, read_only, dispense)
|
||||
ues_ro_dispense = [
|
||||
(
|
||||
ue_impair,
|
||||
rcue.ue_cur_impair is None,
|
||||
deca.res_impair
|
||||
and ue_impair
|
||||
and (deca.etud.id, ue_impair.id) in deca.res_impair.dispense_ues,
|
||||
),
|
||||
(
|
||||
ue_pair,
|
||||
rcue.ue_cur_pair is None,
|
||||
deca.res_pair
|
||||
and ue_pair
|
||||
and (deca.etud.id, ue_pair.id) in deca.res_pair.dispense_ues,
|
||||
),
|
||||
]
|
||||
# Ordonne selon les dates des 2 semestres considérés:
|
||||
if reverse_semestre:
|
||||
ues_ro[0], ues_ro[1] = ues_ro[1], ues_ro[0]
|
||||
ues_ro_dispense[0], ues_ro_dispense[1] = (
|
||||
ues_ro_dispense[1],
|
||||
ues_ro_dispense[0],
|
||||
)
|
||||
# Colonnes d'UE:
|
||||
for ue, ue_read_only in ues_ro:
|
||||
for ue, ue_read_only, ue_dispense in ues_ro_dispense:
|
||||
if ue:
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
|
@ -134,6 +138,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
|
|||
disabled=read_only or ue_read_only,
|
||||
annee_prec=ue_read_only,
|
||||
niveau_id=ue.niveau_competence.id,
|
||||
ue_dispense=ue_dispense,
|
||||
)
|
||||
)
|
||||
else:
|
||||
|
@ -188,21 +193,30 @@ def _gen_but_niveau_ue(
|
|||
disabled: bool = False,
|
||||
annee_prec: bool = False,
|
||||
niveau_id: int = None,
|
||||
ue_dispense: bool = False,
|
||||
) -> str:
|
||||
if dec_ue.ue_status and dec_ue.ue_status["is_capitalized"]:
|
||||
moy_ue_str = f"""<span class="ue_cap">{
|
||||
scu.fmt_note(dec_ue.moy_ue_with_cap)}</span>"""
|
||||
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
|
||||
|
||||
if ue_dispense:
|
||||
etat_en_cours = """Non (ré)inscrit à cette UE"""
|
||||
else:
|
||||
etat_en_cours = f"""UE en cours
|
||||
{ "sans notes" if np.isnan(dec_ue.moy_ue)
|
||||
else
|
||||
("avec moyenne <b>" + scu.fmt_note(dec_ue.moy_ue) + "</b>")
|
||||
}
|
||||
"""
|
||||
|
||||
scoplement = f"""<div class="scoplement">
|
||||
<div>
|
||||
<b>UE {ue.acronyme} capitalisée </b>
|
||||
<span>le {dec_ue.ue_status["event_date"].strftime(scu.DATE_FMT)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{ etat_en_cours }
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
@ -214,7 +228,7 @@ def _gen_but_niveau_ue(
|
|||
<div>
|
||||
<b>UE {ue.acronyme} antérieure </b>
|
||||
<span>validée {dec_ue.validation.code}
|
||||
le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
|
||||
le {dec_ue.validation.event_date.strftime(scu.DATE_FMT)}
|
||||
</span>
|
||||
</div>
|
||||
<div>Non reprise dans l'année en cours</div>
|
||||
|
@ -232,9 +246,7 @@ def _gen_but_niveau_ue(
|
|||
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
|
||||
if dec_ue.code_valide:
|
||||
date_str = (
|
||||
f"""enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
|
||||
à {dec_ue.validation.event_date.strftime("%Hh%M")}
|
||||
"""
|
||||
f"""enregistré le {dec_ue.validation.event_date.strftime(scu.DATEATIME_FMT)}"""
|
||||
if dec_ue.validation and dec_ue.validation.event_date
|
||||
else ""
|
||||
)
|
||||
|
@ -244,7 +256,13 @@ def _gen_but_niveau_ue(
|
|||
</div>
|
||||
"""
|
||||
else:
|
||||
scoplement = ""
|
||||
if dec_ue.ue_status and dec_ue.ue_status["was_capitalized"]:
|
||||
scoplement = """<div class="scoplement">
|
||||
UE déjà capitalisée avec résultat moins favorable.
|
||||
</div>
|
||||
"""
|
||||
else:
|
||||
scoplement = ""
|
||||
|
||||
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else ''
|
||||
if dec_ue.code_valide is not None and dec_ue.codes:
|
||||
|
@ -256,7 +274,7 @@ def _gen_but_niveau_ue(
|
|||
return f"""<div class="but_niveau_ue {ue_class}
|
||||
{'annee_prec' if annee_prec else ''}
|
||||
">
|
||||
<div title="{ue.titre}">{ue.acronyme}</div>
|
||||
<div title="{ue.titre or ''}">{ue.acronyme}</div>
|
||||
<div class="but_note with_scoplement">
|
||||
<div>{moy_ue_str}</div>
|
||||
{scoplement}
|
||||
|
@ -331,250 +349,6 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
|
|||
"""
|
||||
|
||||
|
||||
def jury_but_semestriel(
|
||||
formsemestre: FormSemestre,
|
||||
etud: Identite,
|
||||
read_only: bool,
|
||||
navigation_div: str = "",
|
||||
) -> str:
|
||||
"""Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)."""
|
||||
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
|
||||
parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res)
|
||||
inscription_etat = etud.inscription_etat(formsemestre.id)
|
||||
semestre_terminal = (
|
||||
formsemestre.semestre_id >= formsemestre.formation.get_cursus().NB_SEM
|
||||
)
|
||||
autorisations_passage = ScolarAutorisationInscription.query.filter_by(
|
||||
etudid=etud.id,
|
||||
origin_formsemestre_id=formsemestre.id,
|
||||
).all()
|
||||
# Par défaut: autorisé à passer dans le semestre suivant si sem. impair,
|
||||
# ou si décision déjà enregistrée:
|
||||
est_autorise_a_passer = (formsemestre.semestre_id % 2) or (
|
||||
formsemestre.semestre_id + 1
|
||||
) in (a.semestre_id for a in autorisations_passage)
|
||||
decisions_ues = {
|
||||
ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat)
|
||||
for ue in ues
|
||||
}
|
||||
for dec_ue in decisions_ues.values():
|
||||
dec_ue.compute_codes()
|
||||
|
||||
if request.method == "POST":
|
||||
if not read_only:
|
||||
for key in request.form:
|
||||
code = request.form[key]
|
||||
# Codes d'UE
|
||||
code_match = re.match(r"^code_ue_(\d+)$", key)
|
||||
if code_match:
|
||||
ue_id = int(code_match.group(1))
|
||||
dec_ue = decisions_ues.get(ue_id)
|
||||
if not dec_ue:
|
||||
raise ScoValueError(f"UE invalide ue_id={ue_id}")
|
||||
dec_ue.record(code)
|
||||
db.session.commit()
|
||||
flash("codes enregistrés")
|
||||
if not semestre_terminal:
|
||||
if request.form.get("autorisation_passage"):
|
||||
if not formsemestre.semestre_id + 1 in (
|
||||
a.semestre_id for a in autorisations_passage
|
||||
):
|
||||
ScolarAutorisationInscription.delete_autorisation_etud(
|
||||
etud.id, formsemestre.id
|
||||
)
|
||||
ScolarAutorisationInscription.autorise_etud(
|
||||
etud.id,
|
||||
formsemestre.formation.formation_code,
|
||||
formsemestre.id,
|
||||
formsemestre.semestre_id + 1,
|
||||
)
|
||||
db.session.commit()
|
||||
flash(
|
||||
f"""autorisation de passage en S{formsemestre.semestre_id + 1
|
||||
} enregistrée"""
|
||||
)
|
||||
else:
|
||||
if est_autorise_a_passer:
|
||||
ScolarAutorisationInscription.delete_autorisation_etud(
|
||||
etud.id, formsemestre.id
|
||||
)
|
||||
db.session.commit()
|
||||
flash(
|
||||
f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée"
|
||||
)
|
||||
ScolarNews.add(
|
||||
typ=ScolarNews.NEWS_JURY,
|
||||
obj=formsemestre.id,
|
||||
text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
|
||||
url=url_for(
|
||||
"notes.formsemestre_status",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=formsemestre.id,
|
||||
),
|
||||
)
|
||||
return flask.redirect(
|
||||
url_for(
|
||||
"notes.formsemestre_validation_but",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id,
|
||||
)
|
||||
)
|
||||
# GET
|
||||
if formsemestre.semestre_id % 2 == 0:
|
||||
warning = f"""<div class="warning">
|
||||
Cet étudiant de S{formsemestre.semestre_id} ne peut pas passer
|
||||
en jury BUT annuel car il lui manque le semestre précédent.
|
||||
</div>"""
|
||||
else:
|
||||
warning = ""
|
||||
H = [
|
||||
html_sco_header.sco_header(
|
||||
page_title=f"Validation BUT S{formsemestre.semestre_id}",
|
||||
formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id,
|
||||
cssstyles=("css/jury_but.css",),
|
||||
javascripts=("js/jury_but.js",),
|
||||
),
|
||||
f"""
|
||||
<div class="jury_but">
|
||||
<div>
|
||||
<div class="bull_head">
|
||||
<div>
|
||||
<div class="titre_parcours">Jury BUT S{formsemestre.id}
|
||||
- Parcours {(parcour.libelle if parcour else False) or "non spécifié"}
|
||||
</div>
|
||||
<div class="nom_etud">{etud.nomprenom}</div>
|
||||
</div>
|
||||
<div class="bull_photo"><a href="{
|
||||
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
|
||||
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
|
||||
</div>
|
||||
</div>
|
||||
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
|
||||
{warning}
|
||||
</div>
|
||||
|
||||
<form method="post" class="jury_but_box" id="jury_but">
|
||||
""",
|
||||
]
|
||||
|
||||
erase_span = ""
|
||||
if not read_only:
|
||||
# Requête toutes les validations (pas seulement celles du deca courant),
|
||||
# au cas où: changement d'architecture, saisie en mode classique, ...
|
||||
validations = ScolarFormSemestreValidation.query.filter_by(
|
||||
etudid=etud.id, formsemestre_id=formsemestre.id
|
||||
).all()
|
||||
if validations:
|
||||
erase_span = f"""<a href="{
|
||||
url_for("notes.formsemestre_jury_but_erase",
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
|
||||
etudid=etud.id, only_one_sem=1)
|
||||
}" class="stdlink">effacer les décisions enregistrées</a>"""
|
||||
else:
|
||||
erase_span = (
|
||||
"Cet étudiant n'a aucune décision enregistrée pour ce semestre."
|
||||
)
|
||||
|
||||
H.append(
|
||||
f"""
|
||||
<div class="but_section_annee">
|
||||
</div>
|
||||
<div><b>Unités d'enseignement de S{formsemestre.semestre_id}:</b></div>
|
||||
"""
|
||||
)
|
||||
if not ues:
|
||||
H.append(
|
||||
"""<div class="warning">Aucune UE ! Vérifiez votre programme de
|
||||
formation, et l'association UEs / Niveaux de compétences</div>"""
|
||||
)
|
||||
else:
|
||||
H.append(
|
||||
"""
|
||||
<div class="but_annee">
|
||||
<div class="titre"></div>
|
||||
<div class="titre"></div>
|
||||
<div class="titre"></div>
|
||||
<div class="titre"></div>
|
||||
"""
|
||||
)
|
||||
for ue in ues:
|
||||
dec_ue = decisions_ues[ue.id]
|
||||
H.append("""<div class="but_niveau_titre"><div></div></div>""")
|
||||
H.append(
|
||||
_gen_but_niveau_ue(
|
||||
ue,
|
||||
dec_ue,
|
||||
disabled=read_only,
|
||||
)
|
||||
)
|
||||
H.append(
|
||||
"""<div style=""></div>
|
||||
<div class=""></div>"""
|
||||
)
|
||||
H.append("</div>") # but_annee
|
||||
|
||||
div_autorisations_passage = (
|
||||
f"""
|
||||
<div class="but_autorisations_passage">
|
||||
<span>Autorisé à passer en :</span>
|
||||
{ ", ".join( ["S" + str(a.semestre_id or '') for a in autorisations_passage ] )}
|
||||
</div>
|
||||
"""
|
||||
if autorisations_passage
|
||||
else """<div class="but_autorisations_passage but_explanation">pas d'autorisations de passage enregistrées.</div>"""
|
||||
)
|
||||
H.append(div_autorisations_passage)
|
||||
|
||||
if read_only:
|
||||
H.append(
|
||||
f"""<div class="but_explanation">
|
||||
{"Vous n'avez pas la permission de modifier ces décisions."
|
||||
if formsemestre.etat
|
||||
else "Semestre verrouillé."}
|
||||
Les champs entourés en vert sont enregistrés.
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
else:
|
||||
if formsemestre.semestre_id < formsemestre.formation.get_cursus().NB_SEM:
|
||||
H.append(
|
||||
f"""
|
||||
<div class="but_settings">
|
||||
<input type="checkbox" name="autorisation_passage" value="1" {
|
||||
"checked" if est_autorise_a_passer else ""}>
|
||||
<em>autoriser à passer dans le semestre S{formsemestre.semestre_id+1}</em>
|
||||
</input>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
else:
|
||||
H.append("""<div class="help">dernier semestre de la formation.</div>""")
|
||||
H.append(
|
||||
f"""
|
||||
<div class="but_buttons">
|
||||
<span><input type="submit" value="Enregistrer ces décisions"></span>
|
||||
<span>{erase_span}</span>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
H.append(navigation_div)
|
||||
H.append("</div>")
|
||||
H.append(
|
||||
render_template(
|
||||
"but/documentation_codes_jury.j2",
|
||||
nom_univ=f"""Export {sco_preferences.get_preference("InstituteName")
|
||||
or sco_preferences.get_preference("UnivName")
|
||||
or "Apogée"}""",
|
||||
codes=ScoDocSiteConfig.get_codes_apo_dict(),
|
||||
)
|
||||
)
|
||||
|
||||
return "\n".join(H)
|
||||
|
||||
|
||||
# -------------
|
||||
def infos_fiche_etud_html(etudid: int) -> str:
|
||||
"""Section html pour fiche etudiant
|
||||
|
|
|
@ -75,7 +75,7 @@ class RegroupementCoherentUE:
|
|||
else None
|
||||
)
|
||||
|
||||
# Autres validations pour l'UE paire
|
||||
# Autres validations pour les UEs paire/impaire
|
||||
self.validation_ue_best_pair = best_autre_ue_validation(
|
||||
etud.id,
|
||||
niveau.id,
|
||||
|
@ -101,14 +101,24 @@ class RegroupementCoherentUE:
|
|||
"résultats formsemestre de l'UE si elle est courante, None sinon"
|
||||
self.ue_status_impair = None
|
||||
if self.ue_cur_impair:
|
||||
# UE courante
|
||||
ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id)
|
||||
self.moy_ue_1 = ue_status["moy"] if ue_status else None # avec capitalisée
|
||||
self.ue_1 = self.ue_cur_impair
|
||||
self.res_impair = res_impair
|
||||
self.ue_status_impair = ue_status
|
||||
elif self.validation_ue_best_impair:
|
||||
# UE capitalisée
|
||||
self.moy_ue_1 = self.validation_ue_best_impair.moy_ue
|
||||
self.ue_1 = self.validation_ue_best_impair.ue
|
||||
if (
|
||||
res_impair
|
||||
and self.validation_ue_best_impair
|
||||
and self.validation_ue_best_impair.ue
|
||||
):
|
||||
self.ue_status_impair = res_impair.get_etud_ue_status(
|
||||
etud.id, self.validation_ue_best_impair.ue.id
|
||||
)
|
||||
else:
|
||||
self.moy_ue_1, self.ue_1 = None, None
|
||||
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
|
||||
|
|
|
@ -667,10 +667,12 @@ class BonusCalais(BonusSportAdditif):
|
|||
sur 20 obtenus dans chacune des matières optionnelles sont cumulés
|
||||
dans la limite de 10 points. 6% de ces points cumulés s'ajoutent :
|
||||
<ul>
|
||||
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant.
|
||||
<li><b>en BUT</b> à la moyenne de chaque UE;
|
||||
</li>
|
||||
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b>
|
||||
(ex : UE2.1BS, UE32BS)
|
||||
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant;
|
||||
</li>
|
||||
<li><b>en LP</b>, et en BUT avant 2023-2024, à la moyenne de chaque UE dont
|
||||
l'acronyme termine par <b>BS</b> (comme UE2.1BS, UE32BS).
|
||||
</li>
|
||||
</ul>
|
||||
"""
|
||||
|
@ -692,12 +694,17 @@ class BonusCalais(BonusSportAdditif):
|
|||
else:
|
||||
self.classic_use_bonus_ues = True # pour les LP
|
||||
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
|
||||
ues = self.formsemestre.get_ues(with_sport=False)
|
||||
ues_sans_bs = [
|
||||
ue for ue in ues if ue.acronyme[-2:].upper() != "BS"
|
||||
] # les 2 derniers cars forcés en majus
|
||||
for ue in ues_sans_bs:
|
||||
self.bonus_ues[ue.id] = 0.0
|
||||
if (
|
||||
self.formsemestre.annee_scolaire() < 2023
|
||||
or not self.formsemestre.formation.is_apc()
|
||||
):
|
||||
# LP et anciens semestres: ne s'applique qu'aux UE dont l'acronyme termine par BS
|
||||
ues = self.formsemestre.get_ues(with_sport=False)
|
||||
ues_sans_bs = [
|
||||
ue for ue in ues if ue.acronyme[-2:].upper() != "BS"
|
||||
] # les 2 derniers cars forcés en majus
|
||||
for ue in ues_sans_bs:
|
||||
self.bonus_ues[ue.id] = 0.0
|
||||
|
||||
|
||||
class BonusColmar(BonusSportAdditif):
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
|
||||
"""caches pour tables APC
|
||||
"""
|
||||
from flask import g
|
||||
|
||||
from app.scodoc import sco_cache
|
||||
|
||||
|
@ -47,3 +48,27 @@ class EvaluationsPoidsCache(sco_cache.ScoDocCache):
|
|||
"""
|
||||
|
||||
prefix = "EPC"
|
||||
|
||||
@classmethod
|
||||
def invalidate_all(cls):
|
||||
"delete all cached evaluations poids (in current dept)"
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
|
||||
moduleimpl_ids = [
|
||||
mi.id
|
||||
for mi in ModuleImpl.query.join(FormSemestre).filter_by(
|
||||
dept_id=g.scodoc_dept_id
|
||||
)
|
||||
]
|
||||
cls.delete_many(moduleimpl_ids)
|
||||
|
||||
@classmethod
|
||||
def invalidate_sem(cls, formsemestre_id):
|
||||
"delete cached evaluations poids for this formsemestre from cache"
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
|
||||
moduleimpl_ids = [
|
||||
mi.id for mi in ModuleImpl.query.filter_by(formsemestre_id=formsemestre_id)
|
||||
]
|
||||
cls.delete_many(moduleimpl_ids)
|
||||
|
|
|
@ -23,6 +23,7 @@ from app.models import (
|
|||
)
|
||||
from app.scodoc import sco_cache
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class ValidationsSemestre(ResultatsCache):
|
||||
|
@ -84,7 +85,7 @@ class ValidationsSemestre(ResultatsCache):
|
|||
"code": decision.code,
|
||||
"assidu": decision.assidu,
|
||||
"compense_formsemestre_id": decision.compense_formsemestre_id,
|
||||
"event_date": decision.event_date.strftime("%d/%m/%Y"),
|
||||
"event_date": decision.event_date.strftime(scu.DATE_FMT),
|
||||
}
|
||||
self.decisions_jury = decisions_jury
|
||||
|
||||
|
@ -107,7 +108,7 @@ class ValidationsSemestre(ResultatsCache):
|
|||
decisions_jury_ues[decision.etudid][decision.ue.id] = {
|
||||
"code": decision.code,
|
||||
"ects": ects, # 0. si UE non validée
|
||||
"event_date": decision.event_date.strftime("%d/%m/%Y"),
|
||||
"event_date": decision.event_date.strftime(scu.DATE_FMT),
|
||||
}
|
||||
|
||||
self.decisions_jury_ues = decisions_jury_ues
|
||||
|
|
|
@ -35,7 +35,6 @@ moyenne générale d'une UE.
|
|||
"""
|
||||
import dataclasses
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import sqlalchemy as sa
|
||||
|
@ -46,7 +45,6 @@ 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
|
||||
|
||||
|
||||
|
@ -56,6 +54,7 @@ class EvaluationEtat:
|
|||
|
||||
evaluation_id: int
|
||||
nb_attente: int
|
||||
nb_notes: int # nb notes d'étudiants inscrits au semestre et au modimpl
|
||||
is_complete: bool
|
||||
|
||||
def to_dict(self):
|
||||
|
@ -72,7 +71,15 @@ class ModuleImplResults:
|
|||
les caches sont gérés par ResultatsSemestre.
|
||||
"""
|
||||
|
||||
def __init__(self, moduleimpl: ModuleImpl):
|
||||
def __init__(
|
||||
self, moduleimpl: ModuleImpl, etudids: list[int], etudids_actifs: set[int]
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
- etudids : liste des etudids, qui donne l'index du dataframe
|
||||
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
|
||||
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
|
||||
"""
|
||||
self.moduleimpl_id = moduleimpl.id
|
||||
self.module_id = moduleimpl.module.id
|
||||
self.etudids = None
|
||||
|
@ -105,14 +112,23 @@ class ModuleImplResults:
|
|||
"""
|
||||
self.evals_etudids_sans_note = {}
|
||||
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
|
||||
self.load_notes()
|
||||
self.evals_type = {}
|
||||
"""Type de chaque eval { evaluation.id : evaluation.evaluation_type }"""
|
||||
self.load_notes(etudids, etudids_actifs)
|
||||
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
|
||||
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
|
||||
self.etuds_use_rattrapage = pd.Series(False, index=self.evals_notes.index)
|
||||
"""1 bool par etud, indique si sa moyenne de module utilise la note de rattrapage"""
|
||||
|
||||
def load_notes(self): # ré-écriture de df_load_modimpl_notes
|
||||
def load_notes(
|
||||
self, etudids: list[int], etudids_actifs: set[int]
|
||||
): # ré-écriture de df_load_modimpl_notes
|
||||
"""Charge toutes les notes de toutes les évaluations du module.
|
||||
Args:
|
||||
- etudids : liste des etudids, qui donne l'index du dataframe
|
||||
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
|
||||
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
|
||||
|
||||
Dataframe evals_notes
|
||||
colonnes: le nom de la colonne est l'evaluation_id (int)
|
||||
index (lignes): etudid (int)
|
||||
|
@ -135,12 +151,12 @@ class ModuleImplResults:
|
|||
qui ont des notes ATT.
|
||||
"""
|
||||
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
|
||||
self.etudids = self._etudids()
|
||||
self.etudids = etudids
|
||||
|
||||
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
|
||||
# on prend les inscrits au module ET au semestre (donc sans démissionnaires)
|
||||
inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection(
|
||||
moduleimpl.formsemestre.etudids_actifs
|
||||
etudids_actifs
|
||||
)
|
||||
self.nb_inscrits_module = len(inscrits_module)
|
||||
|
||||
|
@ -148,19 +164,24 @@ class ModuleImplResults:
|
|||
evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
|
||||
self.evaluations_completes = []
|
||||
self.evaluations_completes_dict = {}
|
||||
self.etudids_attente = set() # empty
|
||||
self.evals_type = {}
|
||||
evaluation: Evaluation
|
||||
for evaluation in moduleimpl.evaluations:
|
||||
self.evals_type[evaluation.id] = evaluation.evaluation_type
|
||||
eval_df = self._load_evaluation_notes(evaluation)
|
||||
# is_complete ssi tous les inscrits (non dem) au semestre ont une note
|
||||
# ou évaluation déclarée "à prise en compte immédiate"
|
||||
# Les évaluations de rattrapage et 2eme session sont toujours complètes
|
||||
|
||||
# is_complete ssi
|
||||
# tous les inscrits (non dem) au module ont une note
|
||||
# ou évaluation déclarée "à prise en compte immédiate"
|
||||
# ou rattrapage, 2eme session, bonus
|
||||
# ET pas bloquée par date (is_blocked)
|
||||
is_blocked = evaluation.is_blocked()
|
||||
etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem.
|
||||
is_complete = (
|
||||
(evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE)
|
||||
or (evaluation.evaluation_type == scu.EVALUATION_SESSION2)
|
||||
(evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
|
||||
or (evaluation.publish_incomplete)
|
||||
or (not etudids_sans_note)
|
||||
)
|
||||
) and not is_blocked
|
||||
self.evaluations_completes.append(is_complete)
|
||||
self.evaluations_completes_dict[evaluation.id] = is_complete
|
||||
self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note
|
||||
|
@ -168,25 +189,39 @@ class ModuleImplResults:
|
|||
# NULL en base => ABS (= -999)
|
||||
eval_df.fillna(scu.NOTES_ABSENCE, inplace=True)
|
||||
# Ce merge ne garde que les étudiants inscrits au module
|
||||
# et met à NULL les notes non présentes
|
||||
# et met à NULL (NaN) les notes non présentes
|
||||
# (notes non saisies ou etuds non inscrits au module):
|
||||
evals_notes = evals_notes.merge(
|
||||
eval_df, how="left", left_index=True, right_index=True
|
||||
)
|
||||
# Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
|
||||
eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)]
|
||||
eval_etudids_attente = set(
|
||||
eval_notes_inscr.iloc[
|
||||
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
|
||||
].index
|
||||
)
|
||||
# Nombre de notes (non vides, incluant ATT etc) des inscrits:
|
||||
nb_notes = eval_notes_inscr.notna().sum()
|
||||
|
||||
if is_blocked:
|
||||
eval_etudids_attente = set()
|
||||
else:
|
||||
# Etudiants avec notes en attente:
|
||||
# = ceux avec note ATT
|
||||
eval_etudids_attente = set(
|
||||
eval_notes_inscr.iloc[
|
||||
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
|
||||
].index
|
||||
)
|
||||
if evaluation.publish_incomplete:
|
||||
# et en "immédiat", tous ceux sans note
|
||||
eval_etudids_attente |= etudids_sans_note
|
||||
|
||||
# Synthèse pour état du module:
|
||||
self.etudids_attente |= eval_etudids_attente
|
||||
self.evaluations_etat[evaluation.id] = EvaluationEtat(
|
||||
evaluation_id=evaluation.id,
|
||||
nb_attente=len(eval_etudids_attente),
|
||||
nb_notes=int(nb_notes),
|
||||
is_complete=is_complete,
|
||||
)
|
||||
# au moins une note en ATT dans ce modimpl:
|
||||
# au moins une note en attente (ATT ou manquante en mode "immédiat") dans ce modimpl:
|
||||
self.en_attente = bool(self.etudids_attente)
|
||||
|
||||
# Force columns names to integers (evaluation ids)
|
||||
|
@ -219,36 +254,44 @@ class ModuleImplResults:
|
|||
eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
|
||||
return eval_df
|
||||
|
||||
def _etudids(self):
|
||||
"""L'index du dataframe est la liste de tous les étudiants inscrits au semestre
|
||||
(incluant les DEM et DEF)
|
||||
"""
|
||||
return [
|
||||
inscr.etudid
|
||||
for inscr in db.session.get(
|
||||
ModuleImpl, self.moduleimpl_id
|
||||
).formsemestre.inscriptions
|
||||
]
|
||||
|
||||
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
|
||||
def get_evaluations_coefs(self, modimpl: ModuleImpl) -> np.array:
|
||||
"""Coefficients des évaluations.
|
||||
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
|
||||
sont zéro.
|
||||
Les coefs des évals incomplètes, rattrapage, session 2, bonus sont forcés à zéro.
|
||||
Résultat: 2d-array of floats, shape (nb_evals, 1)
|
||||
"""
|
||||
return (
|
||||
np.array(
|
||||
[
|
||||
e.coefficient
|
||||
if e.evaluation_type == scu.EVALUATION_NORMALE
|
||||
else 0.0
|
||||
for e in moduleimpl.evaluations
|
||||
(
|
||||
e.coefficient
|
||||
if e.evaluation_type == Evaluation.EVALUATION_NORMALE
|
||||
else 0.0
|
||||
)
|
||||
for e in modimpl.evaluations
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
* self.evaluations_completes
|
||||
).reshape(-1, 1)
|
||||
|
||||
def get_evaluations_special_coefs(
|
||||
self, modimpl: ModuleImpl, evaluation_type=Evaluation.EVALUATION_SESSION2
|
||||
) -> np.array:
|
||||
"""Coefficients des évaluations de session 2 ou rattrapage.
|
||||
Les évals de session 2 et rattrapage sont réputées "complètes": elles sont toujours
|
||||
prises en compte mais seules les notes numériques et ABS sont utilisées.
|
||||
Résultat: 2d-array of floats, shape (nb_evals, 1)
|
||||
"""
|
||||
return (
|
||||
np.array(
|
||||
[
|
||||
(e.coefficient if e.evaluation_type == evaluation_type else 0.0)
|
||||
for e in modimpl.evaluations
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
).reshape(-1, 1)
|
||||
|
||||
# was _list_notes_evals_titles
|
||||
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
|
||||
"Liste des évaluations complètes"
|
||||
|
@ -266,7 +309,7 @@ class ModuleImplResults:
|
|||
) / [e.note_max / 20.0 for e in moduleimpl.evaluations]
|
||||
|
||||
def get_eval_notes_dict(self, evaluation_id: int) -> dict:
|
||||
"""Notes d'une évaulation, brutes, sous forme d'un dict
|
||||
"""Notes d'une évaluation, brutes, sous forme d'un dict
|
||||
{ etudid : valeur }
|
||||
avec les valeurs float, ou "ABS" ou EXC
|
||||
"""
|
||||
|
@ -275,32 +318,42 @@ class ModuleImplResults:
|
|||
for (etudid, x) in self.evals_notes[evaluation_id].items()
|
||||
}
|
||||
|
||||
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl):
|
||||
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
|
||||
def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
|
||||
"""Les évaluations de rattrapage de ce module.
|
||||
Rattrapage: la moyenne du module est la meilleure note entre moyenne
|
||||
des autres évals et la note eval rattrapage.
|
||||
des autres évals et la moyenne des notes de rattrapage.
|
||||
"""
|
||||
eval_list = [
|
||||
return [
|
||||
e
|
||||
for e in moduleimpl.evaluations
|
||||
if e.evaluation_type == scu.EVALUATION_RATTRAPAGE
|
||||
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
|
||||
]
|
||||
if eval_list:
|
||||
return eval_list[0]
|
||||
return None
|
||||
|
||||
def get_evaluation_session2(self, moduleimpl: ModuleImpl):
|
||||
"""L'évaluation de deuxième session de ce module, ou None s'il n'en a pas.
|
||||
Session 2: remplace la note de moyenne des autres évals.
|
||||
def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
|
||||
"""Les évaluations de deuxième session de ce module, ou None s'il n'en a pas.
|
||||
La moyenne des notes de Session 2 remplace la note de moyenne des autres évals.
|
||||
"""
|
||||
eval_list = [
|
||||
return [
|
||||
e
|
||||
for e in moduleimpl.evaluations
|
||||
if e.evaluation_type == scu.EVALUATION_SESSION2
|
||||
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
|
||||
]
|
||||
|
||||
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
|
||||
"""Les évaluations bonus de ce module, ou liste vide s'il n'en a pas."""
|
||||
return [
|
||||
e
|
||||
for e in modimpl.evaluations
|
||||
if e.evaluation_type == Evaluation.EVALUATION_BONUS
|
||||
]
|
||||
|
||||
def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]:
|
||||
"""Les indices des évaluations bonus"""
|
||||
return [
|
||||
i
|
||||
for (i, e) in enumerate(modimpl.evaluations)
|
||||
if e.evaluation_type == Evaluation.EVALUATION_BONUS
|
||||
]
|
||||
if eval_list:
|
||||
return eval_list[0]
|
||||
return None
|
||||
|
||||
|
||||
class ModuleImplResultsAPC(ModuleImplResults):
|
||||
|
@ -333,6 +386,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
|
|||
return pd.DataFrame(index=[], columns=evals_poids_df.columns)
|
||||
if nb_ues == 0:
|
||||
return pd.DataFrame(index=self.evals_notes.index, columns=[])
|
||||
# coefs des évals complètes normales (pas rattr., session 2 ni bonus):
|
||||
evals_coefs = self.get_evaluations_coefs(modimpl)
|
||||
evals_poids = evals_poids_df.values * evals_coefs
|
||||
# -> evals_poids shape : (nb_evals, nb_ues)
|
||||
|
@ -346,7 +400,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
|
|||
# et dans dans evals_poids_etuds
|
||||
# (rappel: la comparaison est toujours false face à un NaN)
|
||||
# shape: (nb_etuds, nb_evals, nb_ues)
|
||||
poids_stacked = np.stack([evals_poids] * nb_etuds)
|
||||
poids_stacked = np.stack([evals_poids] * nb_etuds) # nb_etuds, nb_evals, nb_ues
|
||||
evals_poids_etuds = np.where(
|
||||
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
|
||||
poids_stacked,
|
||||
|
@ -354,51 +408,58 @@ class ModuleImplResultsAPC(ModuleImplResults):
|
|||
)
|
||||
# Calcule la moyenne pondérée sur les notes disponibles:
|
||||
evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2)
|
||||
# evals_notes_stacked shape: nb_etuds, nb_evals, nb_ues
|
||||
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
|
||||
etuds_moy_module = np.sum(
|
||||
evals_poids_etuds * evals_notes_stacked, axis=1
|
||||
) / np.sum(evals_poids_etuds, axis=1)
|
||||
# etuds_moy_module shape: nb_etuds x nb_ues
|
||||
|
||||
# Session2 : quand elle existe, remplace la note de module
|
||||
eval_session2 = self.get_evaluation_session2(modimpl)
|
||||
if eval_session2:
|
||||
notes_session2 = self.evals_notes[eval_session2.id].values
|
||||
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
|
||||
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
|
||||
evals_session2 = self.get_evaluations_session2(modimpl)
|
||||
evals_rat = self.get_evaluations_rattrapage(modimpl)
|
||||
if evals_session2:
|
||||
# Session2 : quand elle existe, remplace la note de module
|
||||
# Calcul moyenne notes session2 et remplace (si la note session 2 existe)
|
||||
etuds_moy_module_s2 = self._compute_moy_special(
|
||||
modimpl,
|
||||
evals_notes_stacked,
|
||||
evals_poids_df,
|
||||
Evaluation.EVALUATION_SESSION2,
|
||||
)
|
||||
|
||||
# Vrai si toutes les UEs ont bien une note de session 2 calculée:
|
||||
etuds_use_session2 = np.all(np.isfinite(etuds_moy_module_s2), axis=1)
|
||||
etuds_moy_module = np.where(
|
||||
etuds_use_session2[:, np.newaxis],
|
||||
np.tile(
|
||||
(notes_session2 / (eval_session2.note_max / 20.0))[:, np.newaxis],
|
||||
nb_ues,
|
||||
),
|
||||
etuds_moy_module_s2,
|
||||
etuds_moy_module,
|
||||
)
|
||||
self.etuds_use_session2 = pd.Series(
|
||||
etuds_use_session2, index=self.evals_notes.index
|
||||
)
|
||||
else:
|
||||
# Rattrapage: remplace la note de module ssi elle est supérieure
|
||||
eval_rat = self.get_evaluation_rattrapage(modimpl)
|
||||
if eval_rat:
|
||||
notes_rat = self.evals_notes[eval_rat.id].values
|
||||
# remplace les notes invalides (ATT, EXC...) par des NaN
|
||||
notes_rat = np.where(
|
||||
notes_rat > scu.NOTES_ABSENCE,
|
||||
notes_rat / (eval_rat.note_max / 20.0),
|
||||
np.nan,
|
||||
)
|
||||
# "Étend" le rattrapage sur les UE: la note de rattrapage est la même
|
||||
# pour toutes les UE mais ne remplace que là où elle est supérieure
|
||||
notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1)
|
||||
# prend le max
|
||||
etuds_use_rattrapage = notes_rat_ues > etuds_moy_module
|
||||
etuds_moy_module = np.where(
|
||||
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
|
||||
)
|
||||
# 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
|
||||
)
|
||||
elif evals_rat:
|
||||
etuds_moy_module_rat = self._compute_moy_special(
|
||||
modimpl,
|
||||
evals_notes_stacked,
|
||||
evals_poids_df,
|
||||
Evaluation.EVALUATION_RATTRAPAGE,
|
||||
)
|
||||
etuds_ue_use_rattrapage = (
|
||||
etuds_moy_module_rat > etuds_moy_module
|
||||
) # etud x UE
|
||||
etuds_moy_module = np.where(
|
||||
etuds_ue_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
|
||||
)
|
||||
self.etuds_use_rattrapage = pd.Series(
|
||||
np.any(etuds_ue_use_rattrapage, axis=1), index=self.evals_notes.index
|
||||
)
|
||||
# Application des évaluations bonus:
|
||||
etuds_moy_module = self.apply_bonus(
|
||||
etuds_moy_module,
|
||||
modimpl,
|
||||
evals_poids_df,
|
||||
evals_notes_stacked,
|
||||
)
|
||||
self.etuds_moy_module = pd.DataFrame(
|
||||
etuds_moy_module,
|
||||
index=self.evals_notes.index,
|
||||
|
@ -406,6 +467,58 @@ class ModuleImplResultsAPC(ModuleImplResults):
|
|||
)
|
||||
return self.etuds_moy_module
|
||||
|
||||
def _compute_moy_special(
|
||||
self,
|
||||
modimpl: ModuleImpl,
|
||||
evals_notes_stacked: np.array,
|
||||
evals_poids_df: pd.DataFrame,
|
||||
evaluation_type: int,
|
||||
) -> np.array:
|
||||
"""Calcul moyenne APC sur évals rattrapage ou session2"""
|
||||
nb_etuds = self.evals_notes.shape[0]
|
||||
nb_ues = evals_poids_df.shape[1]
|
||||
evals_coefs_s2 = self.get_evaluations_special_coefs(
|
||||
modimpl, evaluation_type=evaluation_type
|
||||
)
|
||||
evals_poids_s2 = evals_poids_df.values * evals_coefs_s2
|
||||
poids_stacked_s2 = np.stack(
|
||||
[evals_poids_s2] * nb_etuds
|
||||
) # nb_etuds, nb_evals, nb_ues
|
||||
evals_poids_etuds_s2 = np.where(
|
||||
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
|
||||
poids_stacked_s2,
|
||||
0,
|
||||
)
|
||||
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
|
||||
etuds_moy_module_s2 = np.sum(
|
||||
evals_poids_etuds_s2 * evals_notes_stacked, axis=1
|
||||
) / np.sum(evals_poids_etuds_s2, axis=1)
|
||||
return etuds_moy_module_s2
|
||||
|
||||
def apply_bonus(
|
||||
self,
|
||||
etuds_moy_module: pd.DataFrame,
|
||||
modimpl: ModuleImpl,
|
||||
evals_poids_df: pd.DataFrame,
|
||||
evals_notes_stacked: np.ndarray,
|
||||
):
|
||||
"""Ajoute les points des évaluations bonus.
|
||||
Il peut y avoir un nb quelconque d'évaluations bonus.
|
||||
Les points sont directement ajoutés (ils peuvent être négatifs).
|
||||
"""
|
||||
evals_bonus = self.get_evaluations_bonus(modimpl)
|
||||
if not evals_bonus:
|
||||
return etuds_moy_module
|
||||
poids_stacked = np.stack([evals_poids_df.values] * len(etuds_moy_module))
|
||||
for evaluation in evals_bonus:
|
||||
eval_idx = evals_poids_df.index.get_loc(evaluation.id)
|
||||
etuds_moy_module += (
|
||||
evals_notes_stacked[:, eval_idx, :] * poids_stacked[:, eval_idx, :]
|
||||
)
|
||||
# Clip dans [0,20]
|
||||
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
|
||||
return etuds_moy_module
|
||||
|
||||
|
||||
def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
|
||||
"""Charge poids des évaluations d'un module et retourne un dataframe
|
||||
|
@ -454,6 +567,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
|
|||
return evals_poids, ues
|
||||
|
||||
|
||||
# appelé par ModuleImpl.check_apc_conformity()
|
||||
def moduleimpl_is_conforme(
|
||||
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
|
||||
) -> bool:
|
||||
|
@ -475,12 +589,12 @@ def moduleimpl_is_conforme(
|
|||
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")
|
||||
return app.critical_error("moduleimpl_is_conforme: err 1")
|
||||
|
||||
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")
|
||||
return app.critical_error("moduleimpl_is_conforme: err 2")
|
||||
|
||||
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
|
||||
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
|
||||
|
@ -522,42 +636,87 @@ class ModuleImplResultsClassic(ModuleImplResults):
|
|||
evals_coefs_etuds * evals_notes_20, axis=1
|
||||
) / np.sum(evals_coefs_etuds, axis=1)
|
||||
|
||||
# Session2 : quand elle existe, remplace la note de module
|
||||
eval_session2 = self.get_evaluation_session2(modimpl)
|
||||
if eval_session2:
|
||||
notes_session2 = self.evals_notes[eval_session2.id].values
|
||||
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
|
||||
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
|
||||
evals_session2 = self.get_evaluations_session2(modimpl)
|
||||
evals_rat = self.get_evaluations_rattrapage(modimpl)
|
||||
if evals_session2:
|
||||
# Session2 : quand elle existe, remplace la note de module
|
||||
# Calcule la moyenne des évaluations de session2
|
||||
etuds_moy_module_s2 = self._compute_moy_special(
|
||||
modimpl, evals_notes_20, Evaluation.EVALUATION_SESSION2
|
||||
)
|
||||
etuds_use_session2 = np.isfinite(etuds_moy_module_s2)
|
||||
etuds_moy_module = np.where(
|
||||
etuds_use_session2,
|
||||
notes_session2 / (eval_session2.note_max / 20.0),
|
||||
etuds_moy_module_s2,
|
||||
etuds_moy_module,
|
||||
)
|
||||
self.etuds_use_session2 = pd.Series(
|
||||
etuds_use_session2, index=self.evals_notes.index
|
||||
)
|
||||
else:
|
||||
elif evals_rat:
|
||||
# Rattrapage: remplace la note de module ssi elle est supérieure
|
||||
eval_rat = self.get_evaluation_rattrapage(modimpl)
|
||||
if eval_rat:
|
||||
notes_rat = self.evals_notes[eval_rat.id].values
|
||||
# remplace les notes invalides (ATT, EXC...) par des NaN
|
||||
notes_rat = np.where(
|
||||
notes_rat > scu.NOTES_ABSENCE,
|
||||
notes_rat / (eval_rat.note_max / 20.0),
|
||||
np.nan,
|
||||
)
|
||||
# prend le max
|
||||
etuds_use_rattrapage = notes_rat > etuds_moy_module
|
||||
etuds_moy_module = np.where(
|
||||
etuds_use_rattrapage, notes_rat, etuds_moy_module
|
||||
)
|
||||
self.etuds_use_rattrapage = pd.Series(
|
||||
etuds_use_rattrapage, index=self.evals_notes.index
|
||||
)
|
||||
# Calcule la moyenne des évaluations de rattrapage
|
||||
etuds_moy_module_rat = self._compute_moy_special(
|
||||
modimpl, evals_notes_20, Evaluation.EVALUATION_RATTRAPAGE
|
||||
)
|
||||
etuds_use_rattrapage = etuds_moy_module_rat > etuds_moy_module
|
||||
etuds_moy_module = np.where(
|
||||
etuds_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
|
||||
)
|
||||
self.etuds_use_rattrapage = pd.Series(
|
||||
etuds_use_rattrapage, index=self.evals_notes.index
|
||||
)
|
||||
|
||||
# Application des évaluations bonus:
|
||||
etuds_moy_module = self.apply_bonus(
|
||||
etuds_moy_module,
|
||||
modimpl,
|
||||
evals_notes_20,
|
||||
)
|
||||
self.etuds_moy_module = pd.Series(
|
||||
etuds_moy_module,
|
||||
index=self.evals_notes.index,
|
||||
)
|
||||
|
||||
return self.etuds_moy_module
|
||||
|
||||
def _compute_moy_special(
|
||||
self, modimpl: ModuleImpl, evals_notes_20: np.array, evaluation_type: int
|
||||
) -> np.array:
|
||||
"""Calcul moyenne sur évals rattrapage ou session2"""
|
||||
# n'utilise que les notes valides et ABS (0).
|
||||
# Même calcul que pour les évals normales, mais avec seulement les
|
||||
# coefs des évals de session 2 ou rattrapage:
|
||||
nb_etuds = self.evals_notes.shape[0]
|
||||
evals_coefs = self.get_evaluations_special_coefs(
|
||||
modimpl, evaluation_type=evaluation_type
|
||||
).reshape(-1)
|
||||
coefs_stacked = np.stack([evals_coefs] * nb_etuds)
|
||||
# zéro partout sauf si une note ou ABS:
|
||||
evals_coefs_etuds = np.where(
|
||||
self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0
|
||||
)
|
||||
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
|
||||
etuds_moy_module = np.sum(
|
||||
evals_coefs_etuds * evals_notes_20, axis=1
|
||||
) / np.sum(evals_coefs_etuds, axis=1)
|
||||
return etuds_moy_module # array 1d (nb_etuds)
|
||||
|
||||
def apply_bonus(
|
||||
self,
|
||||
etuds_moy_module: np.ndarray,
|
||||
modimpl: ModuleImpl,
|
||||
evals_notes_20: np.ndarray,
|
||||
):
|
||||
"""Ajoute les points des évaluations bonus.
|
||||
Il peut y avoir un nb quelconque d'évaluations bonus.
|
||||
Les points sont directement ajoutés (ils peuvent être négatifs).
|
||||
"""
|
||||
evals_bonus_idx = self.get_evaluations_bonus_idx(modimpl)
|
||||
if not evals_bonus_idx:
|
||||
return etuds_moy_module
|
||||
for eval_idx in evals_bonus_idx:
|
||||
etuds_moy_module += evals_notes_20[:, eval_idx]
|
||||
# Clip dans [0,20]
|
||||
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
|
||||
return etuds_moy_module
|
||||
|
|
|
@ -100,7 +100,7 @@ def compute_sem_moys_apc_using_ects(
|
|||
|
||||
|
||||
def comp_ranks_series(notes: pd.Series) -> tuple[pd.Series, pd.Series]:
|
||||
"""Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur
|
||||
"""Calcul rangs à partir d'une série ("vecteur") de notes (index etudid, valeur
|
||||
numérique) en tenant compte des ex-aequos.
|
||||
|
||||
Result: couple (tuple)
|
||||
|
|
|
@ -99,9 +99,11 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
|
|||
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
|
||||
# sur toutes les UE)
|
||||
default_poids = {
|
||||
mod.id: 1.0
|
||||
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
|
||||
else 0.0
|
||||
mod.id: (
|
||||
1.0
|
||||
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
|
||||
else 0.0
|
||||
)
|
||||
for mod in modules
|
||||
}
|
||||
|
||||
|
@ -148,10 +150,12 @@ def df_load_modimpl_coefs(
|
|||
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
|
||||
# sur toutes les UE)
|
||||
default_poids = {
|
||||
modimpl.id: 1.0
|
||||
if (modimpl.module.module_type == ModuleType.STANDARD)
|
||||
and (modimpl.module.ue.type == UE_SPORT)
|
||||
else 0.0
|
||||
modimpl.id: (
|
||||
1.0
|
||||
if (modimpl.module.module_type == ModuleType.STANDARD)
|
||||
and (modimpl.module.ue.type == UE_SPORT)
|
||||
else 0.0
|
||||
)
|
||||
for modimpl in formsemestre.modimpls_sorted
|
||||
}
|
||||
|
||||
|
@ -200,9 +204,10 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
|
|||
modimpls_results = {}
|
||||
modimpls_evals_poids = {}
|
||||
modimpls_notes = []
|
||||
etudids, etudids_actifs = formsemestre.etudids_actifs()
|
||||
for modimpl in formsemestre.modimpls_sorted:
|
||||
mod_results = moy_mod.ModuleImplResultsAPC(modimpl)
|
||||
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
|
||||
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
|
||||
evals_poids = modimpl.get_evaluations_poids()
|
||||
etuds_moy_module = mod_results.compute_module_moy(evals_poids)
|
||||
modimpls_results[modimpl.id] = mod_results
|
||||
modimpls_evals_poids[modimpl.id] = evals_poids
|
||||
|
|
|
@ -273,7 +273,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
|
|||
return s.index[s.notna()]
|
||||
|
||||
def etud_parcours_ues_ids(self, etudid: int) -> set[int]:
|
||||
"""Ensemble les id des UEs que l'étudiant doit valider dans ce semestre compte tenu
|
||||
"""Ensemble des id des UEs que l'étudiant doit valider dans ce semestre compte tenu
|
||||
du parcours dans lequel il est inscrit.
|
||||
Se base sur le parcours dans ce semestre, et le référentiel de compétences.
|
||||
Note: il n'est pas nécessairement inscrit à toutes ces UEs.
|
||||
|
|
|
@ -242,7 +242,8 @@ class ResultatsSemestreClassic(NotesTableCompat):
|
|||
)
|
||||
}">saisir le coefficient de cette UE avant de continuer</a></p>
|
||||
</div>
|
||||
"""
|
||||
""",
|
||||
safe=True,
|
||||
)
|
||||
|
||||
|
||||
|
@ -256,8 +257,9 @@ def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]
|
|||
"""
|
||||
modimpls_results = {}
|
||||
modimpls_notes = []
|
||||
etudids, etudids_actifs = formsemestre.etudids_actifs()
|
||||
for modimpl in formsemestre.modimpls_sorted:
|
||||
mod_results = moy_mod.ModuleImplResultsClassic(modimpl)
|
||||
mod_results = moy_mod.ModuleImplResultsClassic(modimpl, etudids, etudids_actifs)
|
||||
etuds_moy_module = mod_results.compute_module_moy()
|
||||
modimpls_results[modimpl.id] = mod_results
|
||||
modimpls_notes.append(etuds_moy_module)
|
||||
|
|
|
@ -9,12 +9,13 @@
|
|||
|
||||
from collections import Counter, defaultdict
|
||||
from collections.abc import Generator
|
||||
import datetime
|
||||
from functools import cached_property
|
||||
from operator import attrgetter
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import g, url_for
|
||||
|
||||
from app import db
|
||||
|
@ -22,14 +23,19 @@ from app.comp import res_sem
|
|||
from app.comp.res_cache import ResultatsCache
|
||||
from app.comp.jury import ValidationsSemestre
|
||||
from app.comp.moy_mod import ModuleImplResults
|
||||
from app.models import FormSemestre, FormSemestreUECoef
|
||||
from app.models import Identite
|
||||
from app.models import ModuleImpl, ModuleImplInscription
|
||||
from app.models import ScolarAutorisationInscription
|
||||
from app.models.ues import UniteEns
|
||||
from app.models import (
|
||||
Evaluation,
|
||||
FormSemestre,
|
||||
FormSemestreUECoef,
|
||||
Identite,
|
||||
ModuleImpl,
|
||||
ModuleImplInscription,
|
||||
ScolarAutorisationInscription,
|
||||
UniteEns,
|
||||
)
|
||||
from app.scodoc.sco_cache import ResultatsSemestreCache
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_exceptions import ScoValueError, ScoTemporaryError
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
|
@ -192,16 +198,86 @@ class ResultatsSemestre(ResultatsCache):
|
|||
*[mr.etudids_attente for mr in self.modimpls_results.values()]
|
||||
)
|
||||
|
||||
# # Etat des évaluations
|
||||
# # (se substitue à do_evaluation_etat, sans les moyennes par groupes)
|
||||
# def get_evaluations_etats(evaluation_id: int) -> dict:
|
||||
# """Renvoie dict avec les clés:
|
||||
# last_modif
|
||||
# nb_evals_completes
|
||||
# nb_evals_en_cours
|
||||
# nb_evals_vides
|
||||
# attente
|
||||
# """
|
||||
# Etat des évaluations
|
||||
def get_evaluation_etat(self, evaluation: Evaluation) -> dict:
|
||||
"""État d'une évaluation
|
||||
{
|
||||
"coefficient" : float, # 0 si None
|
||||
"description" : str, # de l'évaluation, "" si None
|
||||
"etat" {
|
||||
"blocked" : bool, # vrai si prise en compte bloquée
|
||||
"evalcomplete" : bool,
|
||||
"last_modif" : datetime.datetime | None, # saisie de note la plus récente
|
||||
"nb_notes" : int, # nb notes d'étudiants inscrits
|
||||
"nb_attente" : int, # nb de notes en ATTente (même si bloquée)
|
||||
},
|
||||
"evaluation_id" : int,
|
||||
"jour" : datetime.datetime, # e.date_debut or datetime.datetime(1900, 1, 1)
|
||||
"publish_incomplete" : bool,
|
||||
}
|
||||
"""
|
||||
mod_results = self.modimpls_results.get(evaluation.moduleimpl_id)
|
||||
if mod_results is None:
|
||||
raise ScoTemporaryError() # argh !
|
||||
etat = mod_results.evaluations_etat.get(evaluation.id)
|
||||
if etat is None:
|
||||
raise ScoTemporaryError() # argh !
|
||||
# Date de dernière saisie de note
|
||||
cursor = db.session.execute(
|
||||
sa.text(
|
||||
"SELECT MAX(date) FROM notes_notes WHERE evaluation_id = :evaluation_id"
|
||||
),
|
||||
{"evaluation_id": evaluation.id},
|
||||
)
|
||||
date_modif = cursor.one_or_none()
|
||||
last_modif = date_modif[0] if date_modif else None
|
||||
return {
|
||||
"coefficient": evaluation.coefficient,
|
||||
"description": evaluation.description,
|
||||
"etat": {
|
||||
"blocked": evaluation.is_blocked(),
|
||||
"evalcomplete": etat.is_complete,
|
||||
"nb_attente": etat.nb_attente,
|
||||
"nb_notes": etat.nb_notes,
|
||||
"last_modif": last_modif,
|
||||
},
|
||||
"evaluation_id": evaluation.id,
|
||||
"jour": evaluation.date_debut or datetime.datetime(1900, 1, 1),
|
||||
"publish_incomplete": evaluation.publish_incomplete,
|
||||
}
|
||||
|
||||
def get_mod_evaluation_etat_list(self, modimpl: ModuleImpl) -> list[dict]:
|
||||
"""Liste des états des évaluations de ce module
|
||||
[ evaluation_etat, ... ] (voir get_evaluation_etat)
|
||||
trié par (numero desc, date_debut desc)
|
||||
"""
|
||||
# nouvelle version 2024-02-02
|
||||
return list(
|
||||
reversed(
|
||||
[
|
||||
self.get_evaluation_etat(evaluation)
|
||||
for evaluation in modimpl.evaluations
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
# modernisation de get_mod_evaluation_etat_list
|
||||
# utilisé par:
|
||||
# sco_evaluations.do_evaluation_etat_in_mod
|
||||
# e["etat"]["evalcomplete"]
|
||||
# e["etat"]["nb_notes"]
|
||||
# e["etat"]["last_modif"]
|
||||
#
|
||||
# sco_formsemestre_status.formsemestre_description_table
|
||||
# "jour" (qui est e.date_debut or datetime.date(1900, 1, 1))
|
||||
# "description"
|
||||
# "coefficient"
|
||||
# e["etat"]["evalcomplete"]
|
||||
# publish_incomplete
|
||||
#
|
||||
# sco_formsemestre_status.formsemestre_tableau_modules
|
||||
# e["etat"]["nb_notes"]
|
||||
#
|
||||
|
||||
# --- JURY...
|
||||
def get_formsemestre_validations(self) -> ValidationsSemestre:
|
||||
|
@ -360,11 +436,28 @@ class ResultatsSemestre(ResultatsCache):
|
|||
ue_cap_dict["compense_formsemestre_id"] = None
|
||||
return ue_cap_dict
|
||||
|
||||
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict:
|
||||
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict | None:
|
||||
"""L'état de l'UE pour cet étudiant.
|
||||
Result: dict, ou None si l'UE n'est pas dans ce semestre.
|
||||
Result: dict, ou None si l'UE n'existe pas ou n'est pas dans ce semestre.
|
||||
{
|
||||
"is_capitalized": # vrai si la version capitalisée est celle prise en compte (meilleure)
|
||||
"was_capitalized":# si elle a été capitalisée (meilleure ou pas)
|
||||
"is_external": # si UE externe
|
||||
"coef_ue": 0.0,
|
||||
"cur_moy_ue": 0.0, # moyenne de l'UE courante
|
||||
"moy": 0.0, # moyenne prise en compte
|
||||
"event_date": # date de la capiltalisation éventuelle (ou None)
|
||||
"ue": ue_dict, # l'UE, comme un dict
|
||||
"formsemestre_id": None,
|
||||
"capitalized_ue_id": None, # l'id de l'UE capitalisée, ou None
|
||||
"ects_pot": 0.0, # deprecated (les ECTS liés à cette UE)
|
||||
"ects": 0.0, # les ECTS acquis grace à cette UE
|
||||
"ects_ue": # les ECTS liés à cette UE
|
||||
}
|
||||
"""
|
||||
ue: UniteEns = db.session.get(UniteEns, ue_id)
|
||||
if not ue:
|
||||
return None
|
||||
ue_dict = ue.to_dict()
|
||||
|
||||
if ue.type == UE_SPORT:
|
||||
|
@ -383,7 +476,7 @@ class ResultatsSemestre(ResultatsCache):
|
|||
"ects": 0.0,
|
||||
"ects_ue": ue.ects,
|
||||
}
|
||||
if not ue_id in self.etud_moy_ue:
|
||||
if not ue_id in self.etud_moy_ue or not etudid in self.etud_moy_ue[ue_id]:
|
||||
return None
|
||||
if not self.validations:
|
||||
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
|
||||
|
@ -425,7 +518,8 @@ class ResultatsSemestre(ResultatsCache):
|
|||
Corrigez ou faite corriger le programme
|
||||
<a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
|
||||
formation_id=ue_capitalized.formation_id)}">via cette page</a>.
|
||||
"""
|
||||
""",
|
||||
safe=True,
|
||||
)
|
||||
else:
|
||||
# Coefs de l'UE capitalisée en formation classique:
|
||||
|
@ -440,11 +534,13 @@ class ResultatsSemestre(ResultatsCache):
|
|||
"is_external": ue_cap["is_external"] if is_capitalized else ue.is_external,
|
||||
"coef_ue": coef_ue,
|
||||
"ects_pot": ue.ects or 0.0,
|
||||
"ects": self.validations.decisions_jury_ues.get(etudid, {})
|
||||
.get(ue.id, {})
|
||||
.get("ects", 0.0)
|
||||
if self.validations.decisions_jury_ues
|
||||
else 0.0,
|
||||
"ects": (
|
||||
self.validations.decisions_jury_ues.get(etudid, {})
|
||||
.get(ue.id, {})
|
||||
.get("ects", 0.0)
|
||||
if self.validations.decisions_jury_ues
|
||||
else 0.0
|
||||
),
|
||||
"ects_ue": ue.ects,
|
||||
"cur_moy_ue": cur_moy_ue,
|
||||
"moy": moy_ue,
|
||||
|
|
|
@ -58,7 +58,6 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
self.moy_moy = "NA"
|
||||
self.moy_gen_rangs_by_group = {} # { group_id : (Series, Series) }
|
||||
self.ue_rangs_by_group = {} # { ue_id : {group_id : (Series, Series)}}
|
||||
self.expr_diagnostics = ""
|
||||
self.parcours = self.formsemestre.formation.get_cursus()
|
||||
self._modimpls_dict_by_ue = {} # local cache
|
||||
|
||||
|
@ -217,9 +216,9 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
# Rangs / UEs:
|
||||
for ue in ues:
|
||||
group_moys_ue = self.etud_moy_ue[ue.id][group_members]
|
||||
self.ue_rangs_by_group.setdefault(ue.id, {})[
|
||||
group.id
|
||||
] = moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
|
||||
self.ue_rangs_by_group.setdefault(ue.id, {})[group.id] = (
|
||||
moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
|
||||
)
|
||||
|
||||
def get_etud_rang(self, etudid: int) -> str:
|
||||
"""Le rang (classement) de l'étudiant dans le semestre.
|
||||
|
@ -423,30 +422,37 @@ class NotesTableCompat(ResultatsSemestre):
|
|||
)
|
||||
return evaluations
|
||||
|
||||
def get_evaluations_etats(self) -> list[dict]:
|
||||
"""Liste de toutes les évaluations du semestre
|
||||
[ {...evaluation et son etat...} ]"""
|
||||
# TODO: à moderniser (voir dans ResultatsSemestre)
|
||||
# utilisé par
|
||||
# do_evaluation_etat_in_sem
|
||||
def get_evaluations_etats(self) -> dict[int, dict]:
|
||||
""" "état" de chaque évaluation du semestre
|
||||
{
|
||||
evaluation_id : {
|
||||
"evalcomplete" : bool,
|
||||
"last_modif" : datetime | None
|
||||
"nb_notes" : int,
|
||||
}, ...
|
||||
}
|
||||
"""
|
||||
# utilisé par do_evaluation_etat_in_sem
|
||||
evaluations_etats = {}
|
||||
for modimpl in self.formsemestre.modimpls_sorted:
|
||||
for evaluation in modimpl.evaluations:
|
||||
evaluation_etat = self.get_evaluation_etat(evaluation)
|
||||
evaluations_etats[evaluation.id] = evaluation_etat["etat"]
|
||||
return evaluations_etats
|
||||
|
||||
from app.scodoc import sco_evaluations
|
||||
|
||||
if not hasattr(self, "_evaluations_etats"):
|
||||
self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem(
|
||||
self.formsemestre.id
|
||||
)
|
||||
|
||||
return self._evaluations_etats
|
||||
|
||||
def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
|
||||
"""Liste des états des évaluations de ce module"""
|
||||
# XXX TODO à moderniser: lent, recharge des données que l'on a déjà...
|
||||
return [
|
||||
e
|
||||
for e in self.get_evaluations_etats()
|
||||
if e["moduleimpl_id"] == moduleimpl_id
|
||||
]
|
||||
# ancienne version < 2024-02-02
|
||||
# def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
|
||||
# """Liste des états des évaluations de ce module
|
||||
# ordonnée selon (numero desc, date_debut desc)
|
||||
# """
|
||||
# # à moderniser: lent, recharge des données que l'on a déjà...
|
||||
# # remplacemé par ResultatsSemestre.get_mod_evaluation_etat_list
|
||||
# #
|
||||
# return [
|
||||
# e
|
||||
# for e in self.get_evaluations_etats()
|
||||
# if e["moduleimpl_id"] == moduleimpl_id
|
||||
# ]
|
||||
|
||||
def get_moduleimpls_attente(self):
|
||||
"""Liste des modimpls du semestre ayant des notes en attente"""
|
||||
|
|
22
app/email.py
22
app/email.py
|
@ -5,12 +5,13 @@
|
|||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
import datetime
|
||||
from threading import Thread
|
||||
|
||||
from flask import current_app, g
|
||||
from flask_mail import Message
|
||||
from flask_mail import BadHeaderError, Message
|
||||
|
||||
from app import mail
|
||||
from app import log, mail
|
||||
from app.models.departements import Departement
|
||||
from app.models.config import ScoDocSiteConfig
|
||||
from app.scodoc import sco_preferences
|
||||
|
@ -19,7 +20,15 @@ from app.scodoc import sco_preferences
|
|||
def send_async_email(app, msg):
|
||||
"Send an email, async"
|
||||
with app.app_context():
|
||||
mail.send(msg)
|
||||
try:
|
||||
mail.send(msg)
|
||||
except BadHeaderError:
|
||||
log(
|
||||
f"""send_async_email: BadHeaderError
|
||||
msg={msg}
|
||||
"""
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
def send_email(
|
||||
|
@ -83,9 +92,12 @@ Adresses d'origine:
|
|||
\n\n"""
|
||||
+ msg.body
|
||||
)
|
||||
|
||||
now = datetime.datetime.now()
|
||||
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S") + ".{:03d}".format(
|
||||
now.microsecond // 1000
|
||||
)
|
||||
current_app.logger.info(
|
||||
f"""email sent to{
|
||||
f"""[{formatted_time}] email sent to{
|
||||
' (mode test)' if email_test_mode_address else ''
|
||||
}: {msg.recipients}
|
||||
from sender {msg.sender}
|
||||
|
|
|
@ -59,3 +59,4 @@ def check_taxe_now(taxes):
|
|||
|
||||
|
||||
from app.entreprises import routes
|
||||
from app.entreprises.activate import activate_module
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
##############################################################################
|
||||
# ScoDoc
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# See LICENSE
|
||||
##############################################################################
|
||||
|
||||
"""Activation du module entreprises
|
||||
|
||||
L'affichage du module est contrôlé par la config ScoDocConfig.enable_entreprises
|
||||
|
||||
Au moment de l'activation, il est en général utile de proposer de configurer les
|
||||
permissions de rôles standards: AdminEntreprise UtilisateurEntreprise ObservateurEntreprise
|
||||
|
||||
Voir associations dans sco_roles_default
|
||||
|
||||
"""
|
||||
from app.auth.models import Role
|
||||
from app.models import ScoDocSiteConfig
|
||||
from app.scodoc.sco_roles_default import SCO_ROLES_ENTREPRISES_DEFAULT
|
||||
|
||||
|
||||
def activate_module(
|
||||
enable: bool = True, set_default_roles_permission: bool = False
|
||||
) -> bool:
|
||||
"""Active le module et en option donne les permissions aux rôles standards.
|
||||
True si l'état d'activation a changé.
|
||||
"""
|
||||
change = ScoDocSiteConfig.enable_entreprises(enable)
|
||||
if enable and set_default_roles_permission:
|
||||
Role.reset_roles_permissions(SCO_ROLES_ENTREPRISES_DEFAULT)
|
||||
return change
|
|
@ -338,9 +338,11 @@ def add_entreprise():
|
|||
if form.validate_on_submit():
|
||||
entreprise = Entreprise(
|
||||
nom=form.nom_entreprise.data.strip(),
|
||||
siret=form.siret.data.strip()
|
||||
if form.siret.data.strip()
|
||||
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}", # siret provisoire
|
||||
siret=(
|
||||
form.siret.data.strip()
|
||||
if form.siret.data.strip()
|
||||
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}"
|
||||
), # siret provisoire
|
||||
siret_provisoire=False if form.siret.data.strip() else True,
|
||||
association=form.association.data,
|
||||
adresse=form.adresse.data.strip(),
|
||||
|
@ -352,7 +354,7 @@ def add_entreprise():
|
|||
db.session.add(entreprise)
|
||||
db.session.commit()
|
||||
db.session.refresh(entreprise)
|
||||
except:
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
flash("Une erreur est survenue veuillez réessayer.")
|
||||
return render_template(
|
||||
|
@ -804,9 +806,9 @@ def add_offre(entreprise_id):
|
|||
missions=form.missions.data.strip(),
|
||||
duree=form.duree.data.strip(),
|
||||
expiration_date=form.expiration_date.data,
|
||||
correspondant_id=form.correspondant.data
|
||||
if form.correspondant.data != ""
|
||||
else None,
|
||||
correspondant_id=(
|
||||
form.correspondant.data if form.correspondant.data != "" else None
|
||||
),
|
||||
)
|
||||
db.session.add(offre)
|
||||
db.session.commit()
|
||||
|
@ -1328,9 +1330,11 @@ def add_contact(entreprise_id):
|
|||
).first_or_404(description=f"entreprise {entreprise_id} inconnue")
|
||||
form = ContactCreationForm(
|
||||
date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}",
|
||||
utilisateur=f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
|
||||
if current_user.nom and current_user.prenom
|
||||
else "",
|
||||
utilisateur=(
|
||||
f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
|
||||
if current_user.nom and current_user.prenom
|
||||
else ""
|
||||
),
|
||||
)
|
||||
if request.method == "POST" and form.cancel.data:
|
||||
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise_id))
|
||||
|
@ -1496,9 +1500,9 @@ def add_stage_apprentissage(entreprise_id):
|
|||
date_debut=form.date_debut.data,
|
||||
date_fin=form.date_fin.data,
|
||||
formation_text=formation.formsemestre.titre if formation else None,
|
||||
formation_scodoc=formation.formsemestre.formsemestre_id
|
||||
if formation
|
||||
else None,
|
||||
formation_scodoc=(
|
||||
formation.formsemestre.formsemestre_id if formation else None
|
||||
),
|
||||
notes=form.notes.data.strip(),
|
||||
)
|
||||
db.session.add(stage_apprentissage)
|
||||
|
@ -1802,7 +1806,7 @@ def import_donnees():
|
|||
db.session.add(entreprise)
|
||||
db.session.commit()
|
||||
db.session.refresh(entreprise)
|
||||
except:
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
flash("Une erreur est survenue veuillez réessayer.")
|
||||
return render_template(
|
||||
|
|
|
@ -102,7 +102,7 @@ class AjoutAssiOrJustForm(FlaskForm):
|
|||
)
|
||||
|
||||
entry_date = StringField(
|
||||
"Date de dépot ou saisie",
|
||||
"Date de dépôt ou saisie",
|
||||
validators=[validators.Length(max=10)],
|
||||
render_kw={
|
||||
"class": "datepicker",
|
||||
|
@ -110,12 +110,23 @@ class AjoutAssiOrJustForm(FlaskForm):
|
|||
"id": "entry_date",
|
||||
},
|
||||
)
|
||||
entry_time = StringField(
|
||||
"Heure dépôt",
|
||||
default="",
|
||||
validators=[validators.Length(max=5)],
|
||||
render_kw={
|
||||
"class": "timepicker",
|
||||
"size": 5,
|
||||
"id": "assi_heure_fin",
|
||||
},
|
||||
)
|
||||
submit = SubmitField("Enregistrer")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
||||
|
||||
|
||||
class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
|
||||
"Formulaire de saisie d'une assiduité pour un étudiant"
|
||||
|
||||
description = TextAreaField(
|
||||
"Description",
|
||||
render_kw={
|
||||
|
@ -142,6 +153,7 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
|
|||
|
||||
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
|
||||
"Formulaire de saisie d'un justificatif pour un étudiant"
|
||||
|
||||
raison = TextAreaField(
|
||||
"Raison",
|
||||
render_kw={
|
||||
|
@ -166,6 +178,12 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
|
|||
|
||||
|
||||
class ChoixDateForm(FlaskForm):
|
||||
"""
|
||||
Formulaire de choix de date
|
||||
(utilisé par la page de choix de date
|
||||
si la date courante n'est pas dans le semestre)
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"Init form, adding a filed for our error messages"
|
||||
super().__init__(*args, **kwargs)
|
||||
|
|
|
@ -17,7 +17,7 @@ def UEParcoursECTSForm(ue: UniteEns) -> FlaskForm:
|
|||
pass
|
||||
|
||||
parcours: list[ApcParcours] = ue.formation.referentiel_competence.parcours
|
||||
# Initialise un champs de saisie par parcours
|
||||
# Initialise un champ de saisie par parcours
|
||||
for parcour in parcours:
|
||||
ects = ue.get_ects(parcour, only_parcours=True)
|
||||
setattr(
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
"""
|
||||
Formulaire activation module entreprises
|
||||
"""
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms.fields.simple import BooleanField, SubmitField
|
||||
|
||||
from app.models import ScoDocSiteConfig
|
||||
|
||||
|
||||
class ActivateEntreprisesForm(FlaskForm):
|
||||
"Formulaire activation module entreprises"
|
||||
set_default_roles_permission = BooleanField(
|
||||
"(re)mettre les rôles 'Entreprise' à leurs valeurs par défaut"
|
||||
)
|
||||
submit = SubmitField("Valider")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
|
@ -82,7 +82,7 @@ class ConfigCASForm(FlaskForm):
|
|||
|
||||
cas_attribute_id = StringField(
|
||||
label="Attribut CAS utilisé comme id (laissez vide pour prendre l'id par défaut)",
|
||||
description="""Le champs CAS qui sera considéré comme l'id unique des
|
||||
description="""Le champ CAS qui sera considéré comme l'id unique des
|
||||
comptes utilisateurs.""",
|
||||
)
|
||||
|
||||
|
|
|
@ -48,13 +48,15 @@ class BonusConfigurationForm(FlaskForm):
|
|||
for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list()
|
||||
],
|
||||
)
|
||||
submit_bonus = SubmitField("Valider")
|
||||
submit_bonus = SubmitField("Enregistrer ce bonus")
|
||||
cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
||||
|
||||
|
||||
class ScoDocConfigurationForm(FlaskForm):
|
||||
"Panneau de configuration avancée"
|
||||
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
|
||||
disable_passerelle = BooleanField( # disable car par défaut activée
|
||||
"""cacher les fonctions liées à une passerelle de publication des résultats vers les étudiants ("œil"). N'affecte pas l'API, juste la présentation."""
|
||||
)
|
||||
month_debut_annee_scolaire = SelectField(
|
||||
label="Mois de début des années scolaires",
|
||||
description="""Date pivot. En France métropolitaine, août.
|
||||
|
@ -83,7 +85,7 @@ class ScoDocConfigurationForm(FlaskForm):
|
|||
disable_bul_pdf = BooleanField(
|
||||
"interdire les exports des bulletins en PDF (déconseillé)"
|
||||
)
|
||||
submit_scodoc = SubmitField("Valider")
|
||||
submit_scodoc = SubmitField("Enregistrer ces paramètres")
|
||||
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
||||
|
||||
|
||||
|
@ -98,6 +100,7 @@ def configuration():
|
|||
form_scodoc = ScoDocConfigurationForm(
|
||||
data={
|
||||
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
|
||||
"disable_passerelle": ScoDocSiteConfig.is_passerelle_disabled(),
|
||||
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
|
||||
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
|
||||
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
|
||||
|
@ -123,12 +126,12 @@ def configuration():
|
|||
flash("Fonction bonus inchangée.")
|
||||
return redirect(url_for("scodoc.index"))
|
||||
elif form_scodoc.submit_scodoc.data and form_scodoc.validate():
|
||||
if ScoDocSiteConfig.enable_entreprises(
|
||||
enabled=form_scodoc.data["enable_entreprises"]
|
||||
if ScoDocSiteConfig.disable_passerelle(
|
||||
disabled=form_scodoc.data["disable_passerelle"]
|
||||
):
|
||||
flash(
|
||||
"Module entreprise "
|
||||
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
|
||||
"Fonction passerelle "
|
||||
+ ("cachées" if form_scodoc.data["disable_passerelle"] else "montrées")
|
||||
)
|
||||
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
|
||||
int(form_scodoc.data["month_debut_annee_scolaire"])
|
||||
|
@ -171,6 +174,7 @@ def configuration():
|
|||
|
||||
return render_template(
|
||||
"configuration.j2",
|
||||
is_entreprises_enabled=ScoDocSiteConfig.is_entreprises_enabled(),
|
||||
form_bonus=form_bonus,
|
||||
form_scodoc=form_scodoc,
|
||||
scu=scu,
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
##############################################################################
|
||||
#
|
||||
# ScoDoc
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Formulaire options génération table poursuite études (PE)
|
||||
"""
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import BooleanField, HiddenField, SubmitField
|
||||
|
||||
|
||||
class ParametrageClasseurPE(FlaskForm):
|
||||
"Formulaire paramétrage génération classeur PE"
|
||||
# cohorte_restreinte = BooleanField(
|
||||
# "Restreindre aux étudiants inscrits dans le semestre (sans interclassement de promotion) (à venir)"
|
||||
# )
|
||||
moyennes_tags = BooleanField(
|
||||
"Générer les moyennes sur les tags de modules personnalisés (cf. programme de formation)",
|
||||
default=True,
|
||||
render_kw={"checked": ""},
|
||||
)
|
||||
moyennes_ue_res_sae = BooleanField(
|
||||
"Générer les moyennes des ressources et des SAEs",
|
||||
default=True,
|
||||
render_kw={"checked": ""},
|
||||
)
|
||||
moyennes_ues_rcues = BooleanField(
|
||||
"Générer les moyennes par RCUEs (compétences) et leurs synthèses HTML étudiant par étudiant",
|
||||
default=True,
|
||||
render_kw={"checked": ""},
|
||||
)
|
||||
|
||||
min_max_moy = BooleanField("Afficher les colonnes min/max/moy")
|
||||
|
||||
# synthese_individuelle_etud = BooleanField(
|
||||
# "Générer (suppose les RCUES)"
|
||||
# )
|
||||
publipostage = BooleanField(
|
||||
"Nomme les moyennes pour publipostage",
|
||||
# default=False,
|
||||
# render_kw={"checked": ""},
|
||||
)
|
||||
submit = SubmitField("Générer les classeurs poursuites d'études")
|
||||
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
|
|
@ -52,7 +52,7 @@ class ScoDocModel(db.Model):
|
|||
def create_from_dict(cls, data: dict) -> "ScoDocModel":
|
||||
"""Create a new instance of the model with attributes given in dict.
|
||||
The instance is added to the session (but not flushed nor committed).
|
||||
Use only relevant arributes for the given model and ignore others.
|
||||
Use only relevant attributes for the given model and ignore others.
|
||||
"""
|
||||
if data:
|
||||
args = cls.convert_dict_fields(cls.filter_model_attributes(data))
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
# -*- coding: UTF-8 -*
|
||||
"""Gestion de l'assiduité (assiduités + justificatifs)
|
||||
"""
|
||||
"""Gestion de l'assiduité (assiduités + justificatifs)"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from flask_login import current_user
|
||||
from flask_sqlalchemy.query import Query
|
||||
from sqlalchemy.exc import DataError
|
||||
|
||||
from app import db, log, g, set_sco_dept
|
||||
from app.models import (
|
||||
|
@ -22,6 +21,7 @@ from app.scodoc import sco_abs_notification
|
|||
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_utils import (
|
||||
EtatAssiduite,
|
||||
EtatJustificatif,
|
||||
|
@ -89,6 +89,8 @@ class Assiduite(ScoDocModel):
|
|||
lazy="select",
|
||||
)
|
||||
|
||||
# Argument "restrict" obligatoire car on override la fonction "to_dict" de ScoDocModel
|
||||
# pylint: disable-next=unused-argument
|
||||
def to_dict(self, format_api=True, restrict: bool | None = None) -> dict:
|
||||
"""Retourne la représentation json de l'assiduité
|
||||
restrict n'est pas utilisé ici.
|
||||
|
@ -112,9 +114,9 @@ class Assiduite(ScoDocModel):
|
|||
"entry_date": self.entry_date,
|
||||
"user_id": None if user is None else user.id, # l'uid
|
||||
"user_name": None if user is None else user.user_name, # le login
|
||||
"user_nom_complet": None
|
||||
if user is None
|
||||
else user.get_nomcomplet(), # "Marie Dupont"
|
||||
"user_nom_complet": (
|
||||
None if user is None else user.get_nomcomplet()
|
||||
), # "Marie Dupont"
|
||||
"est_just": self.est_just,
|
||||
"external_data": self.external_data,
|
||||
}
|
||||
|
@ -307,6 +309,9 @@ class Assiduite(ScoDocModel):
|
|||
|
||||
def supprime(self):
|
||||
"Supprime l'assiduité. Log et commit."
|
||||
|
||||
# Obligatoire car import circulaire sinon
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from app.scodoc import sco_assiduites as scass
|
||||
|
||||
if g.scodoc_dept is None and self.etudiant.dept_id is not None:
|
||||
|
@ -332,13 +337,19 @@ class Assiduite(ScoDocModel):
|
|||
"""
|
||||
return get_formsemestre_from_data(self.to_dict())
|
||||
|
||||
def get_module(self, traduire: bool = False) -> int | str:
|
||||
"TODO documenter"
|
||||
def get_module(self, traduire: bool = False) -> Module | str:
|
||||
"""
|
||||
Retourne le module associé à l'assiduité
|
||||
Si traduire est vrai, retourne le titre du module précédé du code
|
||||
Sinon rentourne l'objet Module ou None
|
||||
"""
|
||||
|
||||
if self.moduleimpl_id is not None:
|
||||
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
|
||||
mod: Module = Module.query.get(modimpl.module_id)
|
||||
if traduire:
|
||||
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
|
||||
mod: Module = Module.query.get(modimpl.module_id)
|
||||
return f"{mod.code} {mod.titre}"
|
||||
return mod
|
||||
|
||||
elif self.external_data is not None and "module" in self.external_data:
|
||||
return (
|
||||
|
@ -354,9 +365,9 @@ class Assiduite(ScoDocModel):
|
|||
retourne le texte "saisie le <date> par <User>"
|
||||
"""
|
||||
|
||||
date: str = self.entry_date.strftime("%d/%m/%Y à %H:%M")
|
||||
date: str = self.entry_date.strftime(scu.DATEATIME_FMT)
|
||||
utilisateur: str = ""
|
||||
if self.user != None:
|
||||
if self.user is not None:
|
||||
self.user: User
|
||||
utilisateur = f"par {self.user.get_prenomnom()}"
|
||||
|
||||
|
@ -418,7 +429,7 @@ class Justificatif(ScoDocModel):
|
|||
|
||||
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
"date de création de l'élément: date de saisie"
|
||||
# pourrait devenir date de dépot au secrétariat, si différente
|
||||
# pourrait devenir date de dépôt au secrétariat, si différente
|
||||
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
|
@ -515,6 +526,8 @@ class Justificatif(ScoDocModel):
|
|||
def create_justificatif(
|
||||
cls,
|
||||
etudiant: Identite,
|
||||
# On a besoin des arguments mais on utilise "locals" pour les récupérer
|
||||
# pylint: disable=unused-argument
|
||||
date_debut: datetime,
|
||||
date_fin: datetime,
|
||||
etat: EtatJustificatif,
|
||||
|
@ -538,8 +551,10 @@ class Justificatif(ScoDocModel):
|
|||
|
||||
def supprime(self):
|
||||
"Supprime le justificatif. Log et commit."
|
||||
|
||||
# Obligatoire car import circulaire sinon
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from app.scodoc import sco_assiduites as scass
|
||||
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
|
||||
|
||||
# Récupération de l'archive du justificatif
|
||||
archive_name: str = self.fichier
|
||||
|
@ -566,11 +581,7 @@ class Justificatif(ScoDocModel):
|
|||
db.session.delete(self)
|
||||
db.session.commit()
|
||||
# On actualise les assiduités justifiées de l'étudiant concerné
|
||||
compute_assiduites_justified(
|
||||
self.etudid,
|
||||
Justificatif.query.filter_by(etudid=self.etudid).all(),
|
||||
True,
|
||||
)
|
||||
self.dejustifier_assiduites()
|
||||
|
||||
def get_fichiers(self) -> tuple[list[str], int]:
|
||||
"""Renvoie la liste des noms de fichiers justicatifs
|
||||
|
@ -592,6 +603,82 @@ class Justificatif(ScoDocModel):
|
|||
accessible_filenames.append(filename[0])
|
||||
return accessible_filenames, len(filenames)
|
||||
|
||||
def justifier_assiduites(
|
||||
self,
|
||||
) -> list[int]:
|
||||
"""Justifie les assiduités sur la période de validité du justificatif"""
|
||||
log(f"justifier_assiduites: {self}")
|
||||
assiduites_justifiees: list[int] = []
|
||||
if self.etat != EtatJustificatif.VALIDE:
|
||||
return []
|
||||
# On récupère les assiduités de l'étudiant sur la période donnée
|
||||
assiduites: Query = self.etudiant.assiduites.filter(
|
||||
Assiduite.date_debut >= self.date_debut,
|
||||
Assiduite.date_fin <= self.date_fin,
|
||||
Assiduite.etat != EtatAssiduite.PRESENT,
|
||||
)
|
||||
# Pour chaque assiduité, on la justifie
|
||||
for assi in assiduites:
|
||||
assi.est_just = True
|
||||
assiduites_justifiees.append(assi.assiduite_id)
|
||||
db.session.add(assi)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return assiduites_justifiees
|
||||
|
||||
def dejustifier_assiduites(self) -> list[int]:
|
||||
"""
|
||||
Déjustifie les assiduités sur la période du justificatif
|
||||
"""
|
||||
assiduites_dejustifiees: list[int] = []
|
||||
|
||||
# On récupère les assiduités de l'étudiant sur la période donnée
|
||||
assiduites: Query = self.etudiant.assiduites.filter(
|
||||
Assiduite.date_debut >= self.date_debut,
|
||||
Assiduite.date_fin <= self.date_fin,
|
||||
Assiduite.etat != EtatAssiduite.PRESENT,
|
||||
)
|
||||
assi: Assiduite
|
||||
for assi in assiduites:
|
||||
# On récupère les justificatifs qui justifient l'assiduité `assi`
|
||||
assi_justifs: list[int] = get_justifs_from_date(
|
||||
self.etudiant.etudid,
|
||||
assi.date_debut,
|
||||
assi.date_fin,
|
||||
long=False,
|
||||
valid=True,
|
||||
)
|
||||
# Si il n'y a pas d'autre justificatif valide, on déjustifie l'assiduité
|
||||
if len(assi_justifs) == 0 or (
|
||||
len(assi_justifs) == 1 and assi_justifs[0] == self.justif_id
|
||||
):
|
||||
assi.est_just = False
|
||||
assiduites_dejustifiees.append(assi.assiduite_id)
|
||||
db.session.add(assi)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return assiduites_dejustifiees
|
||||
|
||||
def get_assiduites(self) -> Query:
|
||||
"""
|
||||
get_assiduites Récupère les assiduités qui sont concernées par le justificatif
|
||||
(Concernée ≠ Justifiée, mais qui sont sur la même période)
|
||||
Ne prends pas en compte les Présences
|
||||
Returns:
|
||||
Query: Les assiduités concernées
|
||||
"""
|
||||
|
||||
assiduites_query = Assiduite.query.filter(
|
||||
Assiduite.etudid == self.etudid,
|
||||
Assiduite.date_debut >= self.date_debut,
|
||||
Assiduite.date_fin <= self.date_fin,
|
||||
Assiduite.etat != EtatAssiduite.PRESENT,
|
||||
)
|
||||
|
||||
return assiduites_query
|
||||
|
||||
|
||||
def is_period_conflicting(
|
||||
date_debut: datetime,
|
||||
|
@ -615,72 +702,6 @@ def is_period_conflicting(
|
|||
return count > 0
|
||||
|
||||
|
||||
def compute_assiduites_justified(
|
||||
etudid: int, justificatifs: list[Justificatif] = None, reset: bool = False
|
||||
) -> list[int]:
|
||||
"""
|
||||
Args:
|
||||
etudid (int): l'identifiant de l'étudiant
|
||||
justificatifs (list[Justificatif]): La liste des justificatifs qui seront utilisés
|
||||
reset (bool, optional): remet à false les assiduites non justifiés. Defaults to False.
|
||||
|
||||
Returns:
|
||||
list[int]: la liste des assiduités qui ont été justifiées.
|
||||
"""
|
||||
# TODO à optimiser (car très long avec 40000 assiduités)
|
||||
# On devrait :
|
||||
# - récupérer uniquement les assiduités qui sont sur la période des justificatifs donnés
|
||||
# - Pour chaque assiduité trouvée, il faut récupérer les justificatifs qui la justifie
|
||||
# - Si au moins un justificatif valide couvre la période de l'assiduité alors on la justifie
|
||||
|
||||
# Si on ne donne pas de justificatifs on prendra par défaut tous les justificatifs de l'étudiant
|
||||
if justificatifs is None:
|
||||
justificatifs: list[Justificatif] = Justificatif.query.filter_by(
|
||||
etudid=etudid
|
||||
).all()
|
||||
|
||||
# On ne prend que les justificatifs valides
|
||||
justificatifs = [j for j in justificatifs if j.etat == EtatJustificatif.VALIDE]
|
||||
|
||||
# On récupère les assiduités de l'étudiant
|
||||
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
|
||||
|
||||
assiduites_justifiees: list[int] = []
|
||||
|
||||
for assi in assiduites:
|
||||
# On ne justifie pas les Présences
|
||||
if assi.etat == EtatAssiduite.PRESENT:
|
||||
continue
|
||||
|
||||
# On récupère les justificatifs qui justifient l'assiduité `assi`
|
||||
assi_justificatifs = Justificatif.query.filter(
|
||||
Justificatif.etudid == assi.etudid,
|
||||
Justificatif.date_debut <= assi.date_debut,
|
||||
Justificatif.date_fin >= assi.date_fin,
|
||||
Justificatif.etat == EtatJustificatif.VALIDE,
|
||||
).all()
|
||||
|
||||
# Si au moins un justificatif possède une période qui couvre l'assiduité
|
||||
if any(
|
||||
assi.date_debut >= j.date_debut and assi.date_fin <= j.date_fin
|
||||
for j in justificatifs + assi_justificatifs
|
||||
):
|
||||
# On justifie l'assiduité
|
||||
# On ajoute l'id de l'assiduité à la liste des assiduités justifiées
|
||||
assi.est_just = True
|
||||
assiduites_justifiees.append(assi.assiduite_id)
|
||||
db.session.add(assi)
|
||||
elif reset:
|
||||
# Si le paramètre reset est Vrai alors les assiduités non justifiées
|
||||
# sont remise en "non justifiée"
|
||||
assi.est_just = False
|
||||
db.session.add(assi)
|
||||
# On valide la session
|
||||
db.session.commit()
|
||||
# On renvoie la liste des assiduite_id des assiduités justifiées
|
||||
return assiduites_justifiees
|
||||
|
||||
|
||||
def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]:
|
||||
"""
|
||||
get_assiduites_justif Récupération des justificatifs d'une assiduité
|
||||
|
|
|
@ -8,16 +8,19 @@
|
|||
from datetime import datetime
|
||||
import functools
|
||||
from operator import attrgetter
|
||||
import yaml
|
||||
|
||||
from flask import g
|
||||
from flask_sqlalchemy.query import Query
|
||||
from sqlalchemy.orm import class_mapper
|
||||
import sqlalchemy
|
||||
|
||||
from app import db
|
||||
from app import db, log
|
||||
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
|
||||
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
|
||||
|
||||
REFCOMP_EQUIVALENCE_FILENAME = "ressources/referentiels/equivalences.yaml"
|
||||
|
||||
|
||||
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
|
||||
|
@ -104,6 +107,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||
def __repr__(self):
|
||||
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
|
||||
|
||||
def get_title(self) -> str:
|
||||
"Titre affichable"
|
||||
# utilise type_titre (B.U.T.), spécialité, version
|
||||
return f"{self.type_titre} {self.specialite} {self.get_version()}"
|
||||
|
||||
def get_version(self) -> str:
|
||||
"La version, normalement sous forme de date iso yyy-mm-dd"
|
||||
if not self.version_orebut:
|
||||
|
@ -124,9 +132,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||
"type_departement": self.type_departement,
|
||||
"type_titre": self.type_titre,
|
||||
"version_orebut": self.version_orebut,
|
||||
"scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z"
|
||||
if self.scodoc_date_loaded
|
||||
else "",
|
||||
"scodoc_date_loaded": (
|
||||
self.scodoc_date_loaded.isoformat() + "Z"
|
||||
if self.scodoc_date_loaded
|
||||
else ""
|
||||
),
|
||||
"scodoc_orig_filename": self.scodoc_orig_filename,
|
||||
"competences": {
|
||||
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
|
||||
|
@ -234,6 +244,92 @@ class ApcReferentielCompetences(db.Model, XMLModel):
|
|||
|
||||
return parcours_info
|
||||
|
||||
def equivalents(self) -> set["ApcReferentielCompetences"]:
|
||||
"""Ensemble des référentiels du même département
|
||||
qui peuvent être considérés comme "équivalents", au sens
|
||||
une formation de ce référentiel pourrait changer vers un équivalent,
|
||||
en ignorant les apprentissages critiques.
|
||||
Pour cela, il faut avoir le même type, etc et les mêmes compétences,
|
||||
niveaux et parcours (voir map_to_other_referentiel).
|
||||
"""
|
||||
candidats = ApcReferentielCompetences.query.filter_by(
|
||||
dept_id=self.dept_id
|
||||
).filter(ApcReferentielCompetences.id != self.id)
|
||||
return {
|
||||
referentiel
|
||||
for referentiel in candidats
|
||||
if not isinstance(self.map_to_other_referentiel(referentiel), str)
|
||||
}
|
||||
|
||||
def map_to_other_referentiel(
|
||||
self, other: "ApcReferentielCompetences"
|
||||
) -> str | tuple[dict[int, int], dict[int, int], dict[int, int]]:
|
||||
"""Build mapping between this referentiel and ref2.
|
||||
If successful, returns 3 dicts mapping self ids to other ids.
|
||||
Else return a string, error message.
|
||||
"""
|
||||
if self.type_structure != other.type_structure:
|
||||
return "type_structure mismatch"
|
||||
if self.type_departement != other.type_departement:
|
||||
return "type_departement mismatch"
|
||||
# Table d'équivalences entre refs:
|
||||
equiv = self._load_config_equivalences()
|
||||
# mêmes parcours ?
|
||||
eq_parcours = equiv.get("parcours", {})
|
||||
parcours_by_code_1 = {eq_parcours.get(p.code, p.code): p for p in self.parcours}
|
||||
parcours_by_code_2 = {
|
||||
eq_parcours.get(p.code, p.code): p for p in other.parcours
|
||||
}
|
||||
if parcours_by_code_1.keys() != parcours_by_code_2.keys():
|
||||
return "parcours mismatch"
|
||||
parcours_map = {
|
||||
parcours_by_code_1[eq_parcours.get(code, code)]
|
||||
.id: parcours_by_code_2[eq_parcours.get(code, code)]
|
||||
.id
|
||||
for code in parcours_by_code_1
|
||||
}
|
||||
# mêmes compétences ?
|
||||
competence_by_code_1 = {c.titre: c for c in self.competences}
|
||||
competence_by_code_2 = {c.titre: c for c in other.competences}
|
||||
if competence_by_code_1.keys() != competence_by_code_2.keys():
|
||||
return "competences mismatch"
|
||||
competences_map = {
|
||||
competence_by_code_1[titre].id: competence_by_code_2[titre].id
|
||||
for titre in competence_by_code_1
|
||||
}
|
||||
# mêmes niveaux (dans chaque compétence) ?
|
||||
niveaux_map = {}
|
||||
for titre in competence_by_code_1:
|
||||
c1 = competence_by_code_1[titre]
|
||||
c2 = competence_by_code_2[titre]
|
||||
niveau_by_attr_1 = {(n.annee, n.ordre, n.libelle): n for n in c1.niveaux}
|
||||
niveau_by_attr_2 = {(n.annee, n.ordre, n.libelle): n for n in c2.niveaux}
|
||||
if niveau_by_attr_1.keys() != niveau_by_attr_2.keys():
|
||||
return f"niveaux mismatch in comp. '{titre}'"
|
||||
niveaux_map.update(
|
||||
{
|
||||
niveau_by_attr_1[a].id: niveau_by_attr_2[a].id
|
||||
for a in niveau_by_attr_1
|
||||
}
|
||||
)
|
||||
return parcours_map, competences_map, niveaux_map
|
||||
|
||||
def _load_config_equivalences(self) -> dict:
|
||||
"""Load config file ressources/referentiels/equivalences.yaml
|
||||
used to define equivalences between distinct referentiels
|
||||
"""
|
||||
try:
|
||||
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:
|
||||
doc = yaml.safe_load(f.read())
|
||||
except FileNotFoundError:
|
||||
log(f"_load_config_equivalences: {REFCOMP_EQUIVALENCE_FILENAME} not found")
|
||||
return {}
|
||||
except yaml.parser.ParserError as exc:
|
||||
raise ScoValueError(
|
||||
f"erreur dans le fichier {REFCOMP_EQUIVALENCE_FILENAME}"
|
||||
) from exc
|
||||
return doc.get(self.specialite, {})
|
||||
|
||||
|
||||
class ApcCompetence(db.Model, XMLModel):
|
||||
"Compétence"
|
||||
|
@ -374,9 +470,11 @@ class ApcNiveau(db.Model, XMLModel):
|
|||
"libelle": self.libelle,
|
||||
"annee": self.annee,
|
||||
"ordre": self.ordre,
|
||||
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
|
||||
if with_app_critiques
|
||||
else {},
|
||||
"app_critiques": (
|
||||
{x.code: x.to_dict() for x in self.app_critiques}
|
||||
if with_app_critiques
|
||||
else {}
|
||||
),
|
||||
}
|
||||
|
||||
def to_dict_bul(self):
|
||||
|
@ -464,9 +562,9 @@ class ApcNiveau(db.Model, XMLModel):
|
|||
return []
|
||||
|
||||
if competence is None:
|
||||
parcour_niveaux: list[
|
||||
ApcParcoursNiveauCompetence
|
||||
] = annee_parcour.niveaux_competences
|
||||
parcour_niveaux: list[ApcParcoursNiveauCompetence] = (
|
||||
annee_parcour.niveaux_competences
|
||||
)
|
||||
niveaux: list[ApcNiveau] = [
|
||||
pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
|
||||
for pn in parcour_niveaux
|
||||
|
|
|
@ -9,6 +9,8 @@ from app.models.but_refcomp import ApcNiveau
|
|||
from app.models.etudiants import Identite
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.ues import UniteEns
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class ApcValidationRCUE(db.Model):
|
||||
|
@ -62,24 +64,25 @@ class ApcValidationRCUE(db.Model):
|
|||
|
||||
def __str__(self):
|
||||
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
|
||||
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
|
||||
self.code} enregistrée le {self.date.strftime(scu.DATE_FMT)}"""
|
||||
|
||||
def html(self) -> str:
|
||||
"description en HTML"
|
||||
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
|
||||
<b>{self.code}</b>
|
||||
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}
|
||||
à {self.date.strftime("%Hh%M")}</em>"""
|
||||
<em>enregistrée le {self.date.strftime(scu.DATEATIME_FMT)}</em>"""
|
||||
|
||||
def annee(self) -> str:
|
||||
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
|
||||
niveau = self.niveau()
|
||||
return niveau.annee if niveau else None
|
||||
|
||||
def niveau(self) -> ApcNiveau:
|
||||
def niveau(self) -> ApcNiveau | None:
|
||||
"""Le niveau de compétence associé à cet RCUE."""
|
||||
# Par convention, il est donné par la seconde UE
|
||||
return self.ue2.niveau_competence
|
||||
# à défaut (si l'UE a été désacciée entre temps), la première
|
||||
# et à défaut, renvoie None
|
||||
return self.ue2.niveau_competence or self.ue1.niveau_competence
|
||||
|
||||
def to_dict(self):
|
||||
"as a dict"
|
||||
|
@ -161,7 +164,7 @@ class ApcValidationAnnee(db.Model):
|
|||
def html(self) -> str:
|
||||
"Affichage html"
|
||||
date_str = (
|
||||
f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
|
||||
f"""le {self.date.strftime(scu.DATEATIME_FMT)}"""
|
||||
if self.date
|
||||
else "(sans date)"
|
||||
)
|
||||
|
@ -218,15 +221,18 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
|
|||
decisions["descr_decisions_rcue"] = ""
|
||||
decisions["descr_decisions_niveaux"] = ""
|
||||
# --- Année: prend la validation pour l'année scolaire et l'ordre de ce semestre
|
||||
annee_but = (formsemestre.semestre_id + 1) // 2
|
||||
validation = ApcValidationAnnee.query.filter_by(
|
||||
etudid=etud.id,
|
||||
annee_scolaire=formsemestre.annee_scolaire(),
|
||||
ordre=annee_but,
|
||||
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
|
||||
).first()
|
||||
if validation:
|
||||
decisions["decision_annee"] = validation.to_dict_bul()
|
||||
if sco_preferences.get_preference("bul_but_code_annuel", formsemestre.id):
|
||||
annee_but = (formsemestre.semestre_id + 1) // 2
|
||||
validation = ApcValidationAnnee.query.filter_by(
|
||||
etudid=etud.id,
|
||||
annee_scolaire=formsemestre.annee_scolaire(),
|
||||
ordre=annee_but,
|
||||
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
|
||||
).first()
|
||||
if validation:
|
||||
decisions["decision_annee"] = validation.to_dict_bul()
|
||||
else:
|
||||
decisions["decision_annee"] = None
|
||||
else:
|
||||
decisions["decision_annee"] = None
|
||||
return decisions
|
||||
|
|
|
@ -92,6 +92,7 @@ class ScoDocSiteConfig(db.Model):
|
|||
"INSTITUTION_CITY": str,
|
||||
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
|
||||
"enable_entreprises": bool,
|
||||
"disable_passerelle": bool, # remplace pref. bul_display_publication
|
||||
"month_debut_annee_scolaire": int,
|
||||
"month_debut_periode2": int,
|
||||
"disable_bul_pdf": bool,
|
||||
|
@ -244,6 +245,12 @@ class ScoDocSiteConfig(db.Model):
|
|||
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
|
||||
return cfg is not None and cfg.value
|
||||
|
||||
@classmethod
|
||||
def is_passerelle_disabled(cls):
|
||||
"""True si on doit cacher les fonctions passerelle ("oeil")."""
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name="disable_passerelle").first()
|
||||
return cfg is not None and cfg.value
|
||||
|
||||
@classmethod
|
||||
def is_user_require_email_institutionnel_enabled(cls) -> bool:
|
||||
"""True si impose saisie email_institutionnel"""
|
||||
|
@ -263,6 +270,11 @@ class ScoDocSiteConfig(db.Model):
|
|||
"""Active (ou déactive) le module entreprises. True si changement."""
|
||||
return cls.set("enable_entreprises", "on" if enabled else "")
|
||||
|
||||
@classmethod
|
||||
def disable_passerelle(cls, disabled: bool = True) -> bool:
|
||||
"""Désactive (ou active) les fonctions liées à la présence d'une passerelle. True si changement."""
|
||||
return cls.set("disable_passerelle", "on" if disabled else "")
|
||||
|
||||
@classmethod
|
||||
def disable_bul_pdf(cls, enabled=True) -> bool:
|
||||
"""Interdit (ou autorise) les exports PDF. True si changement."""
|
||||
|
@ -297,7 +309,7 @@ class ScoDocSiteConfig(db.Model):
|
|||
|
||||
@classmethod
|
||||
def _get_int_field(cls, name: str, default=None) -> int:
|
||||
"""Valeur d'un champs integer"""
|
||||
"""Valeur d'un champ integer"""
|
||||
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
|
||||
if (cfg is None) or cfg.value is None:
|
||||
return default
|
||||
|
@ -311,7 +323,7 @@ class ScoDocSiteConfig(db.Model):
|
|||
default=None,
|
||||
range_values: tuple = (),
|
||||
) -> bool:
|
||||
"""Set champs integer. True si changement."""
|
||||
"""Set champ integer. True si changement."""
|
||||
if value != cls._get_int_field(name, default=default):
|
||||
if not isinstance(value, int) or (
|
||||
range_values and (value < range_values[0]) or (value > range_values[1])
|
||||
|
|
|
@ -19,7 +19,7 @@ from app.models.departements import Departement
|
|||
from app.models.scolar_event import ScolarEvent
|
||||
from app.scodoc import notesdb as ndb
|
||||
from app.scodoc.sco_bac import Baccalaureat
|
||||
from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
|
||||
from app.scodoc.sco_exceptions import ScoGenError, ScoInvalidParamError, ScoValueError
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
|
||||
|
@ -101,7 +101,12 @@ class Identite(models.ScoDocModel):
|
|||
adresses = db.relationship(
|
||||
"Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic"
|
||||
)
|
||||
|
||||
annotations = db.relationship(
|
||||
"EtudAnnotation",
|
||||
backref="etudiant",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic",
|
||||
)
|
||||
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
|
||||
#
|
||||
dispense_ues = db.relationship(
|
||||
|
@ -120,7 +125,7 @@ class Identite(models.ScoDocModel):
|
|||
)
|
||||
|
||||
# Champs "protégés" par ViewEtudData (RGPD)
|
||||
protected_attrs = {"boursier"}
|
||||
protected_attrs = {"boursier", "nationalite"}
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
|
@ -233,6 +238,15 @@ class Identite(models.ScoDocModel):
|
|||
log(f"Identite.create {etud}")
|
||||
return etud
|
||||
|
||||
def from_dict(self, args, **kwargs) -> bool:
|
||||
"""Check arguments, then modify.
|
||||
Add to session but don't commit.
|
||||
True if modification.
|
||||
"""
|
||||
check_etud_duplicate_code(args, "code_nip")
|
||||
check_etud_duplicate_code(args, "code_ine")
|
||||
return super().from_dict(args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
|
||||
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded."""
|
||||
|
@ -283,7 +297,7 @@ class Identite(models.ScoDocModel):
|
|||
else:
|
||||
return self.nom
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def nomprenom(self, reverse=False) -> str:
|
||||
"""Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
|
||||
Si reverse, "Dupont Pierre", sans civilité.
|
||||
|
@ -320,16 +334,14 @@ class Identite(models.ScoDocModel):
|
|||
return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}."
|
||||
|
||||
@cached_property
|
||||
def sort_key(self) -> tuple:
|
||||
def sort_key(self) -> str:
|
||||
"clé pour tris par ordre alphabétique"
|
||||
# Note: scodoc7 utilisait sco_etud.etud_sort_key, à mettre à jour
|
||||
# si on modifie cette méthode.
|
||||
return (
|
||||
scu.sanitize_string(
|
||||
self.nom_usuel or self.nom or "", remove_spaces=False
|
||||
).lower(),
|
||||
scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(),
|
||||
)
|
||||
return scu.sanitize_string(
|
||||
(self.nom_usuel or self.nom or "") + ";" + (self.prenom or ""),
|
||||
remove_spaces=False,
|
||||
).lower()
|
||||
|
||||
def get_first_email(self, field="email") -> str:
|
||||
"Le mail associé à la première adresse de l'étudiant, ou None"
|
||||
|
@ -436,10 +448,11 @@ class Identite(models.ScoDocModel):
|
|||
"prenom_etat_civil": self.prenom_etat_civil,
|
||||
}
|
||||
|
||||
def to_dict_scodoc7(self, restrict=False) -> dict:
|
||||
def to_dict_scodoc7(self, restrict=False, with_inscriptions=False) -> dict:
|
||||
"""Représentation dictionnaire,
|
||||
compatible ScoDoc7 mais sans infos admission.
|
||||
Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
|
||||
Si with_inscriptions, inclut les champs "inscription"
|
||||
"""
|
||||
e_dict = self.__dict__.copy() # dict(self.__dict__)
|
||||
e_dict.pop("_sa_instance_state", None)
|
||||
|
@ -451,6 +464,8 @@ class Identite(models.ScoDocModel):
|
|||
adresse = self.adresses.first()
|
||||
if adresse:
|
||||
e_dict.update(adresse.to_dict(restrict=restrict))
|
||||
if with_inscriptions:
|
||||
e_dict.update(self.inscription_descr())
|
||||
return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty
|
||||
|
||||
def to_dict_bul(self, include_urls=True):
|
||||
|
@ -465,9 +480,11 @@ class Identite(models.ScoDocModel):
|
|||
"civilite": self.civilite,
|
||||
"code_ine": self.code_ine or "",
|
||||
"code_nip": self.code_nip or "",
|
||||
"date_naissance": self.date_naissance.strftime("%d/%m/%Y")
|
||||
if self.date_naissance
|
||||
else "",
|
||||
"date_naissance": (
|
||||
self.date_naissance.strftime(scu.DATE_FMT)
|
||||
if self.date_naissance
|
||||
else ""
|
||||
),
|
||||
"dept_acronym": self.departement.acronym,
|
||||
"dept_id": self.dept_id,
|
||||
"dept_naissance": self.dept_naissance or "",
|
||||
|
@ -494,7 +511,7 @@ class Identite(models.ScoDocModel):
|
|||
d["id"] = self.id # a été écrasé par l'id de adresse
|
||||
return d
|
||||
|
||||
def to_dict_api(self, restrict=False) -> dict:
|
||||
def to_dict_api(self, restrict=False, with_annotations=False) -> dict:
|
||||
"""Représentation dictionnaire pour export API, avec adresses et admission.
|
||||
Si restrict, supprime les infos "personnelles" (boursier)
|
||||
"""
|
||||
|
@ -506,6 +523,17 @@ class Identite(models.ScoDocModel):
|
|||
e["dept_acronym"] = self.departement.acronym
|
||||
e.pop("departement", None)
|
||||
e["sort_key"] = self.sort_key
|
||||
if with_annotations:
|
||||
e["annotations"] = (
|
||||
[
|
||||
annot.to_dict()
|
||||
for annot in EtudAnnotation.query.filter_by(
|
||||
etudid=self.id
|
||||
).order_by(desc(EtudAnnotation.date))
|
||||
]
|
||||
if not restrict
|
||||
else []
|
||||
)
|
||||
if restrict:
|
||||
# Met à None les attributs protégés:
|
||||
for attr in self.protected_attrs:
|
||||
|
@ -514,8 +542,6 @@ class Identite(models.ScoDocModel):
|
|||
|
||||
def inscriptions(self) -> list["FormSemestreInscription"]:
|
||||
"Liste des inscriptions à des formsemestres, triée, la plus récente en tête"
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
|
||||
return (
|
||||
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
|
||||
.filter(
|
||||
|
@ -525,7 +551,7 @@ class Identite(models.ScoDocModel):
|
|||
.all()
|
||||
)
|
||||
|
||||
def inscription_courante(self):
|
||||
def inscription_courante(self) -> "FormSemestreInscription | None":
|
||||
"""La première inscription à un formsemestre _actuellement_ en cours.
|
||||
None s'il n'y en a pas (ou plus, ou pas encore).
|
||||
"""
|
||||
|
@ -541,8 +567,6 @@ class Identite(models.ScoDocModel):
|
|||
(il est rare qu'il y en ai plus d'une, mais c'est possible).
|
||||
Triées par date de début de semestre décroissante (le plus récent en premier).
|
||||
"""
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
|
||||
return (
|
||||
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
|
||||
.filter(
|
||||
|
@ -565,7 +589,9 @@ class Identite(models.ScoDocModel):
|
|||
return r[0] if r else None
|
||||
|
||||
def inscription_descr(self) -> dict:
|
||||
"""Description de l'état d'inscription"""
|
||||
"""Description de l'état d'inscription
|
||||
avec champs compatibles templates ScoDoc7
|
||||
"""
|
||||
inscription_courante = self.inscription_courante()
|
||||
if inscription_courante:
|
||||
titre_sem = inscription_courante.formsemestre.titre_mois()
|
||||
|
@ -576,7 +602,7 @@ class Identite(models.ScoDocModel):
|
|||
else:
|
||||
inscr_txt = "Inscrit en"
|
||||
|
||||
return {
|
||||
result = {
|
||||
"etat_in_cursem": inscription_courante.etat,
|
||||
"inscription_courante": inscription_courante,
|
||||
"inscription": titre_sem,
|
||||
|
@ -599,15 +625,20 @@ class Identite(models.ScoDocModel):
|
|||
inscription = "ancien"
|
||||
situation = "ancien élève"
|
||||
else:
|
||||
inscription = ("non inscrit",)
|
||||
inscription = "non inscrit"
|
||||
situation = inscription
|
||||
return {
|
||||
result = {
|
||||
"etat_in_cursem": "?",
|
||||
"inscription_courante": None,
|
||||
"inscription": inscription,
|
||||
"inscription_str": inscription,
|
||||
"situation": situation,
|
||||
}
|
||||
# aliases pour compat templates ScoDoc7
|
||||
result["etatincursem"] = result["etat_in_cursem"]
|
||||
result["inscriptionstr"] = result["inscription_str"]
|
||||
|
||||
return result
|
||||
|
||||
def inscription_etat(self, formsemestre_id: int) -> str:
|
||||
"""État de l'inscription de cet étudiant au semestre:
|
||||
|
@ -704,7 +735,7 @@ class Identite(models.ScoDocModel):
|
|||
"""
|
||||
if with_paragraph:
|
||||
return f"""{self.etat_civil}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le {
|
||||
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
|
||||
self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else ""}{
|
||||
line_sep}à {self.lieu_naissance or ""}"""
|
||||
return self.etat_civil
|
||||
|
||||
|
@ -728,6 +759,58 @@ class Identite(models.ScoDocModel):
|
|||
)
|
||||
|
||||
|
||||
def check_etud_duplicate_code(args, code_name, edit=True):
|
||||
"""Vérifie que le code n'est pas dupliqué.
|
||||
Raises ScoGenError si problème.
|
||||
"""
|
||||
etudid = args.get("etudid", None)
|
||||
if not args.get(code_name, None):
|
||||
return
|
||||
etuds = Identite.query.filter_by(
|
||||
**{code_name: str(args[code_name]), "dept_id": g.scodoc_dept_id}
|
||||
).all()
|
||||
duplicate = False
|
||||
if edit:
|
||||
duplicate = (len(etuds) > 1) or ((len(etuds) == 1) and etuds[0].id != etudid)
|
||||
else:
|
||||
duplicate = len(etuds) > 0
|
||||
if duplicate:
|
||||
listh = [] # liste des doubles
|
||||
for etud in etuds:
|
||||
listh.append(f"Autre étudiant: {etud.html_link_fiche()}")
|
||||
if etudid:
|
||||
submit_label = "retour à la fiche étudiant"
|
||||
dest_endpoint = "scolar.fiche_etud"
|
||||
parameters = {"etudid": etudid}
|
||||
else:
|
||||
if "tf_submitted" in args:
|
||||
del args["tf_submitted"]
|
||||
submit_label = "Continuer"
|
||||
dest_endpoint = "scolar.etudident_create_form"
|
||||
parameters = args
|
||||
else:
|
||||
submit_label = "Annuler"
|
||||
dest_endpoint = "notes.index_html"
|
||||
parameters = {}
|
||||
|
||||
err_page = f"""<h3><h3>Code étudiant ({code_name}) dupliqué !</h3>
|
||||
<p class="help">Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir
|
||||
ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur.
|
||||
</p>
|
||||
<ul><li>
|
||||
{ '</li><li>'.join(listh) }
|
||||
</li></ul>
|
||||
<p>
|
||||
<a href="{ url_for(dest_endpoint, scodoc_dept=g.scodoc_dept, **parameters) }
|
||||
">{submit_label}</a>
|
||||
</p>
|
||||
"""
|
||||
|
||||
log(f"*** error: code {code_name} duplique: {args[code_name]}")
|
||||
|
||||
raise ScoGenError(err_page)
|
||||
|
||||
|
||||
def make_etud_args(
|
||||
etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True
|
||||
) -> dict:
|
||||
|
@ -1001,11 +1084,16 @@ class EtudAnnotation(db.Model):
|
|||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
|
||||
etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7))
|
||||
etudid = db.Column(db.Integer, db.ForeignKey(Identite.id))
|
||||
author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
|
||||
comment = db.Column(db.Text)
|
||||
|
||||
def to_dict(self):
|
||||
"""Représentation dictionnaire."""
|
||||
e = dict(self.__dict__)
|
||||
e.pop("_sa_instance_state", None)
|
||||
return e
|
||||
|
||||
from app.models.formsemestre import FormSemestre
|
||||
from app.models.modules import Module
|
||||
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
|
||||
|
|
|
@ -5,11 +5,12 @@
|
|||
import datetime
|
||||
from operator import attrgetter
|
||||
|
||||
from flask import g, url_for
|
||||
from flask import abort, g, url_for
|
||||
from flask_login import current_user
|
||||
import sqlalchemy as sa
|
||||
|
||||
from app import db, log
|
||||
from app import models
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.events import ScolarNews
|
||||
from app.models.notes import NotesNotes
|
||||
|
@ -23,10 +24,8 @@ MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
|
|||
NOON = datetime.time(12, 00)
|
||||
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
|
||||
|
||||
VALID_EVALUATION_TYPES = {0, 1, 2}
|
||||
|
||||
|
||||
class Evaluation(db.Model):
|
||||
class Evaluation(models.ScoDocModel):
|
||||
"""Evaluation (contrôle, examen, ...)"""
|
||||
|
||||
__tablename__ = "notes_evaluation"
|
||||
|
@ -38,9 +37,9 @@ class Evaluation(db.Model):
|
|||
)
|
||||
date_debut = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||
date_fin = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||
description = db.Column(db.Text)
|
||||
note_max = db.Column(db.Float)
|
||||
coefficient = db.Column(db.Float)
|
||||
description = db.Column(db.Text, nullable=False)
|
||||
note_max = db.Column(db.Float, nullable=False)
|
||||
coefficient = db.Column(db.Float, nullable=False)
|
||||
visibulletin = db.Column(
|
||||
db.Boolean, nullable=False, default=True, server_default="true"
|
||||
)
|
||||
|
@ -48,15 +47,39 @@ class Evaluation(db.Model):
|
|||
publish_incomplete = db.Column(
|
||||
db.Boolean, nullable=False, default=False, server_default="false"
|
||||
)
|
||||
# type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session"
|
||||
"prise en compte immédiate"
|
||||
evaluation_type = db.Column(
|
||||
db.Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
"type d'evaluation: 0 normale, 1 rattrapage, 2 2eme session, 3 bonus"
|
||||
blocked_until = db.Column(db.DateTime(timezone=True), nullable=True)
|
||||
"date de prise en compte"
|
||||
BLOCKED_FOREVER = datetime.datetime(2666, 12, 31, tzinfo=scu.TIME_ZONE)
|
||||
# ordre de presentation (par défaut, le plus petit numero
|
||||
# est la plus ancienne eval):
|
||||
numero = db.Column(db.Integer, nullable=False, default=0)
|
||||
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
|
||||
|
||||
EVALUATION_NORMALE = 0 # valeurs stockées en base, ne pas changer !
|
||||
EVALUATION_RATTRAPAGE = 1
|
||||
EVALUATION_SESSION2 = 2
|
||||
EVALUATION_BONUS = 3
|
||||
VALID_EVALUATION_TYPES = {
|
||||
EVALUATION_NORMALE,
|
||||
EVALUATION_RATTRAPAGE,
|
||||
EVALUATION_SESSION2,
|
||||
EVALUATION_BONUS,
|
||||
}
|
||||
|
||||
def type_abbrev(self) -> str:
|
||||
"Le nom abrégé du type de cette éval."
|
||||
return {
|
||||
self.EVALUATION_NORMALE: "std",
|
||||
self.EVALUATION_RATTRAPAGE: "rattrapage",
|
||||
self.EVALUATION_SESSION2: "session 2",
|
||||
self.EVALUATION_BONUS: "bonus",
|
||||
}.get(self.evaluation_type, "?")
|
||||
|
||||
def __repr__(self):
|
||||
return f"""<Evaluation {self.id} {
|
||||
self.date_debut.isoformat() if self.date_debut else ''} "{
|
||||
|
@ -70,15 +93,17 @@ class Evaluation(db.Model):
|
|||
date_fin: datetime.datetime = None,
|
||||
description=None,
|
||||
note_max=None,
|
||||
blocked_until=None,
|
||||
coefficient=None,
|
||||
visibulletin=None,
|
||||
publish_incomplete=None,
|
||||
evaluation_type=None,
|
||||
numero=None,
|
||||
**kw, # ceci pour absorber les éventuel arguments excedentaires
|
||||
):
|
||||
) -> "Evaluation":
|
||||
"""Create an evaluation. Check permission and all arguments.
|
||||
Ne crée pas les poids vers les UEs.
|
||||
Add to session, do not commit.
|
||||
"""
|
||||
if not moduleimpl.can_edit_evaluation(current_user):
|
||||
raise AccessDenied(
|
||||
|
@ -87,13 +112,15 @@ class Evaluation(db.Model):
|
|||
args = locals()
|
||||
del args["cls"]
|
||||
del args["kw"]
|
||||
check_convert_evaluation_args(moduleimpl, args)
|
||||
check_and_convert_evaluation_args(args, moduleimpl)
|
||||
# Check numeros
|
||||
Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True)
|
||||
if not "numero" in args or args["numero"] is None:
|
||||
args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"])
|
||||
#
|
||||
evaluation = Evaluation(**args)
|
||||
db.session.add(evaluation)
|
||||
db.session.flush()
|
||||
sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id)
|
||||
url = url_for(
|
||||
"notes.moduleimpl_status",
|
||||
|
@ -189,13 +216,19 @@ class Evaluation(db.Model):
|
|||
e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
|
||||
|
||||
# Deprecated
|
||||
e_dict["jour"] = self.date_debut.strftime("%d/%m/%Y") if self.date_debut else ""
|
||||
e_dict["jour"] = (
|
||||
self.date_debut.strftime(scu.DATE_FMT) if self.date_debut else ""
|
||||
)
|
||||
|
||||
return evaluation_enrich_dict(self, e_dict)
|
||||
|
||||
def to_dict_api(self) -> dict:
|
||||
"Représentation dict pour API JSON"
|
||||
return {
|
||||
"blocked": self.is_blocked(),
|
||||
"blocked_until": (
|
||||
self.blocked_until.isoformat() if self.blocked_until else ""
|
||||
),
|
||||
"coefficient": self.coefficient,
|
||||
"date_debut": self.date_debut.isoformat() if self.date_debut else "",
|
||||
"date_fin": self.date_fin.isoformat() if self.date_fin else "",
|
||||
|
@ -210,9 +243,9 @@ class Evaluation(db.Model):
|
|||
"visibulletin": self.visibulletin,
|
||||
# Deprecated (supprimer avant #sco9.7)
|
||||
"date": self.date_debut.date().isoformat() if self.date_debut else "",
|
||||
"heure_debut": self.date_debut.time().isoformat()
|
||||
if self.date_debut
|
||||
else "",
|
||||
"heure_debut": (
|
||||
self.date_debut.time().isoformat() if self.date_debut else ""
|
||||
),
|
||||
"heure_fin": self.date_fin.time().isoformat() if self.date_fin else "",
|
||||
}
|
||||
|
||||
|
@ -232,14 +265,24 @@ class Evaluation(db.Model):
|
|||
|
||||
return e_dict
|
||||
|
||||
def from_dict(self, data):
|
||||
"""Set evaluation attributes from given dict values."""
|
||||
check_convert_evaluation_args(self.moduleimpl, data)
|
||||
if data.get("numero") is None:
|
||||
data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1
|
||||
for k in self.__dict__:
|
||||
if k != "_sa_instance_state" and k != "id" and k in data:
|
||||
setattr(self, k, data[k])
|
||||
@classmethod
|
||||
def get_evaluation(
|
||||
cls, evaluation_id: int | str, dept_id: int = None
|
||||
) -> "Evaluation":
|
||||
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant."""
|
||||
from app.models import FormSemestre, ModuleImpl
|
||||
|
||||
if not isinstance(evaluation_id, int):
|
||||
try:
|
||||
evaluation_id = int(evaluation_id)
|
||||
except (TypeError, ValueError):
|
||||
abort(404, "evaluation_id invalide")
|
||||
if g.scodoc_dept:
|
||||
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
||||
query = cls.query.filter_by(id=evaluation_id)
|
||||
if dept_id is not None:
|
||||
query = query.join(ModuleImpl).join(FormSemestre).filter_by(dept_id=dept_id)
|
||||
return query.first_or_404()
|
||||
|
||||
@classmethod
|
||||
def get_max_numero(cls, moduleimpl_id: int) -> int:
|
||||
|
@ -265,7 +308,9 @@ class Evaluation(db.Model):
|
|||
evaluations = moduleimpl.evaluations.order_by(
|
||||
Evaluation.date_debut, Evaluation.numero
|
||||
).all()
|
||||
all_numbered = all(e.numero is not None for e in evaluations)
|
||||
numeros_distincts = {e.numero for e in evaluations if e.numero is not None}
|
||||
# pas de None, pas de dupliqués
|
||||
all_numbered = len(numeros_distincts) == len(evaluations)
|
||||
if all_numbered and only_if_unumbered:
|
||||
return # all ok
|
||||
|
||||
|
@ -281,10 +326,10 @@ class Evaluation(db.Model):
|
|||
def descr_heure(self) -> str:
|
||||
"Description de la plage horaire pour affichages ('de 13h00 à 14h00')"
|
||||
if self.date_debut and (not self.date_fin or self.date_fin == self.date_debut):
|
||||
return f"""à {self.date_debut.strftime("%Hh%M")}"""
|
||||
return f"""à {self.date_debut.strftime(scu.TIME_FMT)}"""
|
||||
elif self.date_debut and self.date_fin:
|
||||
return f"""de {self.date_debut.strftime("%Hh%M")
|
||||
} à {self.date_fin.strftime("%Hh%M")}"""
|
||||
return f"""de {self.date_debut.strftime(scu.TIME_FMT)
|
||||
} à {self.date_fin.strftime(scu.TIME_FMT)}"""
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
@ -311,7 +356,7 @@ class Evaluation(db.Model):
|
|||
|
||||
def _h(dt: datetime.datetime) -> str:
|
||||
if dt.minute:
|
||||
return dt.strftime("%Hh%M")
|
||||
return dt.strftime(scu.TIME_FMT)
|
||||
return f"{dt.hour}h"
|
||||
|
||||
if self.date_fin is None:
|
||||
|
@ -337,19 +382,6 @@ class Evaluation(db.Model):
|
|||
Chaine vide si non renseignée."""
|
||||
return self.date_fin.time().isoformat("minutes") if self.date_fin else ""
|
||||
|
||||
def clone(self, not_copying=()):
|
||||
"""Clone, not copying the given attrs
|
||||
Attention: la copie n'a pas d'id avant le prochain commit
|
||||
"""
|
||||
d = dict(self.__dict__)
|
||||
d.pop("id") # get rid of id
|
||||
d.pop("_sa_instance_state") # get rid of SQLAlchemy special attr
|
||||
for k in not_copying:
|
||||
d.pop(k)
|
||||
copy = self.__class__(**d)
|
||||
db.session.add(copy)
|
||||
return copy
|
||||
|
||||
def is_matin(self) -> bool:
|
||||
"Evaluation commençant le matin (faux si pas de date)"
|
||||
if not self.date_debut:
|
||||
|
@ -362,6 +394,14 @@ class Evaluation(db.Model):
|
|||
return False
|
||||
return self.date_debut.time() >= NOON
|
||||
|
||||
def is_blocked(self, now=None) -> bool:
|
||||
"True si prise en compte bloquée"
|
||||
if self.blocked_until is None:
|
||||
return False
|
||||
if now is None:
|
||||
now = datetime.datetime.now(scu.TIME_ZONE)
|
||||
return self.blocked_until > now
|
||||
|
||||
def set_default_poids(self) -> bool:
|
||||
"""Initialize les poids vers les UE à leurs valeurs par défaut
|
||||
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
|
||||
|
@ -386,12 +426,13 @@ class Evaluation(db.Model):
|
|||
return modified
|
||||
|
||||
def set_ue_poids(self, ue, poids: float) -> None:
|
||||
"""Set poids évaluation vers cette UE"""
|
||||
"""Set poids évaluation vers cette UE. Commit."""
|
||||
self.update_ue_poids_dict({ue.id: poids})
|
||||
|
||||
def set_ue_poids_dict(self, ue_poids_dict: dict) -> None:
|
||||
"""set poids vers les UE (remplace existants)
|
||||
ue_poids_dict = { ue_id : poids }
|
||||
Commit session.
|
||||
"""
|
||||
from app.models.ues import UniteEns
|
||||
|
||||
|
@ -401,9 +442,12 @@ class Evaluation(db.Model):
|
|||
if ue is None:
|
||||
raise ScoValueError("poids vers une UE inexistante")
|
||||
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
|
||||
L.append(ue_poids)
|
||||
db.session.add(ue_poids)
|
||||
L.append(ue_poids)
|
||||
|
||||
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
|
||||
|
||||
db.session.commit()
|
||||
self.moduleimpl.invalidate_evaluations_poids() # inval cache
|
||||
|
||||
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None:
|
||||
|
@ -450,6 +494,29 @@ class Evaluation(db.Model):
|
|||
"""
|
||||
return NotesNotes.query.filter_by(etudid=etud.id, evaluation_id=self.id).first()
|
||||
|
||||
@classmethod
|
||||
def get_evaluations_blocked_for_etud(
|
||||
cls, formsemestre, etud: Identite
|
||||
) -> list["Evaluation"]:
|
||||
"""Liste des évaluations de ce semestre avec note pour cet étudiant et date blocage
|
||||
et date blocage < FOREVER.
|
||||
Si non vide, une note apparaitra dans le futur pour cet étudiant: il faut
|
||||
donc interdire la saisie du jury.
|
||||
"""
|
||||
now = datetime.datetime.now(scu.TIME_ZONE)
|
||||
return (
|
||||
Evaluation.query.filter(
|
||||
Evaluation.blocked_until != None, # pylint: disable=C0121
|
||||
Evaluation.blocked_until >= now,
|
||||
)
|
||||
.join(ModuleImpl)
|
||||
.filter_by(formsemestre_id=formsemestre.id)
|
||||
.join(ModuleImplInscription)
|
||||
.filter_by(etudid=etud.id)
|
||||
.join(NotesNotes)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
class EvaluationUEPoids(db.Model):
|
||||
"""Poids des évaluations (BUT)
|
||||
|
@ -487,8 +554,8 @@ class EvaluationUEPoids(db.Model):
|
|||
def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
|
||||
"""add or convert some fields in an evaluation dict"""
|
||||
# For ScoDoc7 compat
|
||||
e_dict["heure_debut"] = e.date_debut.strftime("%Hh%M") if e.date_debut else ""
|
||||
e_dict["heure_fin"] = e.date_fin.strftime("%Hh%M") if e.date_fin else ""
|
||||
e_dict["heure_debut"] = e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else ""
|
||||
e_dict["heure_fin"] = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else ""
|
||||
e_dict["jour_iso"] = e.date_debut.isoformat() if e.date_debut else ""
|
||||
# Calcule durée en minutes
|
||||
e_dict["descrheure"] = e.descr_heure()
|
||||
|
@ -507,7 +574,7 @@ def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
|
|||
return e_dict
|
||||
|
||||
|
||||
def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
||||
def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
|
||||
"""Check coefficient, dates and duration, raises exception if invalid.
|
||||
Convert date and time strings to date and time objects.
|
||||
|
||||
|
@ -522,7 +589,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
|||
# --- evaluation_type
|
||||
try:
|
||||
data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0)
|
||||
if not data["evaluation_type"] in VALID_EVALUATION_TYPES:
|
||||
if not data["evaluation_type"] in Evaluation.VALID_EVALUATION_TYPES:
|
||||
raise ScoValueError("invalid evaluation_type value")
|
||||
except ValueError as exc:
|
||||
raise ScoValueError("invalid evaluation_type value") from exc
|
||||
|
@ -547,7 +614,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
|||
if coef < 0:
|
||||
raise ScoValueError("invalid coefficient value (must be positive or null)")
|
||||
data["coefficient"] = coef
|
||||
# --- date de l'évaluation
|
||||
# --- date de l'évaluation dans le semestre ?
|
||||
formsemestre = moduleimpl.formsemestre
|
||||
date_debut = data.get("date_debut", None)
|
||||
if date_debut:
|
||||
|
@ -562,7 +629,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
|||
):
|
||||
raise ScoValueError(
|
||||
f"""La date de début de l'évaluation ({
|
||||
data["date_debut"].strftime("%d/%m/%Y")
|
||||
data["date_debut"].strftime(scu.DATE_FMT)
|
||||
}) n'est pas dans le semestre !""",
|
||||
dest_url="javascript:history.back();",
|
||||
)
|
||||
|
@ -577,7 +644,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
|||
):
|
||||
raise ScoValueError(
|
||||
f"""La date de fin de l'évaluation ({
|
||||
data["date_fin"].strftime("%d/%m/%Y")
|
||||
data["date_fin"].strftime(scu.DATE_FMT)
|
||||
}) n'est pas dans le semestre !""",
|
||||
dest_url="javascript:history.back();",
|
||||
)
|
||||
|
@ -588,6 +655,8 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
|
|||
"Heures de l'évaluation incohérentes !",
|
||||
dest_url="javascript:history.back();",
|
||||
)
|
||||
if "blocked_until" in data:
|
||||
data["blocked_until"] = data["blocked_until"] or None
|
||||
|
||||
|
||||
def heure_to_time(heure: str) -> datetime.time:
|
||||
|
@ -617,3 +686,6 @@ def _moduleimpl_evaluation_insert_before(
|
|||
db.session.add(e)
|
||||
db.session.commit()
|
||||
return n
|
||||
|
||||
|
||||
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
|
||||
|
|
|
@ -232,7 +232,9 @@ class ScolarNews(db.Model):
|
|||
)
|
||||
|
||||
# Transforme les URL en URL absolues
|
||||
base = scu.ScoURL()
|
||||
base = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)[
|
||||
: -len("/index_html")
|
||||
]
|
||||
txt = re.sub('href=/.*?"', 'href="' + base + "/", txt)
|
||||
|
||||
# Transforme les liens HTML en texte brut: '<a href="url">texte</a>' devient 'texte: url'
|
||||
|
@ -249,11 +251,12 @@ class ScolarNews(db.Model):
|
|||
news_list = cls.last_news(n=n)
|
||||
if not news_list:
|
||||
return ""
|
||||
dept_news_url = url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
|
||||
H = [
|
||||
f"""<div class="news"><span class="newstitle"><a href="{
|
||||
url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
|
||||
f"""<div class="scobox news"><div class="scobox-title"><a href="{
|
||||
dept_news_url
|
||||
}">Dernières opérations</a>
|
||||
</span><ul class="newslist">"""
|
||||
</div><ul class="newslist">"""
|
||||
]
|
||||
|
||||
for news in news_list:
|
||||
|
@ -261,16 +264,22 @@ class ScolarNews(db.Model):
|
|||
f"""<li class="newslist"><span class="newsdate">{news.formatted_date()}</span><span
|
||||
class="newstext">{news}</span></li>"""
|
||||
)
|
||||
H.append(
|
||||
f"""<li class="newslist">
|
||||
<span class="newstext"><a href="{dept_news_url}" class="stdlink">...</a>
|
||||
</span>
|
||||
</li>"""
|
||||
)
|
||||
|
||||
H.append("</ul>")
|
||||
H.append("</ul></div>")
|
||||
|
||||
# Informations générales
|
||||
H.append(
|
||||
f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}">
|
||||
Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>.
|
||||
f"""<div>
|
||||
Pour en savoir plus sur ScoDoc voir
|
||||
<a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">scodoc.org</a>
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
|
||||
H.append("</div>")
|
||||
return "\n".join(H)
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
"""ScoDoc 9 models : Formations
|
||||
"""
|
||||
|
||||
from flask import abort, g
|
||||
from flask_sqlalchemy.query import Query
|
||||
|
||||
import app
|
||||
|
@ -64,6 +66,21 @@ class Formation(db.Model):
|
|||
"titre complet pour affichage"
|
||||
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
|
||||
|
||||
@classmethod
|
||||
def get_formation(cls, formation_id: int | str, dept_id: int = None) -> "Formation":
|
||||
"""Formation ou 404, cherche uniquement dans le département spécifié
|
||||
ou le courant (g.scodoc_dept)"""
|
||||
if not isinstance(formation_id, int):
|
||||
try:
|
||||
formation_id = int(formation_id)
|
||||
except (TypeError, ValueError):
|
||||
abort(404, "formation_id invalide")
|
||||
if g.scodoc_dept:
|
||||
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
||||
if dept_id is not None:
|
||||
return cls.query.filter_by(id=formation_id, dept_id=dept_id).first_or_404()
|
||||
return cls.query.filter_by(id=formation_id).first_or_404()
|
||||
|
||||
def to_dict(self, with_refcomp_attrs=False, with_departement=True):
|
||||
"""As a dict.
|
||||
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
|
||||
|
|
|
@ -25,6 +25,7 @@ from sqlalchemy import func
|
|||
import app.scodoc.sco_utils as scu
|
||||
from app import db, log
|
||||
from app.auth.models import User
|
||||
from app import models
|
||||
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
|
||||
from app.models.but_refcomp import (
|
||||
ApcParcours,
|
||||
|
@ -54,7 +55,7 @@ from app.scodoc.sco_vdi import ApoEtapeVDI
|
|||
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
|
||||
|
||||
|
||||
class FormSemestre(db.Model):
|
||||
class FormSemestre(models.ScoDocModel):
|
||||
"""Mise en oeuvre d'un semestre de formation"""
|
||||
|
||||
__tablename__ = "notes_formsemestre"
|
||||
|
@ -68,7 +69,7 @@ class FormSemestre(db.Model):
|
|||
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
|
||||
titre = db.Column(db.Text(), nullable=False)
|
||||
date_debut = db.Column(db.Date(), nullable=False)
|
||||
date_fin = db.Column(db.Date(), nullable=False)
|
||||
date_fin = db.Column(db.Date(), nullable=False) # jour inclus
|
||||
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
|
||||
"identifiant emplois du temps (unicité non imposée)"
|
||||
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
|
||||
|
@ -84,7 +85,7 @@ class FormSemestre(db.Model):
|
|||
bul_hide_xml = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
"ne publie pas le bulletin XML ou JSON"
|
||||
"ne publie pas le bulletin sur l'API"
|
||||
block_moyennes = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
|
@ -93,6 +94,10 @@ class FormSemestre(db.Model):
|
|||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
|
||||
mode_calcul_moyennes = db.Column(
|
||||
db.Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
"pour usage futur"
|
||||
gestion_semestrielle = db.Column(
|
||||
db.Boolean(), nullable=False, default=False, server_default="false"
|
||||
)
|
||||
|
@ -187,7 +192,8 @@ class FormSemestre(db.Model):
|
|||
def get_formsemestre(
|
||||
cls, formsemestre_id: int | str, dept_id: int = None
|
||||
) -> "FormSemestre":
|
||||
"""FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant"""
|
||||
"""FormSemestre ou 404, cherche uniquement dans le département spécifié
|
||||
ou le courant (g.scodoc_dept)"""
|
||||
if not isinstance(formsemestre_id, int):
|
||||
try:
|
||||
formsemestre_id = int(formsemestre_id)
|
||||
|
@ -202,7 +208,7 @@ class FormSemestre(db.Model):
|
|||
return cls.query.filter_by(id=formsemestre_id).first_or_404()
|
||||
|
||||
def sort_key(self) -> tuple:
|
||||
"""clé pour tris par ordre alphabétique
|
||||
"""clé pour tris par ordre de date_debut, le plus ancien en tête
|
||||
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
|
||||
return (self.date_debut, self.semestre_id)
|
||||
|
||||
|
@ -218,12 +224,12 @@ class FormSemestre(db.Model):
|
|||
d["formsemestre_id"] = self.id
|
||||
d["titre_num"] = self.titre_num()
|
||||
if self.date_debut:
|
||||
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
|
||||
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
|
||||
d["date_debut_iso"] = self.date_debut.isoformat()
|
||||
else:
|
||||
d["date_debut"] = d["date_debut_iso"] = ""
|
||||
if self.date_fin:
|
||||
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
|
||||
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
|
||||
d["date_fin_iso"] = self.date_fin.isoformat()
|
||||
else:
|
||||
d["date_fin"] = d["date_fin_iso"] = ""
|
||||
|
@ -241,19 +247,20 @@ class FormSemestre(db.Model):
|
|||
|
||||
def to_dict_api(self):
|
||||
"""
|
||||
Un dict avec les informations sur le semestre destiné à l'api
|
||||
Un dict avec les informations sur le semestre destinées à l'api
|
||||
"""
|
||||
d = dict(self.__dict__)
|
||||
d.pop("_sa_instance_state", None)
|
||||
d.pop("groups_auto_assignment_data", None)
|
||||
d["annee_scolaire"] = self.annee_scolaire()
|
||||
d["bul_hide_xml"] = self.bul_hide_xml
|
||||
if self.date_debut:
|
||||
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
|
||||
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
|
||||
d["date_debut_iso"] = self.date_debut.isoformat()
|
||||
else:
|
||||
d["date_debut"] = d["date_debut_iso"] = ""
|
||||
if self.date_fin:
|
||||
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
|
||||
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
|
||||
d["date_fin_iso"] = self.date_fin.isoformat()
|
||||
else:
|
||||
d["date_fin"] = d["date_fin_iso"] = ""
|
||||
|
@ -673,7 +680,7 @@ class FormSemestre(db.Model):
|
|||
) -> db.Query:
|
||||
"""Liste (query) ordonnée des formsemestres courants, c'est
|
||||
à dire contenant la date courant (si None, la date actuelle)"""
|
||||
date_courante = date_courante or db.func.now()
|
||||
date_courante = date_courante or db.func.current_date()
|
||||
# Les semestres en cours de ce département
|
||||
formsemestres = FormSemestre.query.filter(
|
||||
FormSemestre.dept_id == dept.id,
|
||||
|
@ -869,9 +876,9 @@ class FormSemestre(db.Model):
|
|||
descr_sem += " " + self.modalite
|
||||
return descr_sem
|
||||
|
||||
def get_abs_count(self, etudid):
|
||||
def get_abs_count(self, etudid) -> tuple[int, int, int]:
|
||||
"""Les comptes d'absences de cet étudiant dans ce semestre:
|
||||
tuple (nb abs, nb abs justifiées)
|
||||
tuple (nb abs non just, nb abs justifiées, nb abs total)
|
||||
Utilise un cache.
|
||||
"""
|
||||
from app.scodoc import sco_assiduites
|
||||
|
@ -929,12 +936,16 @@ class FormSemestre(db.Model):
|
|||
partitions += [p for p in self.partitions if p.partition_name is None]
|
||||
return partitions
|
||||
|
||||
@cached_property
|
||||
def etudids_actifs(self) -> set:
|
||||
"Set des etudids inscrits non démissionnaires et non défaillants"
|
||||
return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT}
|
||||
def etudids_actifs(self) -> tuple[list[int], set[int]]:
|
||||
"""Liste les etudids inscrits (incluant DEM et DEF),
|
||||
qui ser al'index des dataframes de notes
|
||||
et donne l'ensemble des inscrits non DEM ni DEF.
|
||||
"""
|
||||
return [inscr.etudid for inscr in self.inscriptions], {
|
||||
ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
|
||||
}
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def etuds_inscriptions(self) -> dict:
|
||||
"""Map { etudid : inscription } (incluant DEM et DEF)"""
|
||||
return {ins.etud.id: ins for ins in self.inscriptions}
|
||||
|
|
|
@ -3,12 +3,14 @@
|
|||
"""
|
||||
import pandas as pd
|
||||
from flask import abort, g
|
||||
from flask_login import current_user
|
||||
from flask_sqlalchemy.query import Query
|
||||
|
||||
import app
|
||||
from app import db
|
||||
from app.auth.models import User
|
||||
from app.comp import df_cache
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models import APO_CODE_STR_LEN, ScoDocModel
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.evaluations import Evaluation
|
||||
from app.models.modules import Module
|
||||
|
@ -17,7 +19,7 @@ from app.scodoc.sco_permissions import Permission
|
|||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class ModuleImpl(db.Model):
|
||||
class ModuleImpl(ScoDocModel):
|
||||
"""Mise en oeuvre d'un module pour une annee/semestre"""
|
||||
|
||||
__tablename__ = "notes_moduleimpl"
|
||||
|
@ -36,7 +38,10 @@ class ModuleImpl(db.Model):
|
|||
index=True,
|
||||
nullable=False,
|
||||
)
|
||||
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
|
||||
responsable_id = db.Column(
|
||||
"responsable_id", db.Integer, db.ForeignKey("user.id", ondelete="SET NULL")
|
||||
)
|
||||
responsable = db.relationship("User", back_populates="modimpls")
|
||||
# formule de calcul moyenne:
|
||||
computation_expr = db.Column(db.Text())
|
||||
|
||||
|
@ -52,8 +57,8 @@ class ModuleImpl(db.Model):
|
|||
secondary="notes_modules_enseignants",
|
||||
lazy="dynamic",
|
||||
backref="moduleimpl",
|
||||
viewonly=True,
|
||||
)
|
||||
"enseignants du module (sans le responsable)"
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>"
|
||||
|
@ -74,7 +79,9 @@ class ModuleImpl(db.Model):
|
|||
] or self.module.get_edt_ids()
|
||||
|
||||
def get_evaluations_poids(self) -> pd.DataFrame:
|
||||
"""Les poids des évaluations vers les UE (accès via cache)"""
|
||||
"""Les poids des évaluations vers les UEs (accès via cache redis).
|
||||
Toutes les évaluations sont considérées (normales, bonus, rattr., etc.)
|
||||
"""
|
||||
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)
|
||||
if evaluations_poids is None:
|
||||
from app.comp import moy_mod
|
||||
|
@ -85,7 +92,7 @@ class ModuleImpl(db.Model):
|
|||
|
||||
@classmethod
|
||||
def get_modimpl(cls, moduleimpl_id: int | str, dept_id: int = None) -> "ModuleImpl":
|
||||
"""FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant."""
|
||||
"""ModuleImpl ou 404, cherche uniquement dans le département spécifié ou le courant."""
|
||||
from app.models.formsemestre import FormSemestre
|
||||
|
||||
if not isinstance(moduleimpl_id, int):
|
||||
|
@ -104,20 +111,37 @@ class ModuleImpl(db.Model):
|
|||
"""Invalide poids cachés"""
|
||||
df_cache.EvaluationsPoidsCache.delete(self.id)
|
||||
|
||||
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
|
||||
"""true si les poids des évaluations du module permettent de satisfaire
|
||||
les coefficients du PN.
|
||||
def check_apc_conformity(
|
||||
self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE
|
||||
) -> bool:
|
||||
"""true si les poids des évaluations du type indiqué (normales par défaut)
|
||||
du module permettent de satisfaire les coefficients du PN.
|
||||
"""
|
||||
# appelé par formsemestre_status, liste notes, et moduleimpl_status
|
||||
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
|
||||
self.module.module_type
|
||||
not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
|
||||
):
|
||||
return True # Non BUT, toujours conforme
|
||||
from app.comp import moy_mod
|
||||
|
||||
mod_results = res.modimpls_results.get(self.id)
|
||||
if mod_results is None:
|
||||
app.critical_error("check_apc_conformity: err 1")
|
||||
|
||||
selected_evaluations_ids = [
|
||||
eval_id
|
||||
for eval_id, eval_type in mod_results.evals_type.items()
|
||||
if eval_type == evaluation_type
|
||||
]
|
||||
if not selected_evaluations_ids:
|
||||
return True # conforme si pas d'évaluations
|
||||
selected_evaluations_poids = self.get_evaluations_poids().loc[
|
||||
selected_evaluations_ids
|
||||
]
|
||||
return moy_mod.moduleimpl_is_conforme(
|
||||
self,
|
||||
self.get_evaluations_poids(),
|
||||
selected_evaluations_poids,
|
||||
res.modimpl_coefs_df,
|
||||
)
|
||||
|
||||
|
@ -187,7 +211,7 @@ class ModuleImpl(db.Model):
|
|||
return allow_ens and user.id in (ens.id for ens in self.enseignants)
|
||||
return True
|
||||
|
||||
def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
|
||||
def can_change_responsable(self, user: User, raise_exc=False) -> bool:
|
||||
"""Check if user can modify module resp.
|
||||
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
|
||||
= Admin, et dir des etud. (si option l'y autorise)
|
||||
|
@ -208,6 +232,27 @@ class ModuleImpl(db.Model):
|
|||
raise AccessDenied(f"Modification impossible pour {user}")
|
||||
return False
|
||||
|
||||
def can_change_ens(self, user: User | None = None, raise_exc=True) -> bool:
|
||||
"""check if user can modify ens list (raise exception if not)"
|
||||
if user is None, current user.
|
||||
"""
|
||||
user = current_user if user is None else user
|
||||
if not self.formsemestre.etat:
|
||||
if raise_exc:
|
||||
raise ScoLockedSemError("Modification impossible: semestre verrouille")
|
||||
return False
|
||||
# -- check access
|
||||
# admin, resp. module ou resp. semestre
|
||||
if (
|
||||
user.id != self.responsable_id
|
||||
and not user.has_permission(Permission.EditFormSemestre)
|
||||
and user.id not in (u.id for u in self.formsemestre.responsables)
|
||||
):
|
||||
if raise_exc:
|
||||
raise AccessDenied(f"Modification impossible pour {user}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def est_inscrit(self, etud: Identite) -> bool:
|
||||
"""
|
||||
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
"""ScoDoc 9 models : Modules
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
from flask import current_app, g
|
||||
|
||||
from app import db
|
||||
from app import models
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
|
||||
from app.models.but_refcomp import (
|
||||
ApcParcours,
|
||||
ApcReferentielCompetences,
|
||||
app_critiques_modules,
|
||||
parcours_modules,
|
||||
)
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
class Module(db.Model):
|
||||
class Module(models.ScoDocModel):
|
||||
"""Module"""
|
||||
|
||||
__tablename__ = "notes_modules"
|
||||
|
@ -76,6 +83,55 @@ class Module(db.Model):
|
|||
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
|
||||
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
|
||||
|
||||
@classmethod
|
||||
def convert_dict_fields(cls, args: dict) -> dict:
|
||||
"""Convert fields in the given dict. No other side effect.
|
||||
returns: dict to store in model's db.
|
||||
"""
|
||||
# s'assure que ects etc est non ''
|
||||
fs_empty_stored_as_nulls = {
|
||||
"coefficient",
|
||||
"ects",
|
||||
"heures_cours",
|
||||
"heures_td",
|
||||
"heures_tp",
|
||||
}
|
||||
args_dict = {}
|
||||
for key, value in args.items():
|
||||
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
|
||||
if key in fs_empty_stored_as_nulls and value == "":
|
||||
value = None
|
||||
args_dict[key] = value
|
||||
|
||||
return args_dict
|
||||
|
||||
@classmethod
|
||||
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
|
||||
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
|
||||
Add 'id' to excluded."""
|
||||
# on ne peut pas affecter directement parcours
|
||||
return super().filter_model_attributes(data, (excluded or set()) | {"parcours"})
|
||||
|
||||
@classmethod
|
||||
def create_from_dict(cls, data: dict) -> "Module":
|
||||
"""Create from given dict, add parcours"""
|
||||
mod = super().create_from_dict(data)
|
||||
for p in data.get("parcours", []) or []:
|
||||
if isinstance(p, ApcParcours):
|
||||
parcour: ApcParcours = p
|
||||
else:
|
||||
pid = int(p)
|
||||
query = ApcParcours.query.filter_by(id=pid)
|
||||
if g.scodoc_dept:
|
||||
query = query.join(ApcReferentielCompetences).filter_by(
|
||||
dept_id=g.scodoc_dept_id
|
||||
)
|
||||
parcour: ApcParcours = query.first()
|
||||
if parcour is None:
|
||||
raise ScoValueError("Parcours invalide")
|
||||
mod.parcours.append(parcour)
|
||||
return mod
|
||||
|
||||
def clone(self):
|
||||
"""Create a new copy of this module."""
|
||||
mod = Module(
|
||||
|
@ -310,6 +366,14 @@ class Module(db.Model):
|
|||
return []
|
||||
return self.parcours
|
||||
|
||||
def add_tag(self, tag: "NotesTag"):
|
||||
"""Add tag to module. Check if already has it."""
|
||||
if tag.id in {t.id for t in self.tags}:
|
||||
return
|
||||
self.tags.append(tag)
|
||||
db.session.add(self)
|
||||
db.session.flush()
|
||||
|
||||
|
||||
class ModuleUECoef(db.Model):
|
||||
"""Coefficients des modules vers les UE (APC, BUT)
|
||||
|
@ -372,6 +436,19 @@ class NotesTag(db.Model):
|
|||
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
|
||||
title = db.Column(db.Text(), nullable=False)
|
||||
|
||||
@classmethod
|
||||
def get_or_create(cls, title: str, dept_id: int | None = None) -> "NotesTag":
|
||||
"""Get tag, or create it if it doesn't yet exists.
|
||||
If dept_id unspecified, use current dept.
|
||||
"""
|
||||
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
|
||||
tag = NotesTag.query.filter_by(dept_id=dept_id, title=title).first()
|
||||
if tag is None:
|
||||
tag = NotesTag(dept_id=dept_id, title=title)
|
||||
db.session.add(tag)
|
||||
db.session.flush()
|
||||
return tag
|
||||
|
||||
|
||||
# Association tag <-> module
|
||||
notes_modules_tags = db.Table(
|
||||
|
|
|
@ -5,6 +5,7 @@ from flask import g
|
|||
import pandas as pd
|
||||
|
||||
from app import db, log
|
||||
from app import models
|
||||
from app.models import APO_CODE_STR_LEN
|
||||
from app.models import SHORT_STR_LEN
|
||||
from app.models.but_refcomp import ApcNiveau, ApcParcours
|
||||
|
@ -12,7 +13,7 @@ from app.models.modules import Module
|
|||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
class UniteEns(db.Model):
|
||||
class UniteEns(models.ScoDocModel):
|
||||
"""Unité d'Enseignement (UE)"""
|
||||
|
||||
__tablename__ = "notes_ue"
|
||||
|
@ -81,7 +82,7 @@ class UniteEns(db.Model):
|
|||
'EXTERNE' if self.is_external else ''})>"""
|
||||
|
||||
def clone(self):
|
||||
"""Create a new copy of this ue.
|
||||
"""Create a new copy of this ue, add to session.
|
||||
Ne copie pas le code, ni le code Apogée, ni les liens au réf. de comp.
|
||||
(parcours et niveau).
|
||||
"""
|
||||
|
@ -100,8 +101,26 @@ class UniteEns(db.Model):
|
|||
coef_rcue=self.coef_rcue,
|
||||
color=self.color,
|
||||
)
|
||||
db.session.add(ue)
|
||||
return ue
|
||||
|
||||
@classmethod
|
||||
def convert_dict_fields(cls, args: dict) -> dict:
|
||||
"""Convert fields from the given dict to model's attributes values. No side effect.
|
||||
|
||||
args: dict with args in application.
|
||||
returns: dict to store in model's db.
|
||||
"""
|
||||
args = args.copy()
|
||||
if "type" in args:
|
||||
args["type"] = int(args["type"] or 0)
|
||||
if "is_external" in args:
|
||||
args["is_external"] = scu.to_bool(args["is_external"])
|
||||
if "ects" in args:
|
||||
args["ects"] = float(args["ects"])
|
||||
|
||||
return args
|
||||
|
||||
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
|
||||
"""as a dict, with the same conversions as in ScoDoc7.
|
||||
If convert_objects, convert all attributes to native types
|
||||
|
@ -390,6 +409,14 @@ class UniteEns(db.Model):
|
|||
Renvoie (True, "") si ok, sinon (False, error_message)
|
||||
"""
|
||||
msg = ""
|
||||
# Safety check
|
||||
if self.formation.referentiel_competence is None:
|
||||
return False, "pas de référentiel de compétence"
|
||||
# Si tous les parcours, aucun (tronc commun)
|
||||
if {p.id for p in parcours} == {
|
||||
p.id for p in self.formation.referentiel_competence.parcours
|
||||
}:
|
||||
parcours = []
|
||||
# Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
|
||||
prev_niveau = self.niveau_competence
|
||||
if (
|
||||
|
@ -405,6 +432,7 @@ class UniteEns(db.Model):
|
|||
self.niveau_competence, parcours
|
||||
)
|
||||
if not ok:
|
||||
self.formation.invalidate_cached_sems()
|
||||
self.niveau_competence = prev_niveau # restore
|
||||
return False, error_message
|
||||
|
||||
|
|
|
@ -72,7 +72,7 @@ class ScolarFormSemestreValidation(db.Model):
|
|||
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id
|
||||
} ({self.ue_id}): {self.code}"""
|
||||
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {
|
||||
self.event_date.strftime("%d/%m/%Y")}"""
|
||||
self.event_date.strftime(scu.DATE_FMT)}"""
|
||||
|
||||
def delete(self):
|
||||
"Efface cette validation"
|
||||
|
@ -113,20 +113,20 @@ class ScolarFormSemestreValidation(db.Model):
|
|||
if self.ue.parcours else ""}
|
||||
{("émise par " + link)}
|
||||
: <b>{self.code}</b>{moyenne}
|
||||
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
|
||||
"""
|
||||
le {self.event_date.strftime(scu.DATEATIME_FMT)}
|
||||
"""
|
||||
else:
|
||||
return f"""Validation du semestre S{
|
||||
self.formsemestre.semestre_id if self.formsemestre else "?"}
|
||||
{self.formsemestre.html_link_status() if self.formsemestre else ""}
|
||||
: <b>{self.code}</b>
|
||||
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
|
||||
le {self.event_date.strftime(scu.DATEATIME_FMT)}
|
||||
"""
|
||||
|
||||
def ects(self) -> float:
|
||||
"Les ECTS acquis par cette validation. (0 si ce n'est pas une validation d'UE)"
|
||||
return (
|
||||
self.ue.ects
|
||||
self.ue.ects or 0.0
|
||||
if (self.ue is not None) and (self.code in CODES_UE_VALIDES)
|
||||
else 0.0
|
||||
)
|
||||
|
@ -175,8 +175,8 @@ class ScolarAutorisationInscription(db.Model):
|
|||
)
|
||||
return f"""Autorisation de passage vers <b>S{self.semestre_id}</b> émise par
|
||||
{link}
|
||||
le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
|
||||
"""
|
||||
le {self.date.strftime(scu.DATEATIME_FMT)}
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def autorise_etud(
|
||||
|
|
|
@ -0,0 +1,342 @@
|
|||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Thu Sep 8 09:36:33 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from app.models import Identite
|
||||
from app.pe import pe_affichage
|
||||
from app.pe.moys import pe_tabletags, pe_moy, pe_moytag, pe_sxtag
|
||||
from app.pe.rcss import pe_rcs
|
||||
import app.pe.pe_comp as pe_comp
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
class InterClassTag(pe_tabletags.TableTag):
|
||||
"""
|
||||
Interclasse l'ensemble des étudiants diplômés à une année
|
||||
donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S'), qu'il soit
|
||||
de type SemX ou RCSemX,
|
||||
en reportant les moyennes obtenues sur à la version tagguée
|
||||
du RCS (de type SxTag ou RCSTag).
|
||||
Sont ensuite calculés les classements (uniquement)
|
||||
sur les étudiants diplômes.
|
||||
|
||||
Args:
|
||||
nom_rcs: Le nom de l'aggrégat
|
||||
type_interclassement: Le type d'interclassement (par UE ou par compétences)
|
||||
etudiants_diplomes: L'identité des étudiants diplômés
|
||||
rcss: Un dictionnaire {(nom_rcs, fid_final): RCS} donnant soit
|
||||
les SemX soit les RCSemX recencés par le jury PE
|
||||
rcstag: Un dictionnaire {(nom_rcs, fid_final): RCSTag} donnant
|
||||
soit les SxTag (associés aux SemX)
|
||||
soit les RCSTags (associés au RCSemX) calculés par le jury PE
|
||||
suivis: Un dictionnaire associé à chaque étudiant son rcss
|
||||
(de la forme ``{etudid: {nom_rcs: RCS_suivi}}``)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
nom_rcs: str,
|
||||
type_interclassement: str,
|
||||
etudiants_diplomes: dict[int, Identite],
|
||||
rcss: dict[(str, int) : pe_rcs.RCS],
|
||||
rcstags: dict[(str, int) : pe_tabletags.TableTag],
|
||||
suivis: dict[int:dict],
|
||||
):
|
||||
pe_tabletags.TableTag.__init__(self)
|
||||
|
||||
self.nom_rcs: str = nom_rcs
|
||||
"""Le nom du RCS interclassé"""
|
||||
|
||||
# Le type d'interclassement
|
||||
self.type = type_interclassement
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"*** Interclassement par 🗂️{type_interclassement} pour le RCS ⏯️{nom_rcs}"
|
||||
)
|
||||
|
||||
# Les informations sur les étudiants diplômés
|
||||
self.etuds: list[Identite] = list(etudiants_diplomes.values())
|
||||
"""Identités des étudiants diplômés"""
|
||||
self.add_etuds(self.etuds)
|
||||
|
||||
self.diplomes_ids = set(etudiants_diplomes.keys())
|
||||
"""Etudids des étudiants diplômés"""
|
||||
|
||||
# Les RCS de l'aggrégat (SemX ou RCSemX)
|
||||
self.rcss: dict[(str, int), pe_rcs.RCS] = {}
|
||||
"""Ensemble des SemX ou des RCSemX associés à l'aggrégat"""
|
||||
for (nom, fid), rcs in rcss.items():
|
||||
if nom == nom_rcs:
|
||||
self.rcss[(nom, fid)] = rcss
|
||||
|
||||
# Les données tagguées
|
||||
self.rcstags: dict[(str, int), pe_tabletags.TableTag] = {}
|
||||
"""Ensemble des SxTag ou des RCSTags associés à l'aggrégat"""
|
||||
for rcs_id in self.rcss:
|
||||
self.rcstags[rcs_id] = rcstags[rcs_id]
|
||||
|
||||
# Les RCS (SemX ou RCSemX) suivis par les étudiants du jury,
|
||||
# en ne gardant que ceux associés aux diplomés
|
||||
self.suivis: dict[int, pe_rcs.RCS] = {}
|
||||
"""Association entre chaque étudiant et le SxTag ou RCSTag à prendre
|
||||
pour l'aggrégat"""
|
||||
for etudid in self.diplomes_ids:
|
||||
self.suivis[etudid] = suivis[etudid][nom_rcs]
|
||||
|
||||
# Les données sur les tags
|
||||
self.tags_sorted = self._do_taglist()
|
||||
"""Liste des tags (triés par ordre alphabétique)"""
|
||||
aff = pe_affichage.repr_tags(self.tags_sorted)
|
||||
pe_affichage.pe_print(f"--> Tags : {aff}")
|
||||
|
||||
# Les données sur les UEs (si SxTag) ou compétences (si RCSTag)
|
||||
self.champs_sorted = self._do_ues_ou_competences_list()
|
||||
"""Les champs (UEs ou compétences) de l'interclassement"""
|
||||
if self.type == pe_moytag.CODE_MOY_UE:
|
||||
pe_affichage.pe_print(
|
||||
f"--> UEs : {pe_affichage.aff_UEs(self.champs_sorted)}"
|
||||
)
|
||||
else:
|
||||
pe_affichage.pe_print(
|
||||
f"--> Compétences : {pe_affichage.aff_competences(self.champs_sorted)}"
|
||||
)
|
||||
|
||||
# Etudids triés
|
||||
self.etudids_sorted = sorted(list(self.diplomes_ids))
|
||||
|
||||
self.nom = self.get_repr()
|
||||
"""Représentation textuelle de l'interclassement"""
|
||||
|
||||
# Synthétise les moyennes/classements par tag
|
||||
self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {}
|
||||
for tag in self.tags_sorted:
|
||||
# Les moyennes tous modules confondus
|
||||
notes_gen = self.compute_notes_matrice(tag)
|
||||
|
||||
# Les coefficients de la moyenne générale
|
||||
coeffs = self.compute_coeffs_matrice(tag)
|
||||
aff = pe_affichage.repr_profil_coeffs(coeffs, with_index=True)
|
||||
pe_affichage.pe_print(f"--> Moyenne 👜{tag} avec coeffs: {aff} ")
|
||||
|
||||
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
|
||||
tag,
|
||||
self.type,
|
||||
notes_gen,
|
||||
coeffs, # limite les moyennes aux étudiants de la promo
|
||||
)
|
||||
|
||||
def get_repr(self) -> str:
|
||||
"""Une représentation textuelle"""
|
||||
return f"{self.nom_rcs} par {self.type}"
|
||||
|
||||
def _do_taglist(self):
|
||||
"""Synthétise les tags à partir des TableTags (SXTag ou RCSTag)
|
||||
|
||||
Returns:
|
||||
Une liste de tags triés par ordre alphabétique
|
||||
"""
|
||||
tags = []
|
||||
for rcstag in self.rcstags.values():
|
||||
tags.extend(rcstag.tags_sorted)
|
||||
return sorted(set(tags))
|
||||
|
||||
def compute_notes_matrice(self, tag) -> pd.DataFrame:
|
||||
"""Construit la matrice de notes (etudids x champs) en
|
||||
reportant les moyennes obtenues par les étudiants
|
||||
aux semestres de l'aggrégat pour le tag visé.
|
||||
|
||||
Les champs peuvent être des acronymes d'UEs ou des compétences.
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
Return:
|
||||
Le dataFrame (etudids x champs)
|
||||
reportant les moyennes des étudiants aux champs
|
||||
"""
|
||||
# etudids_sorted: Les etudids des étudiants (diplômés) triés
|
||||
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
|
||||
|
||||
# Partant d'un dataframe vierge
|
||||
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
|
||||
|
||||
for rcstag in self.rcstags.values():
|
||||
# Charge les moyennes au tag d'un RCStag
|
||||
if tag in rcstag.moyennes_tags:
|
||||
moytag = rcstag.moyennes_tags[tag]
|
||||
|
||||
notes = moytag.matrice_notes_gen # dataframe etudids x ues
|
||||
|
||||
# Etudiants/Champs communs entre le RCSTag et les données interclassées
|
||||
(
|
||||
etudids_communs,
|
||||
champs_communs,
|
||||
) = pe_comp.find_index_and_columns_communs(df, notes)
|
||||
|
||||
# Injecte les notes par tag
|
||||
df.loc[etudids_communs, champs_communs] = notes.loc[
|
||||
etudids_communs, champs_communs
|
||||
]
|
||||
|
||||
return df
|
||||
|
||||
def compute_coeffs_matrice(self, tag) -> pd.DataFrame:
|
||||
"""Idem que compute_notes_matrices mais pour les coeffs
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
Return:
|
||||
Le dataFrame (etudids x champs)
|
||||
reportant les moyennes des étudiants aux champs
|
||||
"""
|
||||
# etudids_sorted: Les etudids des étudiants (diplômés) triés
|
||||
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
|
||||
|
||||
# Partant d'un dataframe vierge
|
||||
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
|
||||
|
||||
for rcstag in self.rcstags.values():
|
||||
if tag in rcstag.moyennes_tags:
|
||||
# Charge les coeffs au tag d'un RCStag
|
||||
coeffs: pd.DataFrame = rcstag.moyennes_tags[tag].matrice_coeffs_moy_gen
|
||||
|
||||
# Etudiants/Champs communs entre le RCSTag et les données interclassées
|
||||
(
|
||||
etudids_communs,
|
||||
champs_communs,
|
||||
) = pe_comp.find_index_and_columns_communs(df, coeffs)
|
||||
|
||||
# Injecte les coeffs par tag
|
||||
df.loc[etudids_communs, champs_communs] = coeffs.loc[
|
||||
etudids_communs, champs_communs
|
||||
]
|
||||
|
||||
return df
|
||||
|
||||
def _do_ues_ou_competences_list(self) -> list[str]:
|
||||
"""Synthétise les champs (UEs ou compétences) sur lesquels
|
||||
sont calculés les moyennes.
|
||||
|
||||
Returns:
|
||||
Un dictionnaire {'acronyme_ue' : 'compétences'}
|
||||
"""
|
||||
dict_champs = []
|
||||
for rcstag in self.rcstags.values():
|
||||
if isinstance(rcstag, pe_sxtag.SxTag):
|
||||
champs = rcstag.acronymes_sorted
|
||||
else: # pe_rcstag.RCSTag
|
||||
champs = rcstag.competences_sorted
|
||||
dict_champs.extend(champs)
|
||||
return sorted(set(dict_champs))
|
||||
|
||||
def has_tags(self):
|
||||
"""Indique si l'interclassement a des tags (cas d'un
|
||||
interclassement sur un S5 qui n'a pas eu lieu)
|
||||
"""
|
||||
return len(self.tags_sorted) > 0
|
||||
|
||||
def _un_rcstag_significatif(self, rcsstags: dict[(str, int):pe_tabletags]):
|
||||
"""Renvoie un rcstag significatif (ayant des tags et des notes aux tags)
|
||||
parmi le dictionnaire de rcsstags"""
|
||||
for rcstag_id, rcstag in rcsstags.items():
|
||||
moystags: pe_moytag.MoyennesTag = rcstag.moyennes_tags
|
||||
for tag, moystag in moystags.items():
|
||||
tags_tries = moystag.get_all_significant_tags()
|
||||
if tags_tries:
|
||||
return moystag
|
||||
return None
|
||||
|
||||
def compute_df_synthese_moyennes_tag(
|
||||
self, tag, aggregat=None, type_colonnes=False, options={"min_max_moy": True}
|
||||
) -> pd.DataFrame:
|
||||
"""Construit le dataframe retraçant pour les données des moyennes
|
||||
pour affichage dans la synthèse du jury PE. (cf. to_df())
|
||||
|
||||
Args:
|
||||
etudids_sorted: Les etudids des étudiants (diplômés) triés
|
||||
champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
|
||||
Return:
|
||||
Le dataFrame (etudids x champs)
|
||||
reportant les moyennes des étudiants aux champs
|
||||
"""
|
||||
if aggregat:
|
||||
assert (
|
||||
aggregat == self.nom_rcs
|
||||
), "L'interclassement ciblé ne correspond pas à l'aggrégat visé"
|
||||
|
||||
etudids_sorted = sorted(list(self.diplomes_ids))
|
||||
|
||||
if not self.rcstags:
|
||||
return None
|
||||
|
||||
# Partant d'un dataframe vierge
|
||||
initialisation = False
|
||||
df = pd.DataFrame()
|
||||
|
||||
# Pour chaque rcs (suivi) associe la liste des etudids l'ayant suivi
|
||||
asso_rcs_etudids = {}
|
||||
for etudid in etudids_sorted:
|
||||
rcs = self.suivis[etudid]
|
||||
if rcs:
|
||||
if rcs.rcs_id not in asso_rcs_etudids:
|
||||
asso_rcs_etudids[rcs.rcs_id] = []
|
||||
asso_rcs_etudids[rcs.rcs_id].append(etudid)
|
||||
|
||||
for rcs_id, etudids in asso_rcs_etudids.items():
|
||||
# Charge ses moyennes au RCSTag suivi
|
||||
rcstag = self.rcstags[rcs_id] # Le SxTag ou RCSTag
|
||||
# Charge la moyenne
|
||||
if tag in rcstag.moyennes_tags:
|
||||
moytag: pd.DataFrame = rcstag.moyennes_tags[tag]
|
||||
df_moytag = moytag.to_df(
|
||||
aggregat=aggregat, cohorte="Groupe", options=options
|
||||
)
|
||||
|
||||
# Modif les colonnes au regard du 1er df_moytag significatif lu
|
||||
if not initialisation:
|
||||
df = pd.DataFrame(
|
||||
np.nan, index=etudids_sorted, columns=df_moytag.columns
|
||||
)
|
||||
colonnes = list(df_moytag.columns)
|
||||
for col in colonnes:
|
||||
if col.endswith("rang"):
|
||||
df[col] = df[col].astype(str)
|
||||
initialisation = True
|
||||
|
||||
# Injecte les notes des étudiants
|
||||
df.loc[etudids, :] = df_moytag.loc[etudids, :]
|
||||
|
||||
return df
|
|
@ -0,0 +1,128 @@
|
|||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app.comp.moy_sem import comp_ranks_series
|
||||
from app.pe import pe_affichage
|
||||
|
||||
|
||||
class Moyenne:
|
||||
COLONNES = [
|
||||
"note",
|
||||
"classement",
|
||||
"rang",
|
||||
"min",
|
||||
"max",
|
||||
"moy",
|
||||
"nb_etuds",
|
||||
"nb_inscrits",
|
||||
]
|
||||
"""Colonnes du df"""
|
||||
|
||||
@classmethod
|
||||
def get_colonnes_synthese(cls, with_min_max_moy):
|
||||
if with_min_max_moy:
|
||||
return ["note", "rang", "min", "max", "moy"]
|
||||
else:
|
||||
return ["note", "rang"]
|
||||
|
||||
def __init__(self, notes: pd.Series):
|
||||
"""Classe centralisant la synthèse des moyennes/classements d'une série
|
||||
de notes :
|
||||
|
||||
* des "notes" : la Serie pandas des notes (float),
|
||||
* des "classements" : la Serie pandas des classements (float),
|
||||
* des "min" : la note minimum,
|
||||
* des "max" : la note maximum,
|
||||
* des "moy" : la moyenne,
|
||||
* des "nb_inscrits" : le nombre d'étudiants ayant une note,
|
||||
"""
|
||||
self.notes = notes
|
||||
"""Les notes"""
|
||||
self.etudids = list(notes.index) # calcul à venir
|
||||
"""Les id des étudiants"""
|
||||
self.inscrits_ids = notes[notes.notnull()].index.to_list()
|
||||
"""Les id des étudiants dont la note est non nulle"""
|
||||
self.df: pd.DataFrame = self.comp_moy_et_stat(self.notes)
|
||||
"""Le dataframe retraçant les moyennes/classements/statistiques"""
|
||||
self.synthese = self.to_dict()
|
||||
"""La synthèse (dictionnaire) des notes/classements/statistiques"""
|
||||
|
||||
def comp_moy_et_stat(self, notes: pd.Series) -> dict:
|
||||
"""Calcule et structure les données nécessaires au PE pour une série
|
||||
de notes (pouvant être une moyenne d'un tag à une UE ou une moyenne générale
|
||||
d'un tag) dans un dictionnaire spécifique.
|
||||
|
||||
Partant des notes, sont calculés les classements (en ne tenant compte
|
||||
que des notes non nulles).
|
||||
|
||||
Args:
|
||||
notes: Une série de notes (avec des éventuels NaN)
|
||||
|
||||
Returns:
|
||||
Un dictionnaire stockant les notes, les classements, le min,
|
||||
le max, la moyenne, le nb de notes (donc d'inscrits)
|
||||
"""
|
||||
df = pd.DataFrame(
|
||||
np.nan,
|
||||
index=self.etudids,
|
||||
columns=Moyenne.COLONNES,
|
||||
)
|
||||
|
||||
# Supprime d'éventuelles chaines de caractères dans les notes
|
||||
notes = pd.to_numeric(notes, errors="coerce")
|
||||
df["note"] = notes
|
||||
|
||||
# Les nb d'étudiants & nb d'inscrits
|
||||
df["nb_etuds"] = len(self.etudids)
|
||||
df["nb_etuds"] = df["nb_etuds"].astype(int)
|
||||
|
||||
# Les étudiants dont la note n'est pas nulle
|
||||
inscrits_ids = notes[notes.notnull()].index.to_list()
|
||||
df.loc[inscrits_ids, "nb_inscrits"] = len(inscrits_ids)
|
||||
# df["nb_inscrits"] = df["nb_inscrits"].astype(int)
|
||||
|
||||
# Le classement des inscrits
|
||||
notes_non_nulles = notes[inscrits_ids]
|
||||
(class_str, class_int) = comp_ranks_series(notes_non_nulles)
|
||||
df.loc[inscrits_ids, "classement"] = class_int
|
||||
# df["classement"] = df["classement"].astype(int)
|
||||
|
||||
# Le rang (classement/nb_inscrit)
|
||||
df["rang"] = df["rang"].astype(str)
|
||||
df.loc[inscrits_ids, "rang"] = (
|
||||
df.loc[inscrits_ids, "classement"].astype(int).astype(str)
|
||||
+ "/"
|
||||
+ df.loc[inscrits_ids, "nb_inscrits"].astype(int).astype(str)
|
||||
)
|
||||
|
||||
# Les stat (des inscrits)
|
||||
df.loc[inscrits_ids, "min"] = notes.min()
|
||||
df.loc[inscrits_ids, "max"] = notes.max()
|
||||
df.loc[inscrits_ids, "moy"] = notes.mean()
|
||||
|
||||
return df
|
||||
|
||||
def get_df_synthese(self, with_min_max_moy=None):
|
||||
"""Renvoie le df de synthese limité aux colonnes de synthese"""
|
||||
colonnes_synthese = Moyenne.get_colonnes_synthese(
|
||||
with_min_max_moy=with_min_max_moy
|
||||
)
|
||||
df = self.df[colonnes_synthese].copy()
|
||||
df["rang"] = df["rang"].replace("nan", "")
|
||||
return df
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Renvoie un dictionnaire de synthèse des moyennes/classements/statistiques générale (but)"""
|
||||
synthese = {
|
||||
"notes": self.df["note"],
|
||||
"classements": self.df["classement"],
|
||||
"min": self.df["min"].mean(),
|
||||
"max": self.df["max"].mean(),
|
||||
"moy": self.df["moy"].mean(),
|
||||
"nb_inscrits": self.df["nb_inscrits"].mean(),
|
||||
}
|
||||
return synthese
|
||||
|
||||
def is_significatif(self) -> bool:
|
||||
"""Indique si la moyenne est significative (c'est-à-dire à des notes)"""
|
||||
return self.synthese["nb_inscrits"] > 0
|
|
@ -0,0 +1,169 @@
|
|||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from app import comp
|
||||
from app.comp.moy_sem import comp_ranks_series
|
||||
from app.pe.moys import pe_moy
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
CODE_MOY_UE = "UEs"
|
||||
CODE_MOY_COMPETENCES = "Compétences"
|
||||
CHAMP_GENERAL = "Général" # Nom du champ de la moyenne générale
|
||||
|
||||
|
||||
class MoyennesTag:
|
||||
def __init__(
|
||||
self,
|
||||
tag: str,
|
||||
type_moyenne: str,
|
||||
matrice_notes_gen: pd.DataFrame, # etudids x colonnes
|
||||
matrice_coeffs: pd.DataFrame, # etudids x colonnes
|
||||
):
|
||||
"""Classe centralisant la synthèse des moyennes/classements d'une série
|
||||
d'étudiants à un tag donné, en différenciant les notes
|
||||
obtenues aux UE et au général (toutes UEs confondues)
|
||||
|
||||
|
||||
Args:
|
||||
tag: Un tag
|
||||
matrice_notes_gen: Les moyennes (etudid x acronymes_ues ou etudid x compétences)
|
||||
aux différentes UEs ou compétences
|
||||
# notes_gen: Une série de notes (moyenne) sous forme d'un ``pd.Series`` (toutes UEs confondues)
|
||||
"""
|
||||
self.tag = tag
|
||||
"""Le tag associé aux moyennes"""
|
||||
|
||||
self.type = type_moyenne
|
||||
"""Le type de moyennes (par UEs ou par compétences)"""
|
||||
|
||||
# Les moyennes par UE/compétences (ressources/SAEs confondues)
|
||||
self.matrice_notes_gen: pd.DataFrame = matrice_notes_gen
|
||||
"""Les notes par UEs ou Compétences (DataFrame)"""
|
||||
|
||||
self.matrice_coeffs_moy_gen: pd.DataFrame = matrice_coeffs
|
||||
"""Les coeffs à appliquer pour le calcul des moyennes générales
|
||||
(toutes UE ou compétences confondues). NaN si étudiant non inscrit"""
|
||||
|
||||
self.moyennes_gen: dict[int, pd.DataFrame] = {}
|
||||
"""Dataframes retraçant les moyennes/classements/statistiques des étudiants aux UEs"""
|
||||
|
||||
self.etudids = self.matrice_notes_gen.index
|
||||
"""Les étudids renseignés dans les moyennes"""
|
||||
|
||||
self.champs = self.matrice_notes_gen.columns
|
||||
"""Les champs (acronymes d'UE ou compétences) renseignés dans les moyennes"""
|
||||
for col in self.champs: # if ue.type != UE_SPORT:
|
||||
# Les moyennes tous modules confondus
|
||||
notes = matrice_notes_gen[col]
|
||||
self.moyennes_gen[col] = pe_moy.Moyenne(notes)
|
||||
|
||||
# Les moyennes générales (toutes UEs confondues)
|
||||
self.notes_gen = pd.Series(np.nan, index=self.matrice_notes_gen.index)
|
||||
if self.has_notes():
|
||||
self.notes_gen = self.compute_moy_gen(
|
||||
self.matrice_notes_gen, self.matrice_coeffs_moy_gen
|
||||
)
|
||||
self.moyenne_gen = pe_moy.Moyenne(self.notes_gen)
|
||||
"""Dataframe retraçant les moyennes/classements/statistiques général (toutes UESs confondues et modules confondus)"""
|
||||
|
||||
def has_notes(self):
|
||||
"""Détermine si les moyennes (aux UEs ou aux compétences)
|
||||
ont des notes
|
||||
|
||||
Returns:
|
||||
True si la moytag a des notes, False sinon
|
||||
"""
|
||||
notes = self.matrice_notes_gen
|
||||
|
||||
nbre_nan = notes.isna().sum().sum()
|
||||
nbre_notes_potentielles = len(notes.index) * len(notes.columns)
|
||||
if nbre_nan == nbre_notes_potentielles:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def compute_moy_gen(self, moys: pd.DataFrame, coeffs: pd.DataFrame) -> pd.Series:
|
||||
"""Calcule la moyenne générale (toutes UE/compétences confondus)
|
||||
pour le tag considéré, en pondérant les notes obtenues au UE
|
||||
par les coeff (généralement les crédits ECTS).
|
||||
|
||||
Args:
|
||||
moys: Les moyennes etudids x acronymes_ues/compétences
|
||||
coeff: Les coeff etudids x ueids/compétences
|
||||
"""
|
||||
|
||||
# Calcule la moyenne générale dans le semestre (pondérée par le ECTS)
|
||||
try:
|
||||
moy_gen_tag = comp.moy_sem.compute_sem_moys_apc_using_ects(
|
||||
moys,
|
||||
coeffs.fillna(0.0),
|
||||
# formation_id=self.formsemestre.formation_id,
|
||||
skip_empty_ues=True,
|
||||
)
|
||||
except TypeError as e:
|
||||
raise TypeError(
|
||||
"Pb dans le calcul de la moyenne toutes UEs/compétences confondues"
|
||||
)
|
||||
|
||||
return moy_gen_tag
|
||||
|
||||
def to_df(
|
||||
self, aggregat=None, cohorte=None, options={"min_max_moy": True}
|
||||
) -> pd.DataFrame:
|
||||
"""Renvoie le df synthétisant l'ensemble des données
|
||||
connues
|
||||
Adapte les intitulés des colonnes aux données fournies
|
||||
(nom d'aggrégat, type de cohorte).
|
||||
"""
|
||||
if "min_max_moy" not in options or options["min_max_moy"]:
|
||||
with_min_max_moy = True
|
||||
else:
|
||||
with_min_max_moy = False
|
||||
|
||||
etudids_sorted = sorted(self.etudids)
|
||||
|
||||
df = pd.DataFrame(index=etudids_sorted)
|
||||
|
||||
# Ajout des notes pour tous les champs
|
||||
champs = list(self.champs)
|
||||
for champ in champs:
|
||||
df_champ = self.moyennes_gen[champ].get_df_synthese(
|
||||
with_min_max_moy=with_min_max_moy
|
||||
) # le dataframe
|
||||
# Renomme les colonnes
|
||||
|
||||
cols = [
|
||||
get_colonne_df(aggregat, self.tag, champ, cohorte, critere)
|
||||
for critere in pe_moy.Moyenne.get_colonnes_synthese(
|
||||
with_min_max_moy=with_min_max_moy
|
||||
)
|
||||
]
|
||||
df_champ.columns = cols
|
||||
df = df.join(df_champ)
|
||||
|
||||
# Ajoute la moy générale
|
||||
df_moy_gen = self.moyenne_gen.get_df_synthese(with_min_max_moy=with_min_max_moy)
|
||||
cols = [
|
||||
get_colonne_df(aggregat, self.tag, CHAMP_GENERAL, cohorte, critere)
|
||||
for critere in pe_moy.Moyenne.get_colonnes_synthese(
|
||||
with_min_max_moy=with_min_max_moy
|
||||
)
|
||||
]
|
||||
df_moy_gen.columns = cols
|
||||
df = df.join(df_moy_gen)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def get_colonne_df(aggregat, tag, champ, cohorte, critere):
|
||||
"""Renvoie le tuple (aggregat, tag, champ, cohorte, critere)
|
||||
utilisé pour désigner les colonnes du df"""
|
||||
liste_champs = []
|
||||
if aggregat != None:
|
||||
liste_champs += [aggregat]
|
||||
|
||||
liste_champs += [tag, champ]
|
||||
if cohorte != None:
|
||||
liste_champs += [cohorte]
|
||||
liste_champs += [critere]
|
||||
return "|".join(liste_champs)
|
|
@ -0,0 +1,466 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Fri Sep 9 09:15:05 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
from app.models import FormSemestre
|
||||
from app.pe import pe_affichage
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from app.pe.rcss import pe_rcs, pe_rcsemx
|
||||
import app.pe.moys.pe_sxtag as pe_sxtag
|
||||
import app.pe.pe_comp as pe_comp
|
||||
from app.pe.moys import pe_tabletags, pe_moytag
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
class RCSemXTag(pe_tabletags.TableTag):
|
||||
def __init__(
|
||||
self,
|
||||
rcsemx: pe_rcsemx.RCSemX,
|
||||
sxstags: dict[(str, int) : pe_sxtag.SxTag],
|
||||
semXs_suivis: dict[int, dict],
|
||||
):
|
||||
"""Calcule les moyennes par tag (orientées compétences)
|
||||
d'un regroupement de SxTag
|
||||
(RCRCF), pour extraire les classements par tag pour un
|
||||
groupe d'étudiants donnés. Le groupe d'étudiants est formé par ceux ayant tous
|
||||
participé au même semestre terminal.
|
||||
|
||||
Args:
|
||||
rcsemx: Le RCSemX (identifié par un nom et l'id de son semestre terminal)
|
||||
sxstags: Les données sur les SemX taggués
|
||||
semXs_suivis: Les données indiquant quels SXTags sont à prendre en compte
|
||||
pour chaque étudiant
|
||||
"""
|
||||
pe_tabletags.TableTag.__init__(self)
|
||||
|
||||
self.rcs_id: tuple(str, int) = rcsemx.rcs_id
|
||||
"""Identifiant du RCSemXTag (identique au RCSemX sur lequel il s'appuie)"""
|
||||
|
||||
self.rcsemx: pe_rcsemx.RCSemX = rcsemx
|
||||
"""Le regroupement RCSemX associé au RCSemXTag"""
|
||||
|
||||
self.semXs_suivis = semXs_suivis
|
||||
"""Les semXs suivis par les étudiants"""
|
||||
|
||||
self.nom = self.get_repr()
|
||||
"""Représentation textuelle du RSCtag"""
|
||||
|
||||
# Les données du semestre final
|
||||
self.formsemestre_final: FormSemestre = rcsemx.formsemestre_final
|
||||
"""Le semestre final"""
|
||||
self.fid_final: int = rcsemx.formsemestre_final.formsemestre_id
|
||||
"""Le fid du semestre final"""
|
||||
|
||||
# Affichage pour debug
|
||||
pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}")
|
||||
|
||||
# Les données aggrégés (RCRCF + SxTags)
|
||||
self.semXs_aggreges: dict[(str, int) : pe_rcsemx.RCSemX] = rcsemx.semXs_aggreges
|
||||
"""Les SemX aggrégés"""
|
||||
self.sxstags_aggreges = {}
|
||||
"""Les SxTag associés aux SemX aggrégés"""
|
||||
try:
|
||||
for rcf_id in self.semXs_aggreges:
|
||||
self.sxstags_aggreges[rcf_id] = sxstags[rcf_id]
|
||||
except:
|
||||
raise ValueError("Semestres SxTag manquants")
|
||||
self.sxtags_connus = sxstags # Tous les sxstags connus
|
||||
|
||||
# Les étudiants (etuds, états civils & etudis)
|
||||
sems_dans_aggregat = rcsemx.aggregat
|
||||
sxtag_final = self.sxstags_aggreges[(sems_dans_aggregat[-1], self.rcs_id[1])]
|
||||
self.etuds = sxtag_final.etuds
|
||||
"""Les étudiants (extraits du semestre final)"""
|
||||
self.add_etuds(self.etuds)
|
||||
self.etudids_sorted = sorted(self.etudids)
|
||||
"""Les étudids triés"""
|
||||
|
||||
# Les compétences (extraites de tous les Sxtags)
|
||||
self.acronymes_ues_to_competences = self._do_acronymes_to_competences()
|
||||
"""L'association acronyme d'UEs -> compétence (extraites des SxTag aggrégés)"""
|
||||
|
||||
self.competences_sorted = sorted(
|
||||
set(self.acronymes_ues_to_competences.values())
|
||||
)
|
||||
"""Compétences (triées par nom, extraites des SxTag aggrégés)"""
|
||||
aff = pe_affichage.repr_comp_et_ues(self.acronymes_ues_to_competences)
|
||||
pe_affichage.pe_print(f"--> Compétences : {', '.join(self.competences_sorted)}")
|
||||
|
||||
# Les tags
|
||||
self.tags_sorted = self._do_taglist()
|
||||
"""Tags extraits de tous les SxTag aggrégés"""
|
||||
aff_tag = ["👜" + tag for tag in self.tags_sorted]
|
||||
pe_affichage.pe_print(f"--> Tags : {', '.join(aff_tag)}")
|
||||
|
||||
# Les moyennes
|
||||
self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {}
|
||||
|
||||
"""Synthétise les moyennes/classements par tag (qu'ils soient personnalisé ou de compétences)"""
|
||||
for tag in self.tags_sorted:
|
||||
pe_affichage.pe_print(f"--> Moyennes du tag 👜{tag}")
|
||||
|
||||
# Traitement des inscriptions aux semX(tags)
|
||||
# ******************************************
|
||||
# Cube d'inscription (etudids_sorted x compétences_sorted x sxstags)
|
||||
# indiquant quel sxtag est valide pour chaque étudiant
|
||||
inscr_df, inscr_cube = self.compute_inscriptions_comps_cube(tag)
|
||||
|
||||
# Traitement des notes
|
||||
# ********************
|
||||
# Cube de notes (etudids_sorted x compétences_sorted x sxstags)
|
||||
notes_df, notes_cube = self.compute_notes_comps_cube(tag)
|
||||
# Calcule les moyennes sous forme d'un dataframe en les "aggrégant"
|
||||
# compétence par compétence
|
||||
moys_competences = self.compute_notes_competences(notes_cube, inscr_cube)
|
||||
|
||||
# Traitement des coeffs pour la moyenne générale
|
||||
# ***********************************************
|
||||
# Df des coeffs sur tous les SxTags aggrégés
|
||||
coeffs_df, coeffs_cube = self.compute_coeffs_comps_cube(tag)
|
||||
|
||||
# Synthèse des coefficients à prendre en compte pour la moyenne générale
|
||||
matrice_coeffs_moy_gen = self.compute_coeffs_competences(
|
||||
coeffs_cube, inscr_cube, notes_cube
|
||||
)
|
||||
|
||||
# Affichage des coeffs
|
||||
aff = pe_affichage.repr_profil_coeffs(
|
||||
matrice_coeffs_moy_gen, with_index=True
|
||||
)
|
||||
pe_affichage.pe_print(f" > Moyenne calculée avec pour coeffs : {aff}")
|
||||
|
||||
# Mémorise les moyennes et les coeff associés
|
||||
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
|
||||
tag,
|
||||
pe_moytag.CODE_MOY_COMPETENCES,
|
||||
moys_competences,
|
||||
matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Egalité de 2 RCS taggués sur la base de leur identifiant"""
|
||||
return self.rcs_id == other.sxtag_id
|
||||
|
||||
def get_repr(self, verbose=True) -> str:
|
||||
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
|
||||
est basée)"""
|
||||
if verbose:
|
||||
return f"{self.__class__.__name__} basé sur " + self.rcsemx.get_repr(
|
||||
verbose=verbose
|
||||
)
|
||||
else:
|
||||
return f"{self.__class__.__name__} {self.rcs_id}"
|
||||
|
||||
def compute_notes_comps_cube(self, tag):
|
||||
"""Pour un tag donné, construit le cube de notes (etudid x competences x SxTag)
|
||||
nécessaire au calcul des moyennes,
|
||||
en remplaçant les données d'UE (obtenus du SxTag) par les compétences
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
"""
|
||||
# etudids_sorted: list[int],
|
||||
# competences_sorted: list[str],
|
||||
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
|
||||
notes_dfs = {}
|
||||
|
||||
for sxtag_id, sxtag in self.sxstags_aggreges.items():
|
||||
# Partant d'un dataframe vierge
|
||||
notes_df = pd.DataFrame(
|
||||
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
|
||||
)
|
||||
# Charge les notes du semestre tag (copie car changement de nom de colonnes à venir)
|
||||
if tag in sxtag.moyennes_tags: # si le tag est présent dans le semestre
|
||||
moys_tag = sxtag.moyennes_tags[tag]
|
||||
|
||||
notes = moys_tag.matrice_notes_gen.copy() # dataframe etudids x ues
|
||||
|
||||
# Traduction des acronymes d'UE en compétences
|
||||
acronymes_ues_columns = notes.columns
|
||||
acronymes_to_comps = [
|
||||
self.acronymes_ues_to_competences[acro]
|
||||
for acro in acronymes_ues_columns
|
||||
]
|
||||
notes.columns = acronymes_to_comps
|
||||
|
||||
# Les étudiants et les compétences communes
|
||||
(
|
||||
etudids_communs,
|
||||
comp_communes,
|
||||
) = pe_comp.find_index_and_columns_communs(notes_df, notes)
|
||||
|
||||
# Recopie des notes et des coeffs
|
||||
notes_df.loc[etudids_communs, comp_communes] = notes.loc[
|
||||
etudids_communs, comp_communes
|
||||
]
|
||||
|
||||
# Supprime tout ce qui n'est pas numérique
|
||||
# for col in notes_df.columns:
|
||||
# notes_df[col] = pd.to_numeric(notes_df[col], errors="coerce")
|
||||
|
||||
# Stocke les dfs
|
||||
notes_dfs[sxtag_id] = notes_df
|
||||
|
||||
"""Réunit les notes sous forme d'un cube etudids x competences x semestres"""
|
||||
sxtag_x_etudids_x_comps = [
|
||||
notes_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
|
||||
]
|
||||
notes_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1)
|
||||
|
||||
return notes_dfs, notes_etudids_x_comps_x_sxtag
|
||||
|
||||
def compute_coeffs_comps_cube(self, tag):
|
||||
"""Pour un tag donné, construit
|
||||
le cube de coeffs (etudid x competences x SxTag) (traduisant les inscriptions
|
||||
des étudiants aux UEs en fonction de leur parcours)
|
||||
qui s'applique aux différents SxTag
|
||||
en remplaçant les données d'UE (obtenus du SxTag) par les compétences
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
"""
|
||||
# etudids_sorted: list[int],
|
||||
# competences_sorted: list[str],
|
||||
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
|
||||
|
||||
coeffs_dfs = {}
|
||||
|
||||
for sxtag_id, sxtag in self.sxstags_aggreges.items():
|
||||
# Partant d'un dataframe vierge
|
||||
coeffs_df = pd.DataFrame(
|
||||
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
|
||||
)
|
||||
if tag in sxtag.moyennes_tags:
|
||||
moys_tag = sxtag.moyennes_tags[tag]
|
||||
|
||||
# Charge les notes et les coeffs du semestre tag
|
||||
coeffs = moys_tag.matrice_coeffs_moy_gen.copy() # les coeffs
|
||||
|
||||
# Traduction des acronymes d'UE en compétences
|
||||
acronymes_ues_columns = coeffs.columns
|
||||
acronymes_to_comps = [
|
||||
self.acronymes_ues_to_competences[acro]
|
||||
for acro in acronymes_ues_columns
|
||||
]
|
||||
coeffs.columns = acronymes_to_comps
|
||||
|
||||
# Les étudiants et les compétences communes
|
||||
etudids_communs, comp_communes = pe_comp.find_index_and_columns_communs(
|
||||
coeffs_df, coeffs
|
||||
)
|
||||
|
||||
# Recopie des notes et des coeffs
|
||||
coeffs_df.loc[etudids_communs, comp_communes] = coeffs.loc[
|
||||
etudids_communs, comp_communes
|
||||
]
|
||||
|
||||
# Stocke les dfs
|
||||
coeffs_dfs[sxtag_id] = coeffs_df
|
||||
|
||||
"""Réunit les coeffs sous forme d'un cube etudids x competences x semestres"""
|
||||
sxtag_x_etudids_x_comps = [
|
||||
coeffs_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
|
||||
]
|
||||
coeffs_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1)
|
||||
|
||||
return coeffs_dfs, coeffs_etudids_x_comps_x_sxtag
|
||||
|
||||
def compute_inscriptions_comps_cube(
|
||||
self,
|
||||
tag,
|
||||
):
|
||||
"""Pour un tag donné, construit
|
||||
le cube etudid x competences x SxTag traduisant quels sxtags est à prendre
|
||||
en compte pour chaque étudiant.
|
||||
Contient des 0 et des 1 pour indiquer la prise en compte.
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
"""
|
||||
# etudids_sorted: list[int],
|
||||
# competences_sorted: list[str],
|
||||
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
|
||||
# Initialisation
|
||||
inscriptions_dfs = {}
|
||||
|
||||
for sxtag_id, sxtag in self.sxstags_aggreges.items():
|
||||
# Partant d'un dataframe vierge
|
||||
inscription_df = pd.DataFrame(
|
||||
0, index=self.etudids_sorted, columns=self.competences_sorted
|
||||
)
|
||||
|
||||
# Les étudiants dont les résultats au sxtag ont été calculés
|
||||
etudids_sxtag = sxtag.etudids_sorted
|
||||
|
||||
# Les étudiants communs
|
||||
etudids_communs = sorted(set(self.etudids_sorted) & set(etudids_sxtag))
|
||||
|
||||
# Acte l'inscription
|
||||
inscription_df.loc[etudids_communs, :] = 1
|
||||
|
||||
# Stocke les dfs
|
||||
inscriptions_dfs[sxtag_id] = inscription_df
|
||||
|
||||
"""Réunit les inscriptions sous forme d'un cube etudids x competences x semestres"""
|
||||
sxtag_x_etudids_x_comps = [
|
||||
inscriptions_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
|
||||
]
|
||||
inscriptions_etudids_x_comps_x_sxtag = np.stack(
|
||||
sxtag_x_etudids_x_comps, axis=-1
|
||||
)
|
||||
|
||||
return inscriptions_dfs, inscriptions_etudids_x_comps_x_sxtag
|
||||
|
||||
def _do_taglist(self) -> list[str]:
|
||||
"""Synthétise les tags à partir des Sxtags aggrégés.
|
||||
|
||||
Returns:
|
||||
Liste de tags triés par ordre alphabétique
|
||||
"""
|
||||
tags = []
|
||||
for frmsem_id in self.sxstags_aggreges:
|
||||
tags.extend(self.sxstags_aggreges[frmsem_id].tags_sorted)
|
||||
return sorted(set(tags))
|
||||
|
||||
def _do_acronymes_to_competences(self) -> dict[str:str]:
|
||||
"""Synthétise l'association complète {acronyme_ue: competences}
|
||||
extraite de toutes les données/associations des SxTags
|
||||
aggrégés.
|
||||
|
||||
Returns:
|
||||
Un dictionnaire {'acronyme_ue' : 'compétences'}
|
||||
"""
|
||||
dict_competences = {}
|
||||
for sxtag_id, sxtag in self.sxstags_aggreges.items():
|
||||
dict_competences |= sxtag.acronymes_ues_to_competences
|
||||
return dict_competences
|
||||
|
||||
def compute_notes_competences(self, set_cube: np.array, inscriptions: np.array):
|
||||
"""Calcule la moyenne par compétences (à un tag donné) sur plusieurs semestres (partant du set_cube).
|
||||
|
||||
La moyenne est un nombre (note/20), ou NaN si pas de notes disponibles
|
||||
|
||||
*Remarque* : Adaptation de moy_ue.compute_ue_moys_apc au cas des moyennes de tag
|
||||
par aggrégat de plusieurs semestres.
|
||||
|
||||
Args:
|
||||
set_cube: notes moyennes aux compétences ndarray
|
||||
(etuds x UEs|compétences x sxtags), des floats avec des NaN
|
||||
inscriptions: inscrptions aux compétences ndarray
|
||||
(etuds x UEs|compétences x sxtags), des 0 et des 1
|
||||
Returns:
|
||||
Un DataFrame avec pour columns les moyennes par tags,
|
||||
et pour rows les etudid
|
||||
"""
|
||||
# etudids_sorted: liste des étudiants (dim. 0 du cube)
|
||||
# competences_sorted: list (dim. 1 du cube)
|
||||
nb_etuds, nb_comps, nb_semestres = set_cube.shape
|
||||
# assert nb_etuds == len(etudids_sorted)
|
||||
# assert nb_comps == len(competences_sorted)
|
||||
|
||||
# Applique le masque d'inscriptions
|
||||
set_cube_significatif = set_cube * inscriptions
|
||||
|
||||
# Quelles entrées du cube contiennent des notes ?
|
||||
mask = ~np.isnan(set_cube_significatif)
|
||||
|
||||
# Enlève les NaN du cube de notes pour les entrées manquantes
|
||||
set_cube_no_nan = np.nan_to_num(set_cube_significatif, nan=0.0)
|
||||
|
||||
# Les moyennes par tag
|
||||
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
|
||||
etud_moy_tag = np.sum(set_cube_no_nan, axis=2) / np.sum(mask, axis=2)
|
||||
|
||||
# Le dataFrame des notes moyennes
|
||||
etud_moy_tag_df = pd.DataFrame(
|
||||
etud_moy_tag,
|
||||
index=self.etudids_sorted, # les etudids
|
||||
columns=self.competences_sorted, # les competences
|
||||
)
|
||||
etud_moy_tag_df.fillna(np.nan)
|
||||
|
||||
return etud_moy_tag_df
|
||||
|
||||
def compute_coeffs_competences(
|
||||
self,
|
||||
coeff_cube: np.array,
|
||||
inscriptions: np.array,
|
||||
set_cube: np.array,
|
||||
):
|
||||
"""Calcule les coeffs à utiliser pour la moyenne générale (toutes compétences
|
||||
confondues), en fonction des inscriptions.
|
||||
|
||||
Args:
|
||||
coeffs_cube: coeffs impliqués dans la moyenne générale (semestres par semestres)
|
||||
inscriptions: inscriptions aux UES|Compétences ndarray
|
||||
(etuds x UEs|compétences x sxtags), des 0 ou des 1
|
||||
set_cube: les notes
|
||||
|
||||
|
||||
Returns:
|
||||
Un DataFrame de coefficients (etudids_sorted x compétences_sorted)
|
||||
"""
|
||||
# etudids_sorted: liste des étudiants (dim. 0 du cube)
|
||||
# competences_sorted: list (dim. 1 du cube)
|
||||
nb_etuds, nb_comps, nb_semestres = inscriptions.shape
|
||||
# assert nb_etuds == len(etudids_sorted)
|
||||
# assert nb_comps == len(competences_sorted)
|
||||
|
||||
# Applique le masque des inscriptions aux coeffs et aux notes
|
||||
coeffs_significatifs = coeff_cube * inscriptions
|
||||
|
||||
# Enlève les NaN du cube de notes pour les entrées manquantes
|
||||
coeffs_cube_no_nan = np.nan_to_num(coeffs_significatifs, nan=0.0)
|
||||
|
||||
# Quelles entrées du cube contiennent des notes ?
|
||||
mask = ~np.isnan(set_cube)
|
||||
|
||||
# Retire les coefficients associés à des données sans notes
|
||||
coeffs_cube_no_nan = coeffs_cube_no_nan * mask
|
||||
|
||||
# Somme les coefficients (correspondant à des notes)
|
||||
coeff_tag = np.sum(coeffs_cube_no_nan, axis=2)
|
||||
|
||||
# Le dataFrame des coeffs
|
||||
coeffs_df = pd.DataFrame(
|
||||
coeff_tag, index=self.etudids_sorted, columns=self.competences_sorted
|
||||
)
|
||||
# Remet à Nan les coeffs à 0
|
||||
coeffs_df = coeffs_df.fillna(np.nan)
|
||||
|
||||
return coeffs_df
|
|
@ -0,0 +1,476 @@
|
|||
# -*- pole: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Generfal Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Fri Sep 9 09:15:05 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
from app import ScoValueError
|
||||
from app import comp
|
||||
from app.comp.res_but import ResultatsSemestreBUT
|
||||
from app.models import FormSemestre, UniteEns
|
||||
import app.pe.pe_affichage as pe_affichage
|
||||
import app.pe.pe_etudiant as pe_etudiant
|
||||
from app.pe.moys import pe_tabletags, pe_moytag
|
||||
from app.scodoc import sco_tag_module
|
||||
from app.scodoc import codes_cursus as sco_codes
|
||||
from app.scodoc.sco_utils import *
|
||||
|
||||
|
||||
class ResSemBUTTag(ResultatsSemestreBUT, pe_tabletags.TableTag):
|
||||
"""
|
||||
Un ResSemBUTTag représente les résultats des étudiants à un semestre, en donnant
|
||||
accès aux moyennes par tag.
|
||||
Il s'appuie principalement sur un ResultatsSemestreBUT.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
formsemestre: FormSemestre,
|
||||
options={"moyennes_tags": True, "moyennes_ue_res_sae": False},
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
formsemestre: le ``FormSemestre`` sur lequel il se base
|
||||
options: Un dictionnaire d'options
|
||||
"""
|
||||
ResultatsSemestreBUT.__init__(self, formsemestre)
|
||||
pe_tabletags.TableTag.__init__(self)
|
||||
|
||||
# Le nom du res_semestre taggué
|
||||
self.nom = self.get_repr(verbose=True)
|
||||
|
||||
# Les étudiants (etuds, états civils & etudis) ajouté
|
||||
self.add_etuds(self.etuds)
|
||||
self.etudids_sorted = sorted(self.etudids)
|
||||
"""Les etudids des étudiants du ResultatsSemestreBUT triés"""
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"*** ResSemBUTTag du {self.nom} => {len(self.etudids_sorted)} étudiants"
|
||||
)
|
||||
|
||||
# Les UEs (et les dispenses d'UE)
|
||||
self.ues_standards: list[UniteEns] = [
|
||||
ue for ue in self.ues if ue.type == sco_codes.UE_STANDARD
|
||||
]
|
||||
"""Liste des UEs standards du ResultatsSemestreBUT"""
|
||||
|
||||
# Les parcours des étudiants à ce semestre
|
||||
self.parcours = []
|
||||
"""Parcours auxquels sont inscrits les étudiants"""
|
||||
for etudid in self.etudids_sorted:
|
||||
parcour = self.formsemestre.etuds_inscriptions[etudid].parcour
|
||||
if parcour:
|
||||
self.parcours += [parcour.libelle]
|
||||
else:
|
||||
self.parcours += [None]
|
||||
|
||||
# Les UEs en fonction des parcours
|
||||
self.ues_inscr_parcours_df = self.load_ues_inscr_parcours()
|
||||
"""Inscription des étudiants aux UEs des parcours"""
|
||||
|
||||
# Les acronymes des UEs
|
||||
self.ues_to_acronymes = {ue.id: ue.acronyme for ue in self.ues_standards}
|
||||
self.acronymes_sorted = sorted(self.ues_to_acronymes.values())
|
||||
"""Les acronymes de UE triés par ordre alphabétique"""
|
||||
|
||||
# Les compétences associées aux UEs (définies par les acronymes)
|
||||
self.acronymes_ues_to_competences = {}
|
||||
"""Association acronyme d'UEs -> compétence"""
|
||||
for ue in self.ues_standards:
|
||||
assert ue.niveau_competence, ScoValueError(
|
||||
"Des UEs ne sont pas rattachées à des compétences"
|
||||
)
|
||||
nom = ue.niveau_competence.competence.titre
|
||||
self.acronymes_ues_to_competences[ue.acronyme] = nom
|
||||
self.competences_sorted = sorted(
|
||||
list(set(self.acronymes_ues_to_competences.values()))
|
||||
)
|
||||
"""Compétences triées par nom"""
|
||||
aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences)
|
||||
pe_affichage.pe_print(f"--> UEs/Compétences : {aff}")
|
||||
|
||||
# Les tags personnalisés et auto:
|
||||
if "moyennes_tags" in options:
|
||||
tags_dict = self._get_tags_dict(avec_moyennes_tags=options["moyennes_tags"])
|
||||
else:
|
||||
tags_dict = self._get_tags_dict()
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"""--> {pe_affichage.aff_tags_par_categories(tags_dict)}"""
|
||||
)
|
||||
self._check_tags(tags_dict)
|
||||
|
||||
# Les coefficients pour le calcul de la moyenne générale, donnés par
|
||||
# acronymes d'UE
|
||||
self.matrice_coeffs_moy_gen = self._get_matrice_coeffs(
|
||||
self.ues_inscr_parcours_df, self.ues_standards
|
||||
)
|
||||
"""DataFrame indiquant les coeffs des UEs par ordre alphabétique d'acronyme"""
|
||||
profils_aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen)
|
||||
pe_affichage.pe_print(
|
||||
f"--> Moyenne générale calculée avec pour coeffs d'UEs : {profils_aff}"
|
||||
)
|
||||
|
||||
# Les capitalisations (mask etuids x acronyme_ue valant True si capitalisée, False sinon)
|
||||
self.capitalisations = self._get_capitalisations(self.ues_standards)
|
||||
"""DataFrame indiquant les UEs capitalisables d'un étudiant (etudids x )"""
|
||||
|
||||
# Calcul des moyennes & les classements de chaque étudiant à chaque tag
|
||||
self.moyennes_tags = {}
|
||||
"""Moyennes par tags (personnalisés ou 'but')"""
|
||||
for tag in tags_dict["personnalises"]:
|
||||
# pe_affichage.pe_print(f" -> Traitement du tag {tag}")
|
||||
info_tag = tags_dict["personnalises"][tag]
|
||||
# Les moyennes générales par UEs
|
||||
moy_ues_tag = self.compute_moy_ues_tag(
|
||||
self.ues_inscr_parcours_df, info_tag=info_tag, pole=None
|
||||
)
|
||||
# Mémorise les moyennes
|
||||
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
|
||||
tag,
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
moy_ues_tag,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Ajoute les moyennes par UEs + la moyenne générale (but)
|
||||
moy_gen = self.compute_moy_gen()
|
||||
self.moyennes_tags["but"] = pe_moytag.MoyennesTag(
|
||||
"but",
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
moy_gen,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Ajoute la moyenne générale par ressources
|
||||
if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]:
|
||||
moy_res_gen = self.compute_moy_ues_tag(
|
||||
self.ues_inscr_parcours_df, info_tag=None, pole=ModuleType.RESSOURCE
|
||||
)
|
||||
self.moyennes_tags["ressources"] = pe_moytag.MoyennesTag(
|
||||
"ressources",
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
moy_res_gen,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Ajoute la moyenne générale par saes
|
||||
if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]:
|
||||
moy_saes_gen = self.compute_moy_ues_tag(
|
||||
self.ues_inscr_parcours_df, info_tag=None, pole=ModuleType.SAE
|
||||
)
|
||||
self.moyennes_tags["saes"] = pe_moytag.MoyennesTag(
|
||||
"saes",
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
moy_saes_gen,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Tous les tags
|
||||
self.tags_sorted = self.get_all_significant_tags()
|
||||
"""Tags (personnalisés+compétences) par ordre alphabétique"""
|
||||
|
||||
def get_repr(self, verbose=False) -> str:
|
||||
"""Nom affiché pour le semestre taggué, de la forme (par ex.):
|
||||
|
||||
* S1#69 si verbose est False
|
||||
* S1 FI 2023 si verbose est True
|
||||
"""
|
||||
if not verbose:
|
||||
return f"{self.formsemestre}#{self.formsemestre.formsemestre_id}"
|
||||
else:
|
||||
return pe_etudiant.nom_semestre_etape(self.formsemestre, avec_fid=True)
|
||||
|
||||
def _get_matrice_coeffs(
|
||||
self, ues_inscr_parcours_df: pd.DataFrame, ues_standards: list[UniteEns]
|
||||
) -> pd.DataFrame:
|
||||
"""Renvoie un dataFrame donnant les coefficients à appliquer aux UEs
|
||||
dans le calcul de la moyenne générale (toutes UEs confondues).
|
||||
Prend en compte l'inscription des étudiants aux UEs en fonction de leur parcours
|
||||
(cf. ues_inscr_parcours_df).
|
||||
|
||||
Args:
|
||||
ues_inscr_parcours_df: Les inscriptions des étudiants aux UEs
|
||||
ues_standards: Les UEs standards à prendre en compte
|
||||
|
||||
Returns:
|
||||
Un dataFrame etudids x acronymes_UEs avec les coeffs des UEs
|
||||
"""
|
||||
matrice_coeffs_moy_gen = ues_inscr_parcours_df * [
|
||||
ue.ects for ue in ues_standards # if ue.type != UE_SPORT <= déjà supprimé
|
||||
]
|
||||
matrice_coeffs_moy_gen.columns = [
|
||||
self.ues_to_acronymes[ue.id] for ue in ues_standards
|
||||
]
|
||||
# Tri par etudids (dim 0) et par acronymes (dim 1)
|
||||
matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index()
|
||||
matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index(axis=1)
|
||||
return matrice_coeffs_moy_gen
|
||||
|
||||
def _get_capitalisations(self, ues_standards) -> pd.DataFrame:
|
||||
"""Renvoie un dataFrame résumant les UEs capitalisables par les
|
||||
étudiants, d'après les décisions de jury (sous réserve qu'elles existent).
|
||||
|
||||
Args:
|
||||
ues_standards: Liste des UEs standards (notamment autres que le sport)
|
||||
Returns:
|
||||
Un dataFrame etudids x acronymes_UEs dont les valeurs sont ``True`` si l'UE
|
||||
est capitalisable, ``False`` sinon
|
||||
"""
|
||||
capitalisations = pd.DataFrame(
|
||||
False, index=self.etudids_sorted, columns=self.acronymes_sorted
|
||||
)
|
||||
self.get_formsemestre_validations() # charge les validations
|
||||
res_jury = self.validations
|
||||
if res_jury:
|
||||
for etud in self.etuds:
|
||||
etudid = etud.etudid
|
||||
decisions = res_jury.decisions_jury_ues.get(etudid, {})
|
||||
for ue in ues_standards:
|
||||
if ue.id in decisions and decisions[ue.id]["code"] == sco_codes.ADM:
|
||||
capitalisations.loc[etudid, ue.acronyme] = True
|
||||
# Tri par etudis et par accronyme d'UE
|
||||
capitalisations = capitalisations.sort_index()
|
||||
capitalisations = capitalisations.sort_index(axis=1)
|
||||
return capitalisations
|
||||
|
||||
def compute_moy_ues_tag(
|
||||
self,
|
||||
ues_inscr_parcours_df: pd.DataFrame,
|
||||
info_tag: dict[int, dict] = None,
|
||||
pole=None,
|
||||
) -> pd.DataFrame:
|
||||
"""Calcule la moyenne par UE des étudiants pour un tag donné,
|
||||
en ayant connaissance des informations sur le tag et des inscriptions des étudiants aux différentes UEs.
|
||||
|
||||
info_tag détermine les modules pris en compte :
|
||||
* si non `None`, seuls les modules rattachés au tag sont pris en compte
|
||||
* si `None`, tous les modules (quelque soit leur rattachement au tag) sont pris
|
||||
en compte (sert au calcul de la moyenne générale par ressource ou SAE)
|
||||
|
||||
ues_inscr_parcours_df détermine les UEs pour lesquels le calcul d'une moyenne à un sens.
|
||||
|
||||
`pole` détermine les modules pris en compte :
|
||||
|
||||
* si `pole` vaut `ModuleType.RESSOURCE`, seules les ressources sont prises
|
||||
en compte (moyenne de ressources par UEs)
|
||||
* si `pole` vaut `ModuleType.SAE`, seules les SAEs sont prises en compte
|
||||
* si `pole` vaut `None` (ou toute autre valeur),
|
||||
tous les modules sont pris en compte (moyenne d'UEs)
|
||||
|
||||
Les informations sur le tag sont un dictionnaire listant les modimpl_id rattachés au tag,
|
||||
et pour chacun leur éventuel coefficient de **repondération**.
|
||||
|
||||
Args:
|
||||
ues_inscr_parcours_df: L'inscription aux UEs
|
||||
Returns:
|
||||
Le dataframe des moyennes du tag par UE
|
||||
"""
|
||||
modimpls_sorted = self.formsemestre.modimpls_sorted
|
||||
|
||||
# Adaptation du mask de calcul des moyennes au tag visé
|
||||
modimpls_mask = []
|
||||
for modimpl in modimpls_sorted:
|
||||
module = modimpl.module # Le module
|
||||
mask = module.ue.type == sco_codes.UE_STANDARD # Est-ce une UE stantard ?
|
||||
if pole == ModuleType.RESSOURCE:
|
||||
mask &= module.module_type == ModuleType.RESSOURCE
|
||||
elif pole == ModuleType.SAE:
|
||||
mask &= module.module_type == ModuleType.SAE
|
||||
modimpls_mask += [mask]
|
||||
|
||||
# Prise en compte du tag
|
||||
if info_tag:
|
||||
# Désactive tous les modules qui ne sont pas pris en compte pour ce tag
|
||||
for i, modimpl in enumerate(modimpls_sorted):
|
||||
if modimpl.moduleimpl_id not in info_tag:
|
||||
modimpls_mask[i] = False
|
||||
|
||||
# Applique la pondération des coefficients
|
||||
modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy()
|
||||
if info_tag:
|
||||
for modimpl_id in info_tag:
|
||||
ponderation = info_tag[modimpl_id]["ponderation"]
|
||||
modimpl_coefs_ponderes_df[modimpl_id] *= ponderation
|
||||
|
||||
# Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)
|
||||
moyennes_ues_tag = comp.moy_ue.compute_ue_moys_apc(
|
||||
self.sem_cube,
|
||||
self.etuds,
|
||||
self.formsemestre.modimpls_sorted,
|
||||
self.modimpl_inscr_df,
|
||||
modimpl_coefs_ponderes_df,
|
||||
modimpls_mask,
|
||||
self.dispense_ues,
|
||||
block=self.formsemestre.block_moyennes,
|
||||
)
|
||||
|
||||
# Ne conserve que les UEs standards
|
||||
colonnes = [ue.id for ue in self.ues_standards]
|
||||
moyennes_ues_tag = moyennes_ues_tag[colonnes]
|
||||
|
||||
# Applique le masque d'inscription aux UE pour ne conserver que les UE dans lequel l'étudiant est inscrit
|
||||
moyennes_ues_tag = moyennes_ues_tag[colonnes] * ues_inscr_parcours_df[colonnes]
|
||||
|
||||
# Transforme les UEs en acronyme
|
||||
acronymes = [self.ues_to_acronymes[ue.id] for ue in self.ues_standards]
|
||||
moyennes_ues_tag.columns = acronymes
|
||||
|
||||
# Tri par etudids et par ordre alphabétique d'acronyme
|
||||
moyennes_ues_tag = moyennes_ues_tag.sort_index()
|
||||
moyennes_ues_tag = moyennes_ues_tag.sort_index(axis=1)
|
||||
|
||||
return moyennes_ues_tag
|
||||
|
||||
def compute_moy_gen(self):
|
||||
"""Récupère les moyennes des UEs pour le calcul de la moyenne générale,
|
||||
en associant à chaque UE.id son acronyme (toutes UEs confondues)
|
||||
"""
|
||||
df_ues = pd.DataFrame(
|
||||
{ue.id: self.etud_moy_ue[ue.id] for ue in self.ues_standards},
|
||||
index=self.etudids,
|
||||
)
|
||||
# Transforme les UEs en acronyme
|
||||
colonnes = df_ues.columns
|
||||
acronymes = [self.ues_to_acronymes[col] for col in colonnes]
|
||||
df_ues.columns = acronymes
|
||||
|
||||
# Tri par ordre aphabétique de colonnes
|
||||
df_ues.sort_index(axis=1)
|
||||
|
||||
return df_ues
|
||||
|
||||
def _get_tags_dict(self, avec_moyennes_tags=True):
|
||||
"""Renvoie les tags personnalisés (déduits des modules du semestre)
|
||||
et les tags automatiques ('but'), et toutes leurs informations,
|
||||
dans un dictionnaire de la forme :
|
||||
|
||||
``{"personnalises": {tag: info_sur_le_tag},
|
||||
"auto": {tag: {}}``
|
||||
|
||||
Returns:
|
||||
Le dictionnaire structuré des tags ("personnalises" vs. "auto")
|
||||
"""
|
||||
dict_tags = {"personnalises": dict(), "auto": dict()}
|
||||
|
||||
if avec_moyennes_tags:
|
||||
# Les tags perso (seulement si l'option d'utiliser les tags perso est choisie)
|
||||
dict_tags["personnalises"] = get_synthese_tags_personnalises_semestre(
|
||||
self.formsemestre
|
||||
)
|
||||
|
||||
# Les tags automatiques
|
||||
# Déduit des compétences
|
||||
# dict_ues_competences = get_noms_competences_from_ues(self.nt.formsemestre)
|
||||
# noms_tags_comp = list(set(dict_ues_competences.values()))
|
||||
|
||||
# BUT
|
||||
dict_tags["auto"] = {"but": {}, "ressources": {}, "saes": {}}
|
||||
return dict_tags
|
||||
|
||||
def _check_tags(self, dict_tags):
|
||||
"""Vérifie l'unicité des tags"""
|
||||
noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys())))
|
||||
noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp
|
||||
noms_tags = noms_tags_perso + noms_tags_auto
|
||||
|
||||
intersection = list(set(noms_tags_perso) & set(noms_tags_auto))
|
||||
|
||||
if intersection:
|
||||
liste_intersection = "\n".join(
|
||||
[f"<li><code>{tag}</code></li>" for tag in intersection]
|
||||
)
|
||||
s = "s" if len(intersection) > 1 else ""
|
||||
message = f"""Erreur dans le module PE : Un des tags saisis dans votre
|
||||
programme de formation fait parti des tags réservés. En particulier,
|
||||
votre semestre <em>{self.formsemestre.titre_annee()}</em>
|
||||
contient le{s} tag{s} réservé{s} suivant :
|
||||
<ul>
|
||||
{liste_intersection}
|
||||
</ul>
|
||||
Modifiez votre programme de formation pour le{s} supprimer.
|
||||
Il{s} ser{'ont' if s else 'a'} automatiquement à vos documents de poursuites d'études.
|
||||
"""
|
||||
raise ScoValueError(message)
|
||||
|
||||
|
||||
def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre):
|
||||
"""Etant données les implémentations des modules du semestre (modimpls),
|
||||
synthétise les tags renseignés dans le programme pédagogique &
|
||||
associés aux modules du semestre,
|
||||
en les associant aux modimpls qui les concernent (modimpl_id) et
|
||||
au coeff de repondération fournie avec le tag (par défaut 1 si non indiquée)).
|
||||
|
||||
Le dictionnaire fournit est de la forme :
|
||||
|
||||
``{ tag : { modimplid: {"modimpl": ModImpl,
|
||||
"ponderation": coeff_de_reponderation}
|
||||
} }``
|
||||
|
||||
Args:
|
||||
formsemestre: Le formsemestre à la base de la recherche des tags
|
||||
|
||||
Return:
|
||||
Un dictionnaire décrivant les tags
|
||||
"""
|
||||
synthese_tags = {}
|
||||
|
||||
# Instance des modules du semestre
|
||||
modimpls = formsemestre.modimpls_sorted
|
||||
|
||||
for modimpl in modimpls:
|
||||
modimpl_id = modimpl.id
|
||||
|
||||
# Liste des tags pour le module concerné
|
||||
tags = sco_tag_module.module_tag_list(modimpl.module.id)
|
||||
|
||||
# Traitement des tags recensés, chacun pouvant étant de la forme
|
||||
# "mathématiques", "théorie", "pe:0", "maths:2"
|
||||
for tag in tags:
|
||||
# Extraction du nom du tag et du coeff de pondération
|
||||
(tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag)
|
||||
|
||||
# Ajout d'une clé pour le tag
|
||||
if tagname not in synthese_tags:
|
||||
synthese_tags[tagname] = {}
|
||||
|
||||
# Ajout du module (modimpl) au tagname considéré
|
||||
synthese_tags[tagname][modimpl_id] = {
|
||||
"modimpl": modimpl, # les données sur le module
|
||||
"ponderation": ponderation, # la pondération demandée pour le tag sur le module
|
||||
}
|
||||
|
||||
return synthese_tags
|
|
@ -0,0 +1,406 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Fri Sep 9 09:15:05 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
from app.pe import pe_affichage, pe_comp
|
||||
import app.pe.moys.pe_ressemtag as pe_ressemtag
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
from app.pe.moys import pe_moytag, pe_tabletags
|
||||
import app.pe.rcss.pe_trajectoires as pe_trajectoires
|
||||
from app.scodoc.sco_utils import ModuleType
|
||||
|
||||
|
||||
class SxTag(pe_tabletags.TableTag):
|
||||
def __init__(
|
||||
self,
|
||||
sxtag_id: (str, int),
|
||||
semx: pe_trajectoires.SemX,
|
||||
ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag],
|
||||
):
|
||||
"""Calcule les moyennes/classements par tag d'un semestre de type 'Sx'
|
||||
(par ex. 'S1', 'S2', ...) représentés par acronyme d'UE.
|
||||
|
||||
Il représente :
|
||||
|
||||
* pour les étudiants *non redoublants* : moyennes/classements
|
||||
du semestre suivi
|
||||
* pour les étudiants *redoublants* : une fusion des moyennes/classements
|
||||
dans les (2) 'Sx' qu'il a suivi, en exploitant les informations de capitalisation :
|
||||
meilleure moyenne entre l'UE capitalisée et l'UE refaite (la notion de meilleure
|
||||
s'appliquant à la moyenne d'UE)
|
||||
|
||||
Un SxTag (regroupant potentiellement plusieurs semestres) est identifié
|
||||
par un tuple ``(Sx, fid)`` où :
|
||||
|
||||
* ``x`` est le rang (semestre_id) du semestre
|
||||
* ``fid`` le formsemestre_id du semestre final (le plus récent) du regroupement.
|
||||
|
||||
Les **tags**, les **UE** et les inscriptions aux UEs (pour les étudiants)
|
||||
considérés sont uniquement ceux du semestre final.
|
||||
|
||||
Args:
|
||||
sxtag_id: L'identifiant de SxTag
|
||||
ressembuttags: Un dictionnaire de la forme `{fid: ResSemBUTTag(fid)}` donnant
|
||||
les semestres à regrouper et les résultats/moyennes par tag des
|
||||
semestres
|
||||
"""
|
||||
pe_tabletags.TableTag.__init__(self)
|
||||
|
||||
assert sxtag_id and len(sxtag_id) == 2 and sxtag_id[1] in ressembuttags
|
||||
|
||||
self.sxtag_id: (str, int) = sxtag_id
|
||||
"""Identifiant du SxTag de la forme (nom_Sx, fid_semestre_final)"""
|
||||
assert (
|
||||
len(self.sxtag_id) == 2
|
||||
and isinstance(self.sxtag_id[0], str)
|
||||
and isinstance(self.sxtag_id[1], int)
|
||||
), "Format de l'identifiant du SxTag non respecté"
|
||||
|
||||
self.agregat = sxtag_id[0]
|
||||
"""Nom de l'aggrégat du RCS"""
|
||||
|
||||
self.semx = semx
|
||||
"""Le SemX sur lequel il s'appuie"""
|
||||
assert semx.rcs_id == sxtag_id, "Problème de correspondance SxTag/SemX"
|
||||
|
||||
# Les resultats des semestres taggués à prendre en compte dans le SemX
|
||||
self.ressembuttags = {
|
||||
fid: ressembuttags[fid] for fid in semx.semestres_aggreges
|
||||
}
|
||||
"""Les ResSemBUTTags à regrouper dans le SxTag"""
|
||||
|
||||
# Les données du semestre final
|
||||
self.fid_final = sxtag_id[1]
|
||||
self.ressembuttag_final = ressembuttags[self.fid_final]
|
||||
"""Le ResSemBUTTag final"""
|
||||
|
||||
# Ajoute les etudids et les états civils
|
||||
self.etuds = self.ressembuttag_final.etuds
|
||||
"""Les étudiants (extraits du ReSemBUTTag final)"""
|
||||
self.add_etuds(self.etuds)
|
||||
self.etudids_sorted = sorted(self.etudids)
|
||||
"""Les etudids triés"""
|
||||
|
||||
# Affichage
|
||||
pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}")
|
||||
|
||||
# Les tags
|
||||
self.tags_sorted = self.ressembuttag_final.tags_sorted
|
||||
"""Tags (extraits du ReSemBUTTag final)"""
|
||||
aff_tag = pe_affichage.repr_tags(self.tags_sorted)
|
||||
pe_affichage.pe_print(f"--> Tags : {aff_tag}")
|
||||
|
||||
# Les UE données par leur acronyme
|
||||
self.acronymes_sorted = self.ressembuttag_final.acronymes_sorted
|
||||
"""Les acronymes des UEs (extraits du ResSemBUTTag final)"""
|
||||
|
||||
# L'association UE-compétences extraites du dernier semestre
|
||||
self.acronymes_ues_to_competences = (
|
||||
self.ressembuttag_final.acronymes_ues_to_competences
|
||||
)
|
||||
"""L'association acronyme d'UEs -> compétence"""
|
||||
self.competences_sorted = sorted(self.acronymes_ues_to_competences.values())
|
||||
"""Les compétences triées par nom"""
|
||||
|
||||
aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences)
|
||||
pe_affichage.pe_print(f"--> UEs/Compétences : {aff}")
|
||||
|
||||
# Les coeffs pour la moyenne générale (traduisant également l'inscription
|
||||
# des étudiants aux UEs) (etudids_sorted x acronymes_ues_sorted)
|
||||
self.matrice_coeffs_moy_gen = self.ressembuttag_final.matrice_coeffs_moy_gen
|
||||
"""La matrice des coeffs pour la moyenne générale"""
|
||||
aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen)
|
||||
pe_affichage.pe_print(
|
||||
f"--> Moyenne générale calculée avec pour coeffs d'UEs : {aff}"
|
||||
)
|
||||
|
||||
# Masque des inscriptions et des capitalisations
|
||||
self.masque_df = None
|
||||
"""Le DataFrame traduisant les capitalisations des différents semestres"""
|
||||
self.masque_df, masque_cube = compute_masques_capitalisation_cube(
|
||||
self.etudids_sorted,
|
||||
self.acronymes_sorted,
|
||||
self.ressembuttags,
|
||||
self.fid_final,
|
||||
)
|
||||
pe_affichage.aff_capitalisations(
|
||||
self.etuds,
|
||||
self.ressembuttags,
|
||||
self.fid_final,
|
||||
self.acronymes_sorted,
|
||||
self.masque_df,
|
||||
)
|
||||
|
||||
# Les moyennes par tag
|
||||
self.moyennes_tags: dict[str, pd.DataFrame] = {}
|
||||
"""Moyennes aux UEs (identifiées par leur acronyme) des différents tags"""
|
||||
|
||||
if self.tags_sorted:
|
||||
pe_affichage.pe_print("--> Calcul des moyennes par tags :")
|
||||
|
||||
for tag in self.tags_sorted:
|
||||
pe_affichage.pe_print(f" > MoyTag 👜{tag}")
|
||||
|
||||
# Masque des inscriptions aux UEs (extraits de la matrice de coefficients)
|
||||
inscr_mask: np.array = ~np.isnan(self.matrice_coeffs_moy_gen.to_numpy())
|
||||
|
||||
# Moyennes (tous modules confondus)
|
||||
if not self.has_notes_tag(tag):
|
||||
pe_affichage.pe_print(
|
||||
f" --> Semestre (final) actuellement sans notes"
|
||||
)
|
||||
matrice_moys_ues = pd.DataFrame(
|
||||
np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted
|
||||
)
|
||||
else:
|
||||
# Moyennes tous modules confondus
|
||||
### Cube de note etudids x UEs tous modules confondus
|
||||
notes_df_gen, notes_cube_gen = self.compute_notes_ues_cube(tag)
|
||||
|
||||
# DataFrame des moyennes (tous modules confondus)
|
||||
matrice_moys_ues = self.compute_notes_ues(
|
||||
notes_cube_gen, masque_cube, inscr_mask
|
||||
)
|
||||
|
||||
# Mémorise les infos pour la moyenne au tag
|
||||
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
|
||||
tag,
|
||||
pe_moytag.CODE_MOY_UE,
|
||||
matrice_moys_ues,
|
||||
self.matrice_coeffs_moy_gen,
|
||||
)
|
||||
|
||||
# Affichage de debug
|
||||
aff = pe_affichage.repr_profil_coeffs(
|
||||
self.matrice_coeffs_moy_gen, with_index=True
|
||||
)
|
||||
pe_affichage.pe_print(f" > Moyenne générale calculée avec : {aff}")
|
||||
|
||||
def has_notes_tag(self, tag):
|
||||
"""Détermine si le SxTag, pour un tag donné, est en cours d'évaluation.
|
||||
Si oui, n'a pas (encore) de notes dans le resformsemestre final.
|
||||
|
||||
Args:
|
||||
tag: Le tag visé
|
||||
|
||||
Returns:
|
||||
True si a des notes, False sinon
|
||||
"""
|
||||
moy_tag_dernier_sem = self.ressembuttag_final.moyennes_tags[tag]
|
||||
return moy_tag_dernier_sem.has_notes()
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Egalité de 2 SxTag sur la base de leur identifiant"""
|
||||
return self.sxtag_id == other.sxtag_id
|
||||
|
||||
def get_repr(self, verbose=False) -> str:
|
||||
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
|
||||
est basée)"""
|
||||
if verbose:
|
||||
return f"SXTag basé sur {self.semx.get_repr()}"
|
||||
else:
|
||||
# affichage = [str(fid) for fid in self.ressembuttags]
|
||||
return f"SXTag {self.agregat}#{self.fid_final}"
|
||||
|
||||
def compute_notes_ues_cube(self, tag) -> (pd.DataFrame, np.array):
|
||||
"""Construit le cube de notes des UEs (etudid x accronyme_ue x semestre_aggregé)
|
||||
nécessaire au calcul des moyennes du tag pour le RCS Sx.
|
||||
(Renvoie également le dataframe associé pour debug).
|
||||
|
||||
Args:
|
||||
tag: Le tag considéré (personalisé ou "but")
|
||||
"""
|
||||
# Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2)
|
||||
# etudids_sorted = etudids_sorted
|
||||
# acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()])
|
||||
semestres_id = list(self.ressembuttags.keys())
|
||||
|
||||
dfs = {}
|
||||
|
||||
for frmsem_id in semestres_id:
|
||||
# Partant d'un dataframe vierge
|
||||
df = pd.DataFrame(
|
||||
np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted
|
||||
)
|
||||
|
||||
# Charge les notes du semestre tag
|
||||
sem_tag = self.ressembuttags[frmsem_id]
|
||||
moys_tag = sem_tag.moyennes_tags[tag]
|
||||
notes = moys_tag.matrice_notes_gen # dataframe etudids x ues
|
||||
|
||||
# les étudiants et les acronymes communs
|
||||
etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs(
|
||||
df, notes
|
||||
)
|
||||
|
||||
# Recopie
|
||||
df.loc[etudids_communs, acronymes_communs] = notes.loc[
|
||||
etudids_communs, acronymes_communs
|
||||
]
|
||||
|
||||
# Supprime tout ce qui n'est pas numérique
|
||||
for col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
# Stocke le df
|
||||
dfs[frmsem_id] = df
|
||||
|
||||
"""Réunit les notes sous forme d'un cube etudids x ues x semestres"""
|
||||
semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs]
|
||||
etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1)
|
||||
return dfs, etudids_x_ues_x_semestres
|
||||
|
||||
def compute_notes_ues(
|
||||
self,
|
||||
set_cube: np.array,
|
||||
masque_cube: np.array,
|
||||
inscr_mask: np.array,
|
||||
) -> pd.DataFrame:
|
||||
"""Calcule la moyenne par UEs à un tag donné en prenant la note maximum (UE
|
||||
par UE) obtenue par un étudiant à un semestre.
|
||||
|
||||
Args:
|
||||
set_cube: notes moyennes aux modules ndarray
|
||||
(semestre_ids x etudids x UEs), des floats avec des NaN
|
||||
masque_cube: masque indiquant si la note doit être prise en compte ndarray
|
||||
(semestre_ids x etudids x UEs), des 1.0 ou des 0.0
|
||||
inscr_mask: masque etudids x UE traduisant les inscriptions des
|
||||
étudiants aux UE (du semestre terminal)
|
||||
Returns:
|
||||
Un DataFrame avec pour columns les moyennes par ues,
|
||||
et pour rows les etudid
|
||||
"""
|
||||
# etudids_sorted: liste des étudiants (dim. 0 du cube) trié par etudid
|
||||
# acronymes_sorted: liste des acronymes des ues (dim. 1 du cube) trié par acronyme
|
||||
nb_etuds, nb_ues, nb_semestres = set_cube.shape
|
||||
nb_etuds_mask, nb_ues_mask = inscr_mask.shape
|
||||
# assert nb_etuds == len(self.etudids_sorted)
|
||||
# assert nb_ues == len(self.acronymes_sorted)
|
||||
# assert nb_etuds == nb_etuds_mask
|
||||
# assert nb_ues == nb_ues_mask
|
||||
|
||||
# Entrées à garder dans le cube en fonction du masque d'inscription aux UEs du parcours
|
||||
inscr_mask_3D = np.stack([inscr_mask] * nb_semestres, axis=-1)
|
||||
set_cube = set_cube * inscr_mask_3D
|
||||
|
||||
# Entrées à garder en fonction des UEs capitalisées ou non
|
||||
set_cube = set_cube * masque_cube
|
||||
|
||||
# Quelles entrées du cube contiennent des notes ?
|
||||
mask = ~np.isnan(set_cube)
|
||||
|
||||
# Enlève les NaN du cube pour les entrées manquantes : NaN -> -1.0
|
||||
set_cube_no_nan = np.nan_to_num(set_cube, nan=-1.0)
|
||||
|
||||
# Les moyennes par ues
|
||||
# TODO: Pour l'instant un max sans prise en compte des UE capitalisées
|
||||
etud_moy = np.max(set_cube_no_nan, axis=2)
|
||||
|
||||
# Fix les max non calculé -1 -> NaN
|
||||
etud_moy[etud_moy < 0] = np.NaN
|
||||
|
||||
# Le dataFrame
|
||||
etud_moy_tag_df = pd.DataFrame(
|
||||
etud_moy,
|
||||
index=self.etudids_sorted, # les etudids
|
||||
columns=self.acronymes_sorted, # les acronymes d'UEs
|
||||
)
|
||||
|
||||
etud_moy_tag_df = etud_moy_tag_df.fillna(np.nan)
|
||||
|
||||
return etud_moy_tag_df
|
||||
|
||||
|
||||
def compute_masques_capitalisation_cube(
|
||||
etudids_sorted: list[int],
|
||||
acronymes_sorted: list[str],
|
||||
ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag],
|
||||
formsemestre_id_final: int,
|
||||
) -> (pd.DataFrame, np.array):
|
||||
"""Construit le cube traduisant les masques des UEs à prendre en compte dans le calcul
|
||||
des moyennes, en utilisant le dataFrame de capitalisations de chaque ResSemBUTTag
|
||||
|
||||
Ces masques contiennent : 1 si la note doit être prise en compte, 0 sinon
|
||||
|
||||
Le masque des UEs à prendre en compte correspondant au semestre final (identifié par
|
||||
son formsemestre_id_final) est systématiquement à 1 (puisque les résultats
|
||||
de ce semestre doivent systématiquement
|
||||
être pris en compte notamment pour les étudiants non redoublant).
|
||||
|
||||
Args:
|
||||
etudids_sorted: La liste des etudids triés par ordre croissant (dim 0)
|
||||
acronymes_sorted: La liste des acronymes de UEs triés par acronyme croissant (dim 1)
|
||||
ressembuttags: Le dictionnaire des résultats de semestres BUT (tous tags confondus)
|
||||
formsemestre_id_final: L'identifiant du formsemestre_id_final
|
||||
"""
|
||||
# Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2)
|
||||
# etudids_sorted = etudids_sorted
|
||||
# acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()])
|
||||
semestres_id = list(ressembuttags.keys())
|
||||
|
||||
dfs = {}
|
||||
|
||||
for frmsem_id in semestres_id:
|
||||
# Partant d'un dataframe contenant des 1.0
|
||||
if frmsem_id == formsemestre_id_final:
|
||||
df = pd.DataFrame(1.0, index=etudids_sorted, columns=acronymes_sorted)
|
||||
else: # semestres redoublés
|
||||
df = pd.DataFrame(0.0, index=etudids_sorted, columns=acronymes_sorted)
|
||||
|
||||
# Traitement des capitalisations : remplace les infos de capitalisations par les coeff 1 ou 0
|
||||
capitalisations = ressembuttags[frmsem_id].capitalisations
|
||||
capitalisations = capitalisations.replace(True, 1.0).replace(False, 0.0)
|
||||
|
||||
# Met à 0 les coeffs des UEs non capitalisées pour les étudiants
|
||||
# inscrits dans les 2 semestres: 1.0*False => 0.0
|
||||
etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs(
|
||||
df, capitalisations
|
||||
)
|
||||
|
||||
df.loc[etudids_communs, acronymes_communs] = capitalisations.loc[
|
||||
etudids_communs, acronymes_communs
|
||||
]
|
||||
|
||||
# Stocke le df
|
||||
dfs[frmsem_id] = df
|
||||
|
||||
"""Réunit les notes sous forme d'un cube etudids x ues x semestres"""
|
||||
semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs]
|
||||
etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1)
|
||||
return dfs, etudids_x_ues_x_semestres
|
|
@ -0,0 +1,203 @@
|
|||
# -*- pole: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
|
||||
"""
|
||||
Created on Thu Sep 8 09:36:33 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.models import Identite
|
||||
from app.pe.moys import pe_moytag
|
||||
|
||||
TAGS_RESERVES = ["but"]
|
||||
|
||||
CHAMPS_ADMINISTRATIFS = ["Civilité", "Nom", "Prénom"]
|
||||
|
||||
|
||||
class TableTag(object):
|
||||
def __init__(self):
|
||||
"""Classe centralisant différentes méthodes communes aux
|
||||
SemestreTag, TrajectoireTag, AggregatInterclassTag
|
||||
"""
|
||||
# Les étudiants
|
||||
# self.etuds: list[Identite] = None # A venir
|
||||
"""Les étudiants"""
|
||||
# self.etudids: list[int] = {}
|
||||
"""Les etudids"""
|
||||
|
||||
def add_etuds(self, etuds: list[Identite]):
|
||||
"""Mémorise les informations sur les étudiants
|
||||
|
||||
Args:
|
||||
etuds: la liste des identités de l'étudiant
|
||||
"""
|
||||
# self.etuds = etuds
|
||||
self.etudids = list({etud.etudid for etud in etuds})
|
||||
|
||||
def get_all_significant_tags(self):
|
||||
"""Liste des tags de la table, triée par ordre alphabétique,
|
||||
extraite des clés du dictionnaire ``moyennes_tags``, en ne
|
||||
considérant que les moyennes ayant des notes.
|
||||
|
||||
Returns:
|
||||
Liste de tags triés par ordre alphabétique
|
||||
"""
|
||||
tags = []
|
||||
tag: str = ""
|
||||
moytag: pe_moytag.MoyennesTag = None
|
||||
for tag, moytag in self.moyennes_tags.items():
|
||||
if moytag.has_notes():
|
||||
tags.append(tag)
|
||||
return sorted(tags)
|
||||
|
||||
def to_df(
|
||||
self,
|
||||
administratif=True,
|
||||
aggregat=None,
|
||||
tags_cibles=None,
|
||||
cohorte=None,
|
||||
options={"min_max_moy": True},
|
||||
) -> pd.DataFrame:
|
||||
"""Renvoie un dataframe listant toutes les données
|
||||
des moyennes/classements/nb_inscrits/min/max/moy
|
||||
des étudiants aux différents tags.
|
||||
|
||||
tags_cibles limitent le dataframe aux tags indiqués
|
||||
type_colonnes indiquent si les colonnes doivent être passées en multiindex
|
||||
|
||||
Args:
|
||||
administratif: Indique si les données administratives sont incluses
|
||||
aggregat: l'aggrégat représenté
|
||||
tags_cibles: la liste des tags ciblés
|
||||
cohorte: la cohorte représentée
|
||||
Returns:
|
||||
Le dataframe complet de synthèse
|
||||
"""
|
||||
# Les tags visés
|
||||
tags_tries = self.get_all_significant_tags()
|
||||
if not tags_cibles:
|
||||
tags_cibles = tags_tries
|
||||
tags_cibles = sorted(tags_cibles)
|
||||
|
||||
# Les tags visés avec des notes
|
||||
|
||||
# Les étudiants visés
|
||||
if administratif:
|
||||
df = df_administratif(self.etuds, aggregat=aggregat, cohorte=cohorte)
|
||||
else:
|
||||
df = pd.DataFrame(index=self.etudids)
|
||||
|
||||
if not self.is_significatif():
|
||||
return df
|
||||
|
||||
# Ajout des données par tags
|
||||
for tag in tags_cibles:
|
||||
if tag in self.moyennes_tags:
|
||||
moy_tag_df = self.moyennes_tags[tag].to_df(
|
||||
aggregat=aggregat, cohorte=cohorte, options=options
|
||||
)
|
||||
df = df.join(moy_tag_df)
|
||||
|
||||
# Tri par nom, prénom
|
||||
if administratif:
|
||||
colonnes_tries = [
|
||||
_get_champ_administratif(champ, aggregat=aggregat, cohorte=cohorte)
|
||||
for champ in CHAMPS_ADMINISTRATIFS[1:]
|
||||
] # Nom + Prénom
|
||||
df = df.sort_values(by=colonnes_tries)
|
||||
return df
|
||||
|
||||
def has_etuds(self):
|
||||
"""Indique si un tabletag contient des étudiants"""
|
||||
return len(self.etuds) > 0
|
||||
|
||||
def is_significatif(self):
|
||||
"""Indique si une tabletag a des données"""
|
||||
# A des étudiants
|
||||
if not self.etuds:
|
||||
return False
|
||||
# A des tags avec des notes
|
||||
tags_tries = self.get_all_significant_tags()
|
||||
if not tags_tries:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _get_champ_administratif(champ, aggregat=None, cohorte=None):
|
||||
"""Pour un champ donné, renvoie l'index (ou le multindex)
|
||||
à intégrer au dataframe"""
|
||||
liste = []
|
||||
if aggregat != None:
|
||||
liste += [aggregat]
|
||||
liste += ["Administratif", "Identité"]
|
||||
if cohorte != None:
|
||||
liste += [champ]
|
||||
liste += [champ]
|
||||
return "|".join(liste)
|
||||
|
||||
|
||||
def df_administratif(
|
||||
etuds: list[Identite], aggregat=None, cohorte=None
|
||||
) -> pd.DataFrame:
|
||||
"""Renvoie un dataframe donnant les données administratives
|
||||
des étudiants du TableTag
|
||||
|
||||
Args:
|
||||
etuds: Identité des étudiants générant les données administratives
|
||||
"""
|
||||
identites = {etud.etudid: etud for etud in etuds}
|
||||
|
||||
donnees = {}
|
||||
etud: Identite = None
|
||||
for etudid, etud in identites.items():
|
||||
data = {
|
||||
CHAMPS_ADMINISTRATIFS[0]: etud.civilite_str,
|
||||
CHAMPS_ADMINISTRATIFS[1]: etud.nom,
|
||||
CHAMPS_ADMINISTRATIFS[2]: etud.prenom_str,
|
||||
}
|
||||
donnees[etudid] = {
|
||||
_get_champ_administratif(champ, aggregat, cohorte): data[champ]
|
||||
for champ in CHAMPS_ADMINISTRATIFS
|
||||
}
|
||||
|
||||
colonnes = [
|
||||
_get_champ_administratif(champ, aggregat, cohorte)
|
||||
for champ in CHAMPS_ADMINISTRATIFS
|
||||
]
|
||||
|
||||
df = pd.DataFrame.from_dict(donnees, orient="index", columns=colonnes)
|
||||
df = df.sort_values(by=colonnes[1:])
|
||||
return df
|
|
@ -1,68 +1,234 @@
|
|||
from app.models import Formation, FormSemestre
|
||||
from app.scodoc import codes_cursus
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""Affichages, debug
|
||||
"""
|
||||
|
||||
from flask import g
|
||||
from app import log
|
||||
from app.pe.rcss import pe_rcs
|
||||
|
||||
PE_DEBUG = False
|
||||
|
||||
|
||||
def nom_semestre_etape(semestre: FormSemestre, avec_fid=False) -> str:
|
||||
"""Nom d'un semestre à afficher dans le descriptif des étapes de la scolarité
|
||||
d'un étudiant.
|
||||
# On stocke les logs PE dans g.scodoc_pe_log
|
||||
# pour ne pas modifier les nombreux appels à pe_print.
|
||||
def pe_start_log() -> list[str]:
|
||||
"Initialize log"
|
||||
g.scodoc_pe_log = []
|
||||
return g.scodoc_pe_log
|
||||
|
||||
Par ex: Pour un S2, affiche ``"Semestre 2 FI S014-2015 (129)"`` avec :
|
||||
|
||||
* 2 le numéro du semestre,
|
||||
* FI la modalité,
|
||||
* 2014-2015 les dates
|
||||
def pe_print(*a, **cles):
|
||||
"Log (or print in PE_DEBUG mode) and store in g"
|
||||
if PE_DEBUG:
|
||||
msg = " ".join(a)
|
||||
print(msg)
|
||||
else:
|
||||
lines = getattr(g, "scodoc_pe_log")
|
||||
if lines is None:
|
||||
lines = pe_start_log()
|
||||
msg = " ".join(a)
|
||||
lines.append(msg)
|
||||
if "info" in cles:
|
||||
log(msg)
|
||||
|
||||
Args:
|
||||
semestre: Un ``FormSemestre``
|
||||
avec_fid: Ajoute le n° du semestre à la description
|
||||
|
||||
Returns:
|
||||
La chaine de caractères décrivant succintement le semestre
|
||||
def pe_get_log() -> str:
|
||||
"Renvoie une chaîne avec tous les messages loggués"
|
||||
return "\n".join(getattr(g, "scodoc_pe_log", []))
|
||||
|
||||
|
||||
# Affichage dans le tableur pe en cas d'absence de notes
|
||||
SANS_NOTE = "-"
|
||||
|
||||
|
||||
def repr_profil_coeffs(matrice_coeffs_moy_gen, with_index=False):
|
||||
"""Affiche les différents types de coefficients (appelés profil)
|
||||
d'une matrice_coeffs_moy_gen (pour debug)
|
||||
"""
|
||||
formation: Formation = semestre.formation
|
||||
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
|
||||
|
||||
description = [
|
||||
parcours.SESSION_NAME.capitalize(),
|
||||
str(semestre.semestre_id),
|
||||
semestre.modalite, # eg FI ou FC
|
||||
f"{semestre.date_debut.year}-{semestre.date_fin.year}",
|
||||
]
|
||||
if avec_fid:
|
||||
description.append(f"({semestre.forsemestre_id})")
|
||||
# Les profils des coeffs d'UE (pour debug)
|
||||
profils = []
|
||||
index_a_profils = {}
|
||||
for i in matrice_coeffs_moy_gen.index:
|
||||
val = matrice_coeffs_moy_gen.loc[i].fillna("-")
|
||||
val = " | ".join([str(v) for v in val])
|
||||
if val not in profils:
|
||||
profils += [val]
|
||||
index_a_profils[val] = [str(i)]
|
||||
else:
|
||||
index_a_profils[val] += [str(i)]
|
||||
|
||||
return " ".join(description)
|
||||
# L'affichage
|
||||
if len(profils) > 1:
|
||||
if with_index:
|
||||
elmts = [
|
||||
" " * 10
|
||||
+ prof
|
||||
+ " (par ex. "
|
||||
+ ", ".join(index_a_profils[prof][:10])
|
||||
+ ")"
|
||||
for prof in profils
|
||||
]
|
||||
else:
|
||||
elmts = [" " * 10 + prof for prof in profils]
|
||||
profils_aff = "\n" + "\n".join(elmts)
|
||||
else:
|
||||
profils_aff = "\n".join(profils)
|
||||
return profils_aff
|
||||
|
||||
|
||||
def etapes_du_cursus(semestres: dict[int, FormSemestre], nbre_etapes_max: int) -> list[str]:
|
||||
"""Partant d'un dictionnaire de semestres (qui retrace
|
||||
la scolarité d'un étudiant), liste les noms des
|
||||
semestres (en version abbrégée)
|
||||
qu'un étudiant a suivi au cours de sa scolarité à l'IUT.
|
||||
Les noms des semestres sont renvoyés dans un dictionnaire
|
||||
``{"etape i": nom_semestre_a_etape_i}``
|
||||
avec i variant jusqu'à nbre_semestres_max. (S'il n'y a pas de semestre à l'étape i,
|
||||
le nom affiché est vide.
|
||||
|
||||
La fonction suppose la liste des semestres triées par ordre
|
||||
décroissant de date.
|
||||
|
||||
Args:
|
||||
semestres: une liste de ``FormSemestre``
|
||||
nbre_etapes_max: le nombre d'étapes max prise en compte
|
||||
|
||||
Returns:
|
||||
Une liste de nom de semestre (dans le même ordre que les ``semestres``)
|
||||
|
||||
See also:
|
||||
app.pe.pe_affichage.nom_semestre_etape
|
||||
def repr_asso_ue_comp(acronymes_ues_to_competences):
|
||||
"""Représentation textuelle de l'association UE -> Compétences
|
||||
fournies dans acronymes_ues_to_competences
|
||||
"""
|
||||
assert len(semestres) <= nbre_etapes_max
|
||||
champs = acronymes_ues_to_competences.keys()
|
||||
champs = sorted(champs)
|
||||
aff_comp = []
|
||||
for acro in champs:
|
||||
aff_comp += [f"📍{acro} (∈ 💡{acronymes_ues_to_competences[acro]})"]
|
||||
return ", ".join(aff_comp)
|
||||
|
||||
noms = [nom_semestre_etape(sem, avec_fid=False) for (fid, sem) in semestres.items()]
|
||||
noms = noms[::-1] # trie par ordre croissant
|
||||
|
||||
dico = {f"Etape {i+1}": "" for i in range(nbre_etapes_max)}
|
||||
for (i, nom) in enumerate(noms): # Charge les noms de semestres
|
||||
dico[f"Etape {i+1}"] = nom
|
||||
return dico
|
||||
def aff_UEs(champs):
|
||||
"""Représentation textuelle des UEs fournies dans `champs`"""
|
||||
champs_tries = sorted(champs)
|
||||
aff_comp = []
|
||||
|
||||
for comp in champs_tries:
|
||||
aff_comp += ["📍" + comp]
|
||||
return ", ".join(aff_comp)
|
||||
|
||||
|
||||
def aff_competences(champs):
|
||||
"""Affiche les compétences"""
|
||||
champs_tries = sorted(champs)
|
||||
aff_comp = []
|
||||
|
||||
for comp in champs_tries:
|
||||
aff_comp += ["💡" + comp]
|
||||
return ", ".join(aff_comp)
|
||||
|
||||
|
||||
def repr_tags(tags):
|
||||
"""Affiche les tags"""
|
||||
tags_tries = sorted(tags)
|
||||
aff_tag = ["👜" + tag for tag in tags_tries]
|
||||
return ", ".join(aff_tag)
|
||||
|
||||
|
||||
def aff_tags_par_categories(dict_tags):
|
||||
"""Etant donné un dictionnaire de tags, triés
|
||||
par catégorie (ici "personnalisés" ou "auto")
|
||||
représentation textuelle des tags
|
||||
"""
|
||||
noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys())))
|
||||
noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp
|
||||
if noms_tags_perso:
|
||||
aff_tags_perso = ", ".join([f"👜{nom}" for nom in noms_tags_perso])
|
||||
aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto])
|
||||
return f"Tags du programme de formation : {aff_tags_perso} + Automatiques : {aff_tags_auto}"
|
||||
else:
|
||||
aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto])
|
||||
return f"Tags automatiques {aff_tags_auto} (aucun tag personnalisé)"
|
||||
|
||||
# Affichage
|
||||
|
||||
|
||||
def aff_trajectoires_suivies_par_etudiants(etudiants):
|
||||
"""Affiche les trajectoires (regroupement de (form)semestres)
|
||||
amenant un étudiant du S1 à un semestre final"""
|
||||
# Affichage pour debug
|
||||
etudiants_ids = etudiants.etudiants_ids
|
||||
jeunes = list(enumerate(etudiants_ids))
|
||||
for no_etud, etudid in jeunes:
|
||||
etat = "⛔" if etudid in etudiants.abandons_ids else "✅"
|
||||
|
||||
pe_print(f"--> {etat} {etudiants.identites[etudid].nomprenom} (#{etudid}) :")
|
||||
trajectoires = etudiants.trajectoires[etudid]
|
||||
for nom_rcs, rcs in trajectoires.items():
|
||||
if rcs:
|
||||
pe_print(f" > RCS ⏯️{nom_rcs}: {rcs.get_repr()}")
|
||||
|
||||
|
||||
def aff_semXs_suivis_par_etudiants(etudiants):
|
||||
"""Affiche les SemX (regroupement de semestres de type Sx)
|
||||
amenant un étudiant à valider un Sx"""
|
||||
etudiants_ids = etudiants.etudiants_ids
|
||||
jeunes = list(enumerate(etudiants_ids))
|
||||
|
||||
for no_etud, etudid in jeunes:
|
||||
etat = "⛔" if etudid in etudiants.abandons_ids else "✅"
|
||||
pe_print(f"--> {etat} {etudiants.identites[etudid].nomprenom} :")
|
||||
for nom_rcs, rcs in etudiants.semXs[etudid].items():
|
||||
if rcs:
|
||||
pe_print(f" > SemX ⏯️{nom_rcs}: {rcs.get_repr()}")
|
||||
|
||||
vides = []
|
||||
for nom_rcs in pe_rcs.TOUS_LES_SEMESTRES:
|
||||
les_semX_suivis = []
|
||||
for no_etud, etudid in jeunes:
|
||||
if etudiants.semXs[etudid][nom_rcs]:
|
||||
les_semX_suivis.append(etudiants.semXs[etudid][nom_rcs])
|
||||
if not les_semX_suivis:
|
||||
vides += [nom_rcs]
|
||||
vides = sorted(list(set(vides)))
|
||||
pe_print(f"⚠️ SemX sans données : {', '.join(vides)}")
|
||||
|
||||
|
||||
def aff_capitalisations(etuds, ressembuttags, fid_final, acronymes_sorted, masque_df):
|
||||
"""Affichage des capitalisations du sxtag pour debug"""
|
||||
aff_cap = []
|
||||
for etud in etuds:
|
||||
cap = []
|
||||
for frmsem_id in ressembuttags:
|
||||
if frmsem_id != fid_final:
|
||||
for accr in acronymes_sorted:
|
||||
if masque_df[frmsem_id].loc[etud.etudid, accr] > 0.0:
|
||||
cap += [accr]
|
||||
if cap:
|
||||
aff_cap += [f" > {etud.nomprenom} : {', '.join(cap)}"]
|
||||
if aff_cap:
|
||||
pe_print(f"--> ⚠️ Capitalisations :")
|
||||
pe_print("\n".join(aff_cap))
|
||||
|
||||
|
||||
def repr_comp_et_ues(acronymes_ues_to_competences):
|
||||
"""Affichage pour debug"""
|
||||
aff_comp = []
|
||||
competences_sorted = sorted(acronymes_ues_to_competences.keys())
|
||||
for comp in competences_sorted:
|
||||
liste = []
|
||||
for acro in acronymes_ues_to_competences:
|
||||
if acronymes_ues_to_competences[acro] == comp:
|
||||
liste += ["📍" + acro]
|
||||
aff_comp += [f" 💡{comp} (⇔ {', '.join(liste)})"]
|
||||
return "\n".join(aff_comp)
|
||||
|
||||
|
||||
def aff_rcsemxs_suivis_par_etudiants(etudiants):
|
||||
"""Affiche les RCSemX (regroupement de SemX)
|
||||
amenant un étudiant du S1 à un Sx"""
|
||||
etudiants_ids = etudiants.etudiants_ids
|
||||
jeunes = list(enumerate(etudiants_ids))
|
||||
|
||||
for no_etud, etudid in jeunes:
|
||||
etat = "⛔" if etudid in etudiants.abandons_ids else "✅"
|
||||
pe_print(f"-> {etat} {etudiants.identites[etudid].nomprenom} :")
|
||||
for nom_rcs, rcs in etudiants.rcsemXs[etudid].items():
|
||||
if rcs:
|
||||
pe_print(f" > RCSemX ⏯️{nom_rcs}: {rcs.get_repr()}")
|
||||
|
||||
vides = []
|
||||
for nom_rcs in pe_rcs.TOUS_LES_RCS:
|
||||
les_rcssemX_suivis = []
|
||||
for no_etud, etudid in jeunes:
|
||||
if etudiants.rcsemXs[etudid][nom_rcs]:
|
||||
les_rcssemX_suivis.append(etudiants.rcsemXs[etudid][nom_rcs])
|
||||
if not les_rcssemX_suivis:
|
||||
vides += [nom_rcs]
|
||||
vides = sorted(list(set(vides)))
|
||||
pe_print(f"⚠️ RCSemX vides : {', '.join(vides)}")
|
||||
|
|
|
@ -41,26 +41,16 @@ import datetime
|
|||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
import pandas as pd
|
||||
from flask import g
|
||||
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app import log
|
||||
|
||||
from app.models import FormSemestre
|
||||
from app.pe.rcss.pe_rcs import TYPES_RCS
|
||||
from app.scodoc import sco_formsemestre
|
||||
from app.scodoc.sco_logos import find_logo
|
||||
|
||||
PE_DEBUG = 0
|
||||
|
||||
if not PE_DEBUG:
|
||||
# log to notes.log
|
||||
def pe_print(*a, **kw):
|
||||
# kw is ignored. log always add a newline
|
||||
log(" ".join(a))
|
||||
|
||||
else:
|
||||
pe_print = print # print function
|
||||
|
||||
|
||||
# Generated LaTeX files are encoded as:
|
||||
PE_LATEX_ENCODING = "utf-8"
|
||||
|
@ -82,93 +72,11 @@ Descriptif d'un parcours classique BUT
|
|||
TODO:: A améliorer si BUT en moins de 6 semestres
|
||||
"""
|
||||
|
||||
PARCOURS = {
|
||||
"S1": {
|
||||
"aggregat": ["S1"],
|
||||
"ordre": 1,
|
||||
"affichage_court": "S1",
|
||||
"affichage_long": "Semestre 1",
|
||||
},
|
||||
"S2": {
|
||||
"aggregat": ["S2"],
|
||||
"ordre": 2,
|
||||
"affichage_court": "S2",
|
||||
"affichage_long": "Semestre 2",
|
||||
},
|
||||
"1A": {
|
||||
"aggregat": ["S1", "S2"],
|
||||
"ordre": 3,
|
||||
"affichage_court": "1A",
|
||||
"affichage_long": "1ère année",
|
||||
},
|
||||
"S3": {
|
||||
"aggregat": ["S3"],
|
||||
"ordre": 4,
|
||||
"affichage_court": "S3",
|
||||
"affichage_long": "Semestre 3",
|
||||
},
|
||||
"S4": {
|
||||
"aggregat": ["S4"],
|
||||
"ordre": 5,
|
||||
"affichage_court": "S4",
|
||||
"affichage_long": "Semestre 4",
|
||||
},
|
||||
"2A": {
|
||||
"aggregat": ["S3", "S4"],
|
||||
"ordre": 6,
|
||||
"affichage_court": "2A",
|
||||
"affichage_long": "2ème année",
|
||||
},
|
||||
"3S": {
|
||||
"aggregat": ["S1", "S2", "S3"],
|
||||
"ordre": 7,
|
||||
"affichage_court": "S1+S2+S3",
|
||||
"affichage_long": "BUT du semestre 1 au semestre 3",
|
||||
},
|
||||
"4S": {
|
||||
"aggregat": ["S1", "S2", "S3", "S4"],
|
||||
"ordre": 8,
|
||||
"affichage_court": "BUT",
|
||||
"affichage_long": "BUT du semestre 1 au semestre 4",
|
||||
},
|
||||
"S5": {
|
||||
"aggregat": ["S5"],
|
||||
"ordre": 9,
|
||||
"affichage_court": "S5",
|
||||
"affichage_long": "Semestre 5",
|
||||
},
|
||||
"S6": {
|
||||
"aggregat": ["S6"],
|
||||
"ordre": 10,
|
||||
"affichage_court": "S6",
|
||||
"affichage_long": "Semestre 6",
|
||||
},
|
||||
"3A": {
|
||||
"aggregat": ["S5", "S6"],
|
||||
"ordre": 11,
|
||||
"affichage_court": "3A",
|
||||
"affichage_long": "3ème année",
|
||||
},
|
||||
"5S": {
|
||||
"aggregat": ["S1", "S2", "S3", "S4", "S5"],
|
||||
"ordre": 12,
|
||||
"affichage_court": "S1+S2+S3+S4+S5",
|
||||
"affichage_long": "BUT du semestre 1 au semestre 5",
|
||||
},
|
||||
"6S": {
|
||||
"aggregat": ["S1", "S2", "S3", "S4", "S5", "S6"],
|
||||
"ordre": 13,
|
||||
"affichage_court": "BUT",
|
||||
"affichage_long": "BUT (tout semestre inclus)",
|
||||
},
|
||||
}
|
||||
NBRE_SEMESTRES_DIPLOMANT = 6
|
||||
AGGREGAT_DIPLOMANT = (
|
||||
"6S" # aggrégat correspondant à la totalité des notes pour le diplôme
|
||||
)
|
||||
TOUS_LES_SEMESTRES = PARCOURS[AGGREGAT_DIPLOMANT]["aggregat"]
|
||||
TOUS_LES_AGGREGATS = [cle for cle in PARCOURS.keys() if not cle.startswith("S")]
|
||||
TOUS_LES_PARCOURS = list(PARCOURS.keys())
|
||||
TOUS_LES_SEMESTRES = TYPES_RCS[AGGREGAT_DIPLOMANT]["aggregat"]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
|
@ -189,7 +97,8 @@ def calcul_age(born: datetime.date) -> int:
|
|||
return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
|
||||
|
||||
|
||||
def remove_accents(input_unicode_str):
|
||||
# Nota: scu.suppress_accents fait la même chose mais renvoie un str et non un bytes
|
||||
def remove_accents(input_unicode_str: str) -> bytes:
|
||||
"""Supprime les accents d'une chaine unicode"""
|
||||
nfkd_form = unicodedata.normalize("NFKD", input_unicode_str)
|
||||
only_ascii = nfkd_form.encode("ASCII", "ignore")
|
||||
|
@ -224,15 +133,15 @@ def escape_for_latex(s):
|
|||
|
||||
|
||||
# ----------------------------------------------------------------------------------------
|
||||
def list_directory_filenames(path):
|
||||
"""List of regular filenames in a directory (recursive)
|
||||
def list_directory_filenames(path: str) -> list[str]:
|
||||
"""List of regular filenames (paths) in a directory (recursive)
|
||||
Excludes files and directories begining with .
|
||||
"""
|
||||
R = []
|
||||
paths = []
|
||||
for root, dirs, files in os.walk(path, topdown=True):
|
||||
dirs[:] = [d for d in dirs if d[0] != "."]
|
||||
R += [os.path.join(root, fn) for fn in files if fn[0] != "."]
|
||||
return R
|
||||
paths += [os.path.join(root, fn) for fn in files if fn[0] != "."]
|
||||
return paths
|
||||
|
||||
|
||||
def add_local_file_to_zip(zipfile, ziproot, pathname, path_in_zip):
|
||||
|
@ -286,13 +195,15 @@ def add_pe_stuff_to_zip(zipfile, ziproot):
|
|||
def get_annee_diplome_semestre(
|
||||
sem_base: FormSemestre | dict, nbre_sem_formation: int = 6
|
||||
) -> int:
|
||||
"""Pour un semestre ``sem_base`` donné (supposé être un semestre d'une formation BUT à 6 semestres)
|
||||
et connaissant le numéro du semestre, ses dates de début et de fin du semestre, prédit l'année à laquelle
|
||||
sera remis le diplôme BUT des étudiants qui y sont scolarisés
|
||||
(en supposant qu'il n'y ait pas de redoublement à venir).
|
||||
"""Pour un semestre ``sem_base`` donné (supposé être un semestre d'une formation BUT
|
||||
à 6 semestres) et connaissant le numéro du semestre, ses dates de début et de fin du
|
||||
semestre, prédit l'année à laquelle sera remis le diplôme BUT des étudiants qui y
|
||||
sont scolarisés (en supposant qu'il n'y ait pas de redoublement à venir).
|
||||
|
||||
**Remarque sur le calcul** : Les semestres de 1ère partie d'année (S1, S3, S5 ou S4, S6 pour des semestres décalés)
|
||||
s'étalent sur deux années civiles ; contrairement au semestre de seconde partie d'année universitaire.
|
||||
**Remarque sur le calcul** : Les semestres de 1ère partie d'année (S1, S3, S5 ou S4,
|
||||
S6 pour des semestres décalés)
|
||||
s'étalent sur deux années civiles ; contrairement au semestre de seconde partie
|
||||
d'année universitaire.
|
||||
|
||||
Par exemple :
|
||||
|
||||
|
@ -326,23 +237,23 @@ def get_annee_diplome_semestre(
|
|||
if (
|
||||
1 <= sem_id <= nbre_sem_formation
|
||||
): # Si le semestre est un semestre BUT => problème si formation BUT en 1 an ??
|
||||
nbreSemRestant = (
|
||||
nb_sem_restants = (
|
||||
nbre_sem_formation - sem_id
|
||||
) # nombre de semestres restant avant diplome
|
||||
nbreAnRestant = nbreSemRestant // 2 # nombre d'annees restant avant diplome
|
||||
# Flag permettant d'activer ou désactiver un increment à prendre en compte en cas de semestre décalé
|
||||
nb_annees_restantes = (
|
||||
nb_sem_restants // 2
|
||||
) # nombre d'annees restant avant diplome
|
||||
# Flag permettant d'activer ou désactiver un increment
|
||||
# à prendre en compte en cas de semestre décalé
|
||||
# avec 1 - delta = 0 si semestre de 1ere partie d'année / 1 sinon
|
||||
delta = annee_fin - annee_debut
|
||||
decalage = nbreSemRestant % 2 # 0 si S4, 1 si S3, 0 si S2, 1 si S1
|
||||
decalage = nb_sem_restants % 2 # 0 si S4, 1 si S3, 0 si S2, 1 si S1
|
||||
increment = decalage * (1 - delta)
|
||||
return annee_fin + nbreAnRestant + increment
|
||||
return annee_fin + nb_annees_restantes + increment
|
||||
|
||||
|
||||
def get_cosemestres_diplomants(
|
||||
annee_diplome: int, formation_id: int
|
||||
) -> dict[int, FormSemestre]:
|
||||
"""Ensemble des cosemestres donnant lieu à diplomation à l'``annee_diplome``
|
||||
et s'intégrant à la formation donnée par son ``formation_id``.
|
||||
def get_cosemestres_diplomants(annee_diplome: int) -> dict[int, FormSemestre]:
|
||||
"""Ensemble des cosemestres donnant lieu à diplomation à l'``annee_diplome``.
|
||||
|
||||
**Définition** : Un co-semestre est un semestre :
|
||||
|
||||
|
@ -350,30 +261,21 @@ def get_cosemestres_diplomants(
|
|||
* dont la formation est la même (optionnel)
|
||||
* qui a des étudiants inscrits
|
||||
|
||||
Si formation_id == None, ne prend pas en compte l'identifiant de formation
|
||||
TODO:: A raccrocher à un programme
|
||||
|
||||
Args:
|
||||
annee_diplome: L'année de diplomation
|
||||
formation_id: L'identifiant de la formation
|
||||
|
||||
Returns:
|
||||
Un dictionnaire {fid: FormSemestre(fid)} contenant les cosemestres
|
||||
"""
|
||||
tousLesSems = (
|
||||
tous_les_sems = (
|
||||
sco_formsemestre.do_formsemestre_list()
|
||||
) # tous les semestres memorisés dans scodoc
|
||||
|
||||
if formation_id:
|
||||
cosemestres_fids = {
|
||||
sem["id"]
|
||||
for sem in tousLesSems
|
||||
if get_annee_diplome_semestre(sem) == annee_diplome
|
||||
and sem["formation_id"] == formation_id
|
||||
}
|
||||
else:
|
||||
cosemestres_fids = {
|
||||
sem["id"]
|
||||
for sem in tousLesSems
|
||||
if get_annee_diplome_semestre(sem) == annee_diplome
|
||||
}
|
||||
cosemestres_fids = {
|
||||
sem["id"]
|
||||
for sem in tous_les_sems
|
||||
if get_annee_diplome_semestre(sem) == annee_diplome
|
||||
}
|
||||
|
||||
cosemestres = {}
|
||||
for fid in cosemestres_fids:
|
||||
|
@ -382,3 +284,56 @@ def get_cosemestres_diplomants(
|
|||
cosemestres[fid] = cosem
|
||||
|
||||
return cosemestres
|
||||
|
||||
|
||||
def tri_semestres_par_rang(cosemestres: dict[int, FormSemestre]):
|
||||
"""Partant d'un dictionnaire de cosemestres, les tri par rang (semestre_id) dans un
|
||||
dictionnaire {rang: [liste des semestres du dit rang]}"""
|
||||
cosemestres_tries = {}
|
||||
for sem in cosemestres.values():
|
||||
cosemestres_tries[sem.semestre_id] = cosemestres_tries.get(
|
||||
sem.semestre_id, []
|
||||
) + [sem]
|
||||
return cosemestres_tries
|
||||
|
||||
|
||||
def find_index_and_columns_communs(
|
||||
df1: pd.DataFrame, df2: pd.DataFrame
|
||||
) -> (list, list):
|
||||
"""Partant de 2 DataFrames ``df1`` et ``df2``, renvoie les indices de lignes
|
||||
et de colonnes, communes aux 2 dataframes
|
||||
|
||||
Args:
|
||||
df1: Un dataFrame
|
||||
df2: Un dataFrame
|
||||
Returns:
|
||||
Le tuple formé par la liste des indices de lignes communs et la liste des indices
|
||||
de colonnes communes entre les 2 dataFrames
|
||||
"""
|
||||
indices1 = df1.index
|
||||
indices2 = df2.index
|
||||
indices_communs = list(df1.index.intersection(df2.index))
|
||||
colonnes1 = df1.columns
|
||||
colonnes2 = df2.columns
|
||||
colonnes_communes = list(set(colonnes1) & set(colonnes2))
|
||||
return indices_communs, colonnes_communes
|
||||
|
||||
|
||||
def get_dernier_semestre_en_date(semestres: dict[int, FormSemestre]) -> FormSemestre:
|
||||
"""Renvoie le dernier semestre en **date de fin** d'un dictionnaire
|
||||
de semestres (potentiellement non trié) de la forme ``{fid: FormSemestre(fid)}``.
|
||||
|
||||
Args:
|
||||
semestres: Un dictionnaire de semestres
|
||||
|
||||
Return:
|
||||
Le FormSemestre du semestre le plus récent
|
||||
"""
|
||||
if semestres:
|
||||
fid_dernier_semestre = list(semestres.keys())[0]
|
||||
dernier_semestre: FormSemestre = semestres[fid_dernier_semestre]
|
||||
for fid in semestres:
|
||||
if semestres[fid].date_fin > dernier_semestre.date_fin:
|
||||
dernier_semestre = semestres[fid]
|
||||
return dernier_semestre
|
||||
return None
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. c All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
|
@ -35,10 +35,15 @@ Created on 17/01/2024
|
|||
|
||||
@author: barasc
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
import app.pe.pe_comp as pe_comp
|
||||
from app.models import FormSemestre, Identite
|
||||
from app.pe.pe_comp import pe_print
|
||||
from app import ScoValueError
|
||||
from app.models import FormSemestre, Identite, Formation
|
||||
from app.pe import pe_comp, pe_affichage
|
||||
from app.pe.rcss import pe_rcs
|
||||
from app.scodoc import codes_cursus
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.comp.res_sem import load_formsemestre_results
|
||||
|
||||
|
||||
class EtudiantsJuryPE:
|
||||
|
@ -46,111 +51,124 @@ class EtudiantsJuryPE:
|
|||
|
||||
def __init__(self, annee_diplome: int):
|
||||
"""
|
||||
|
||||
Args:
|
||||
annee_diplome: L'année de diplomation
|
||||
"""
|
||||
|
||||
self.annee_diplome = annee_diplome
|
||||
"""L'année du diplôme"""
|
||||
|
||||
"Les identités des étudiants traités pour le jury"
|
||||
self.identites = {} # ex. ETUDINFO_DICT
|
||||
"Les cursus (semestres suivis, abandons) des étudiants"
|
||||
self.cursus = {}
|
||||
"""Les aggrégats des semestres suivis (par ex: 3S=S1+S2+S3 à prendre en compte avec d'éventuels redoublements) des étudiants"""
|
||||
self.trajectoires = {}
|
||||
self.identites: dict[int:Identite] = {} # ex. ETUDINFO_DICT
|
||||
"""Les identités des étudiants traités pour le jury"""
|
||||
|
||||
self.cursus: dict[int:dict] = {}
|
||||
"""Les cursus (semestres suivis, abandons) des étudiants"""
|
||||
|
||||
self.trajectoires: dict[int:dict] = {}
|
||||
"""Les trajectoires (regroupement cohérents de semestres) suivis par les étudiants"""
|
||||
|
||||
self.semXs: dict[int:dict] = {}
|
||||
"""Les semXs (RCS de type Sx) suivis par chaque étudiant"""
|
||||
|
||||
self.rcsemXs: dict[int:dict] = {}
|
||||
"""Les RC de SemXs (RCS de type Sx, xA, xS) suivis par chaque étudiant"""
|
||||
|
||||
"Les etudids des étudiants à considérer au jury (ceux qui seront effectivement diplômés)"
|
||||
self.etudiants_diplomes = {}
|
||||
self.diplomes_ids = {}
|
||||
"""Les identités des étudiants à considérer au jury (ceux qui seront effectivement
|
||||
diplômés)"""
|
||||
|
||||
self.diplomes_ids = {}
|
||||
"""Les etudids des étudiants diplômés"""
|
||||
|
||||
"Les etudids des étudiants dont il faut calculer les moyennes/classements (même si d'éventuels abandons)"
|
||||
self.etudiants_ids = {}
|
||||
"""Les étudiants inscrits dans les co-semestres (ceux du jury mais aussi d'autres ayant été réorientés ou ayant abandonnés)"""
|
||||
"""Les etudids des étudiants dont il faut calculer les moyennes/classements
|
||||
(même si d'éventuels abandons).
|
||||
Il s'agit des étudiants inscrits dans les co-semestres (ceux du jury mais aussi
|
||||
d'autres ayant été réorientés ou ayant abandonnés)"""
|
||||
|
||||
self.cosemestres: dict[int, FormSemestre] = None
|
||||
"Les cosemestres donnant lieu à même année de diplome"
|
||||
|
||||
def find_etudiants(self, formation_id: int):
|
||||
self.abandons = {}
|
||||
"""Les étudiants qui ne seront pas diplômés à ce jury (redoublants/réorientés)"""
|
||||
self.abandons_ids = {}
|
||||
"""Les etudids des étudiants redoublants/réorientés"""
|
||||
|
||||
def find_etudiants(self):
|
||||
"""Liste des étudiants à prendre en compte dans le jury PE, en les recherchant
|
||||
de manière automatique par rapport à leur année de diplomation ``annee_diplome``
|
||||
dans la formation ``formation_id``. XXX TODO voir si on garde formation_id qui n'est pas utilisé ici
|
||||
de manière automatique par rapport à leur année de diplomation ``annee_diplome``.
|
||||
|
||||
Les données obtenues sont stockées dans les attributs de EtudiantsJuryPE.
|
||||
|
||||
|
||||
formation_id: L'identifiant de la formation (inutilisé)
|
||||
|
||||
*Remarque* : ex: JuryPE.get_etudiants_in_jury()
|
||||
"""
|
||||
cosemestres = pe_comp.get_cosemestres_diplomants(self.annee_diplome, None)
|
||||
cosemestres = pe_comp.get_cosemestres_diplomants(self.annee_diplome)
|
||||
self.cosemestres = cosemestres
|
||||
|
||||
pe_comp.pe_print(f"1) Recherche des coSemestres -> {len(cosemestres)} trouvés")
|
||||
|
||||
pe_comp.pe_print("2) Liste des étudiants dans les différents co-semestres")
|
||||
self.etudiants_ids = get_etudiants_dans_semestres(cosemestres)
|
||||
pe_comp.pe_print(
|
||||
" => %d étudiants trouvés dans les cosemestres" % len(self.etudiants_ids)
|
||||
pe_affichage.pe_print(
|
||||
f"1) Recherche des cosemestres -> {len(cosemestres)} trouvés", info=True
|
||||
)
|
||||
|
||||
"""Analyse des parcours étudiants pour déterminer leur année effective de diplome
|
||||
avec prise en compte des redoublements, des abandons, ...."""
|
||||
pe_comp.pe_print("3) Analyse des parcours individuels des étudiants")
|
||||
pe_affichage.pe_print(
|
||||
"2) Liste des étudiants dans les différents cosemestres", info=True
|
||||
)
|
||||
etudiants_ids = get_etudiants_dans_semestres(cosemestres)
|
||||
pe_affichage.pe_print(
|
||||
f" => {len(etudiants_ids)} étudiants trouvés dans les cosemestres",
|
||||
info=True,
|
||||
)
|
||||
|
||||
no_etud = 0
|
||||
for no_etud, etudid in enumerate(self.etudiants_ids):
|
||||
"""L'identité de l'étudiant"""
|
||||
identite = Identite.get_etud(etudid)
|
||||
self.identites[etudid] = identite
|
||||
# Analyse des parcours étudiants pour déterminer leur année effective de diplome
|
||||
# avec prise en compte des redoublements, des abandons, ....
|
||||
pe_affichage.pe_print(
|
||||
"3) Analyse des parcours individuels des étudiants", info=True
|
||||
)
|
||||
|
||||
"""L'analyse de son cursus"""
|
||||
self.analyse_etat_etudiant(etudid, cosemestres)
|
||||
# Ajoute une liste d'étudiants
|
||||
self.add_etudiants(etudiants_ids)
|
||||
|
||||
"""L'analyse de son parcours pour atteindre chaque semestre de la formation"""
|
||||
self.structure_cursus_etudiant(etudid)
|
||||
|
||||
if (no_etud + 1) % 10 == 0:
|
||||
pe_comp.pe_print(f"{no_etud + 1}")
|
||||
no_etud += 1
|
||||
pe_comp.pe_print()
|
||||
|
||||
"""Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris"""
|
||||
# Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris
|
||||
self.etudiants_diplomes = self.get_etudiants_diplomes()
|
||||
self.diplomes_ids = set(self.etudiants_diplomes.keys())
|
||||
self.etudiants_ids = set(self.identites.keys())
|
||||
|
||||
"""Les étudiants dont il faut calculer les moyennes"""
|
||||
self.etudiants_ids = {etudid for etudid in self.identites}
|
||||
# Les abandons (pour debug)
|
||||
self.abandons = self.get_etudiants_redoublants_ou_reorientes()
|
||||
# Les identités des étudiants ayant redoublés ou ayant abandonnés
|
||||
|
||||
"""Les formsemestres (des étudiants) dont il faut calculer les moyennes"""
|
||||
self.formsemestres_jury_ids = self.get_formsemestres()
|
||||
self.abandons_ids = set(self.abandons)
|
||||
# Les identifiants des étudiants ayant redoublés ou ayant abandonnés
|
||||
|
||||
# Synthèse
|
||||
pe_comp.pe_print(
|
||||
f" => {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}"
|
||||
pe_affichage.pe_print(f"4) Bilan", info=True)
|
||||
pe_affichage.pe_print(
|
||||
f"--> {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}",
|
||||
info=True,
|
||||
)
|
||||
nbre_abandons = len(self.etudiants_ids) - len(self.etudiants_diplomes)
|
||||
pe_comp.pe_print(f" => {nbre_abandons} étudiants éliminer pour abandon")
|
||||
pe_comp.pe_print(
|
||||
f" => {len(self.formsemestres_jury_ids)} semestres dont il faut calculer la moyenne"
|
||||
)
|
||||
pe_comp.pe_print(
|
||||
f" => quelques étudiants futurs diplômés : "
|
||||
+ ", ".join([str(etudid) for etudid in list(self.etudiants_diplomes)[:10]])
|
||||
)
|
||||
pe_comp.pe_print(
|
||||
f" => semestres dont il faut calculer les moyennes : "
|
||||
+ ", ".join([str(fid) for fid in list(self.formsemestres_jury_ids)])
|
||||
)
|
||||
# Les abandons :
|
||||
self.abandons = sorted(
|
||||
[
|
||||
self.cursus[etudid]["nom"]
|
||||
for etudid in self.cursus
|
||||
if etudid not in self.diplomes_ids
|
||||
]
|
||||
assert nbre_abandons == len(self.abandons_ids)
|
||||
|
||||
pe_affichage.pe_print(
|
||||
f"--> {nbre_abandons} étudiants traités mais non diplômés (redoublement, réorientation, abandon)"
|
||||
)
|
||||
|
||||
def add_etudiants(self, etudiants_ids):
|
||||
"""Ajoute une liste d'étudiants aux données du jury"""
|
||||
nbre_etudiants_ajoutes = 0
|
||||
for etudid in etudiants_ids:
|
||||
if etudid not in self.identites:
|
||||
nbre_etudiants_ajoutes += 1
|
||||
|
||||
# L'identité de l'étudiant
|
||||
self.identites[etudid] = Identite.get_etud(etudid)
|
||||
|
||||
# Analyse son cursus
|
||||
self.analyse_etat_etudiant(etudid, self.cosemestres)
|
||||
|
||||
# Analyse son parcours pour atteindre chaque semestre de la formation
|
||||
self.structure_cursus_etudiant(etudid)
|
||||
self.etudiants_ids = set(self.identites.keys())
|
||||
return nbre_etudiants_ajoutes
|
||||
|
||||
def get_etudiants_diplomes(self) -> dict[int, Identite]:
|
||||
"""Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
|
||||
qui vont être à traiter au jury PE pour
|
||||
|
@ -162,9 +180,26 @@ class EtudiantsJuryPE:
|
|||
"""
|
||||
etudids = [
|
||||
etudid
|
||||
for etudid in self.cursus
|
||||
if self.cursus[etudid]["diplome"] == self.annee_diplome
|
||||
and self.cursus[etudid]["abandon"] == False
|
||||
for etudid, cursus_etud in self.cursus.items()
|
||||
if cursus_etud["diplome"] == self.annee_diplome
|
||||
and cursus_etud["abandon"] is False
|
||||
]
|
||||
etudiants = {etudid: self.identites[etudid] for etudid in etudids}
|
||||
return etudiants
|
||||
|
||||
def get_etudiants_redoublants_ou_reorientes(self) -> dict[int, Identite]:
|
||||
"""Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
|
||||
dont les notes seront prises en compte (pour les classements) mais qui n'apparaitront
|
||||
pas dans le jury car diplômé une autre année (redoublants) ou réorienté ou démissionnaire.
|
||||
|
||||
Returns:
|
||||
Un dictionnaire `{etudid: Identite(etudid)}`
|
||||
"""
|
||||
etudids = [
|
||||
etudid
|
||||
for etudid, cursus_etud in self.cursus.items()
|
||||
if cursus_etud["diplome"] != self.annee_diplome
|
||||
or cursus_etud["abandon"] is True
|
||||
]
|
||||
etudiants = {etudid: self.identites[etudid] for etudid in etudids}
|
||||
return etudiants
|
||||
|
@ -180,8 +215,11 @@ class EtudiantsJuryPE:
|
|||
|
||||
* à insérer une entrée dans ``self.cursus`` pour mémoriser son identité,
|
||||
avec son nom, prénom, etc...
|
||||
* à analyser son parcours, pour déterminer s'il n'a (ou non) abandonné l'IUT en cours de
|
||||
route (cf. clé abandon)
|
||||
* à analyser son parcours, pour déterminer s'il a démissionné, redoublé (autre année de diplôme)
|
||||
ou a abandonné l'IUT en cours de route (cf. clé abandon). Un étudiant est considéré
|
||||
en abandon si connaissant son dernier semestre (par ex. un S3) il n'est pas systématiquement
|
||||
inscrit à l'un des S4, S5 ou S6 existants dans les cosemestres.
|
||||
|
||||
|
||||
Args:
|
||||
etudid: L'etudid d'un étudiant, à ajouter à ceux traiter par le jury
|
||||
|
@ -190,21 +228,31 @@ class EtudiantsJuryPE:
|
|||
"""
|
||||
identite = Identite.get_etud(etudid)
|
||||
|
||||
"""Le cursus global de l'étudiant (restreint aux semestres APC)"""
|
||||
# Le cursus global de l'étudiant (restreint aux semestres APC)
|
||||
formsemestres = identite.get_formsemestres()
|
||||
|
||||
semestres_etudiant = {
|
||||
frmsem.formsemestre_id: frmsem
|
||||
for frmsem in formsemestres
|
||||
if frmsem.formation.is_apc()
|
||||
formsemestre.formsemestre_id: formsemestre
|
||||
for formsemestre in formsemestres
|
||||
if formsemestre.formation.is_apc()
|
||||
}
|
||||
|
||||
# Le parcours final
|
||||
parcour = formsemestres[0].etuds_inscriptions[etudid].parcour
|
||||
if parcour:
|
||||
libelle = parcour.libelle
|
||||
else:
|
||||
libelle = None
|
||||
|
||||
self.cursus[etudid] = {
|
||||
"etudid": etudid, # les infos sur l'étudiant
|
||||
"etat_civil": identite.etat_civil, # Ajout à la table jury
|
||||
"nom": identite.nom,
|
||||
"entree": formsemestres[-1].date_debut.year, # La date d'entrée à l'IUT
|
||||
"diplome": annee_diplome(identite), # Le date prévisionnelle de son diplôme
|
||||
"parcours": libelle, # Le parcours final
|
||||
"diplome": get_annee_diplome(
|
||||
identite
|
||||
), # Le date prévisionnelle de son diplôme
|
||||
"formsemestres": semestres_etudiant, # les semestres de l'étudiant
|
||||
"nb_semestres": len(
|
||||
semestres_etudiant
|
||||
|
@ -212,29 +260,24 @@ class EtudiantsJuryPE:
|
|||
"abandon": False, # va être traité en dessous
|
||||
}
|
||||
|
||||
""" Est-il réorienté / démissionnaire ou a-t-il arrêté volontairement sa formation ?"""
|
||||
self.cursus[etudid]["abandon"] = arret_de_formation(identite, cosemestres)
|
||||
# Si l'étudiant est succeptible d'être diplomé
|
||||
if self.cursus[etudid]["diplome"] == self.annee_diplome:
|
||||
# Est-il démissionnaire : charge son dernier semestre pour connaitre son état ?
|
||||
dernier_semes_etudiant = formsemestres[0]
|
||||
res = load_formsemestre_results(dernier_semes_etudiant)
|
||||
etud_etat = res.get_etud_etat(etudid)
|
||||
if etud_etat == scu.DEMISSION:
|
||||
self.cursus[etudid]["abandon"] = True
|
||||
else:
|
||||
# Est-il réorienté ou a-t-il arrêté (volontairement) sa formation ?
|
||||
self.cursus[etudid]["abandon"] = arret_de_formation(
|
||||
identite, cosemestres
|
||||
)
|
||||
|
||||
def get_semestres_significatifs(self, etudid: int):
|
||||
"""Ensemble des semestres d'un étudiant, qui l'auraient amené à être diplomé
|
||||
l'année visée (supprime les semestres qui conduisent à une diplomation
|
||||
postérieure à celle du jury visé)
|
||||
|
||||
Args:
|
||||
etudid: L'identifiant d'un étudiant
|
||||
|
||||
Returns:
|
||||
Un dictionnaire ``{fid: FormSemestre(fid)`` dans lequel les semestres
|
||||
amènent à une diplomation avant l'annee de diplomation du jury
|
||||
"""
|
||||
|
||||
semestres_etudiant = self.cursus[etudid]["formsemestres"]
|
||||
semestres_significatifs = {}
|
||||
for fid in semestres_etudiant:
|
||||
semestre = semestres_etudiant[fid]
|
||||
if pe_comp.get_annee_diplome_semestre(semestre) <= self.annee_diplome:
|
||||
semestres_significatifs[fid] = semestre
|
||||
return semestres_significatifs
|
||||
# Initialise ses trajectoires/SemX/RCSemX
|
||||
self.trajectoires[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS}
|
||||
self.semXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_SEMESTRES}
|
||||
self.rcsemXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS}
|
||||
|
||||
def structure_cursus_etudiant(self, etudid: int):
|
||||
"""Structure les informations sur les semestres suivis par un
|
||||
|
@ -242,152 +285,146 @@ class EtudiantsJuryPE:
|
|||
de moyennes PE.
|
||||
|
||||
Cette structuration s'appuie sur les numéros de semestre: pour chaque Si, stocke :
|
||||
le dernier semestre (en date) de numéro i qu'il a suivi (1 ou 0 si pas encore suivi). Ce semestre influera les
|
||||
interclassement par semestre dans la promo.
|
||||
le dernier semestre (en date) de numéro i qu'il a suivi (1 ou 0 si pas encore suivi).
|
||||
Ce semestre influera les interclassements par semestre dans la promo.
|
||||
"""
|
||||
semestres_significatifs = self.get_semestres_significatifs(etudid)
|
||||
semestres_significatifs = get_semestres_significatifs(
|
||||
self.cursus[etudid]["formsemestres"], self.annee_diplome
|
||||
)
|
||||
|
||||
"""Tri des semestres par numéro de semestre"""
|
||||
for nom_sem in pe_comp.TOUS_LES_SEMESTRES:
|
||||
i = int(nom_sem[1]) # le n° du semestre
|
||||
# Tri des semestres par numéro de semestre
|
||||
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
|
||||
# les semestres de n°i de l'étudiant:
|
||||
semestres_i = {
|
||||
fid: semestres_significatifs[fid]
|
||||
for fid in semestres_significatifs
|
||||
if semestres_significatifs[fid].semestre_id == i
|
||||
} # les semestres de n°i de l'étudiant
|
||||
self.cursus[etudid][nom_sem] = semestres_i
|
||||
fid: sem_sig
|
||||
for fid, sem_sig in semestres_significatifs.items()
|
||||
if sem_sig.semestre_id == i
|
||||
}
|
||||
self.cursus[etudid][f"S{i}"] = semestres_i
|
||||
|
||||
def get_trajectoire(self, etudid: int, formsemestre_final: FormSemestre):
|
||||
"""Ensemble des semestres parcourus par
|
||||
un étudiant pour l'amener à un semestre terminal.
|
||||
def get_formsemestres_finals_des_rcs(self, nom_rcs: str) -> dict[int, FormSemestre]:
|
||||
"""Pour un nom de RCS donné, ensemble des formsemestres finals possibles
|
||||
pour les RCS. Par ex. un RCS '3S' incluant S1+S2+S3 a pour semestre final un S3.
|
||||
Les formsemestres finals obtenus traduisent :
|
||||
|
||||
Par ex: si formsemestre_terminal est un S3, ensemble des S1,
|
||||
S2, S3 suivi pour l'amener au S3 (il peut y avoir plusieurs S1,
|
||||
ou S2, ou S3 s'il a redoublé).
|
||||
|
||||
Les semestres parcourus sont antérieurs (en terme de date de fin)
|
||||
au formsemestre_terminal.
|
||||
* les différents parcours des étudiants liés par exemple au choix de modalité
|
||||
(par ex: S1 FI + S2 FI + S3 FI ou S1 FI + S2 FI + S3 UFA), en renvoyant les
|
||||
formsemestre_id du S3 FI et du S3 UFA.
|
||||
* les éventuelles situations de redoublement (par ex pour 1 étudiant ayant
|
||||
redoublé sa 2ème année :
|
||||
S1 + S2 + S3 (1ère session) et S1 + S2 + S3 + S4 + S3 (2ème session), en
|
||||
renvoyant les formsemestre_id du S3 (1ère session) et du S3 (2ème session)
|
||||
|
||||
Args:
|
||||
etudid: L'identifiant de l'étudiant
|
||||
formsemestre_final: le semestre final visé
|
||||
"""
|
||||
numero_semestre_terminal = formsemestre_final.semestre_id
|
||||
semestres_significatifs = self.get_semestres_significatifs(etudid)
|
||||
|
||||
"""Semestres de n° inférieur (pax ex: des S1, S2, S3 pour un S3 terminal) et qui lui sont antérieurs"""
|
||||
semestres_aggreges = {}
|
||||
for fid in semestres_significatifs:
|
||||
semestre = semestres_significatifs[fid]
|
||||
if (
|
||||
semestre.semestre_id <= numero_semestre_terminal
|
||||
and semestre.date_fin <= formsemestre_final.date_fin
|
||||
):
|
||||
semestres_aggreges[fid] = semestre
|
||||
return semestres_aggreges
|
||||
|
||||
def get_formsemestres_terminaux_aggregat(self, aggregat: str):
|
||||
"""Pour un aggrégat donné, ensemble des formsemestres terminaux possibles pour l'aggrégat
|
||||
(pour l'aggrégat '3S' incluant S1+S2+S3, a pour semestre terminal S3).
|
||||
Ces formsemestres traduisent :
|
||||
|
||||
* les différents parcours des étudiants liés par exemple au choix de modalité (par ex: S1 FI + S2 FI + S3 FI
|
||||
ou S1 FI + S2 FI + S3 UFA), en renvoyant les formsemestre_id du S3 FI et du S3 UFA.
|
||||
* les éventuelles situations de redoublement (par ex pour 1 étudiant ayant redoublé sa 2ème année :
|
||||
S1 + S2 + S3 (1ère session) et S1 + S2 + S3 + S4 + S3 (2ème session), en renvoyant les formsemestre_id du
|
||||
S3 (1ère session) et du S3 (2ème session)
|
||||
|
||||
Args:
|
||||
aggregat: L'aggrégat
|
||||
nom_rcs: Le nom du RCS (parmi Sx, xA, xS)
|
||||
|
||||
Returns:
|
||||
Un dictionnaire ``{fid: FormSemestre(fid)}``
|
||||
"""
|
||||
formsemestres_terminaux = {}
|
||||
for etudid in self.trajectoires:
|
||||
if self.trajectoires[etudid][aggregat]:
|
||||
trajectoire = self.trajectoires[etudid][aggregat]
|
||||
"""Le semestre terminal de l'étudiant de l'aggrégat"""
|
||||
fid = trajectoire.semestre_final.formsemestre_id
|
||||
formsemestres_terminaux[fid] = trajectoire.semestre_final
|
||||
for trajectoire_aggr in self.cursus.values():
|
||||
trajectoire = trajectoire_aggr[nom_rcs]
|
||||
if trajectoire:
|
||||
# Le semestre terminal de l'étudiant de l'aggrégat
|
||||
fid = trajectoire.formsemestre_final.formsemestre_id
|
||||
formsemestres_terminaux[fid] = trajectoire.formsemestre_final
|
||||
return formsemestres_terminaux
|
||||
|
||||
def get_formsemestres(self, semestres_recherches=None):
|
||||
"""Ayant connaissance des étudiants dont il faut calculer les moyennes pour
|
||||
le jury PE (attribut `self.etudiant_ids) et de leur cursus (semestres
|
||||
parcourus),
|
||||
renvoie un dictionnaire ``{fid: FormSemestre(fid)}``
|
||||
contenant l'ensemble des formsemestres de leurs cursus, dont il faudra calculer
|
||||
la moyenne.
|
||||
Les formsemestres sont limités à ceux indiqués dans ``semestres_recherches``.
|
||||
def nbre_etapes_max_diplomes(self, etudids: list[int]) -> int:
|
||||
"""Partant d'un ensemble d'étudiants,
|
||||
nombre de semestres (étapes) maximum suivis par les étudiants du jury.
|
||||
|
||||
Args:
|
||||
semestres_recherches: Une liste ou une chaine de caractères parmi :
|
||||
|
||||
* None : pour obtenir tous les formsemestres du jury
|
||||
* 'Si' : pour obtenir les semestres de n° i (par ex. 'S1')
|
||||
* 'iA' : pour obtenir les semestres de l'année i (par ex. '1A' donne ['S1, 'S2'])
|
||||
* '3S', '4S' : pour obtenir les combinaisons de semestres définies par les aggrégats
|
||||
|
||||
Returns:
|
||||
Un dictionnaire de la forme ``{fid: FormSemestre(fid)}``
|
||||
|
||||
Remarque:
|
||||
Une liste de la forme ``[ 'Si', 'iA' , ... ]`` (combinant les formats précédents) est possible.
|
||||
"""
|
||||
if semestres_recherches is None:
|
||||
"""Appel récursif pour obtenir tous les semestres (validants)"""
|
||||
semestres = self.get_formsemestres(pe_comp.AGGREGAT_DIPLOMANT)
|
||||
return semestres
|
||||
elif isinstance(semestres_recherches, list):
|
||||
"""Appel récursif sur tous les éléments de la liste"""
|
||||
semestres = {}
|
||||
for elmt in semestres_recherches:
|
||||
semestres_elmt = self.get_formsemestres(elmt)
|
||||
semestres = semestres | semestres_elmt
|
||||
return semestres
|
||||
elif (
|
||||
isinstance(semestres_recherches, str)
|
||||
and semestres_recherches in pe_comp.TOUS_LES_AGGREGATS
|
||||
):
|
||||
"""Cas d'un aggrégat avec appel récursif sur toutes les entrées de l'aggrégat"""
|
||||
semestres = self.get_formsemestres(
|
||||
pe_comp.PARCOURS[semestres_recherches]["aggregat"]
|
||||
)
|
||||
return semestres
|
||||
elif (
|
||||
isinstance(semestres_recherches, str)
|
||||
and semestres_recherches in pe_comp.TOUS_LES_SEMESTRES
|
||||
):
|
||||
"""semestres_recherches est un nom de semestre de type S1,
|
||||
pour une recherche parmi les étudiants à prendre en compte
|
||||
dans le jury (diplômé et redoublants non diplômé)
|
||||
"""
|
||||
nom_sem = semestres_recherches
|
||||
semestres = {}
|
||||
for etudid in self.etudiants_ids:
|
||||
if self.cursus[etudid][nom_sem]:
|
||||
semestres = semestres | self.cursus[etudid][nom_sem]
|
||||
return semestres
|
||||
else:
|
||||
raise ValueError("Probleme de paramètres d'appel dans get_formsemestreids")
|
||||
|
||||
def nbre_etapes_max_diplomes(self):
|
||||
"""Connaissant les étudiants diplomes du jury PE,
|
||||
nombre de semestres (étapes) maximum suivis par les étudiants du jury.
|
||||
etudids: Liste d'étudid d'étudiants
|
||||
"""
|
||||
nbres_semestres = []
|
||||
for etudid in self.diplomes_ids:
|
||||
for etudid in etudids:
|
||||
nbres_semestres.append(self.cursus[etudid]["nb_semestres"])
|
||||
if not nbres_semestres:
|
||||
return 0
|
||||
return max(nbres_semestres)
|
||||
|
||||
def df_administratif(self, etudids: list[int]) -> pd.DataFrame:
|
||||
"""Synthétise toutes les données administratives d'un groupe
|
||||
d'étudiants fournis par les etudid dans un dataFrame
|
||||
|
||||
Args:
|
||||
etudids: La liste des étudiants à prendre en compte
|
||||
"""
|
||||
|
||||
etudids = list(etudids)
|
||||
|
||||
# Récupération des données des étudiants
|
||||
administratif = {}
|
||||
nbre_semestres_max = self.nbre_etapes_max_diplomes(etudids)
|
||||
|
||||
for etudid in etudids:
|
||||
etudiant = self.identites[etudid]
|
||||
cursus = self.cursus[etudid]
|
||||
formsemestres = cursus["formsemestres"]
|
||||
parcours = cursus["parcours"]
|
||||
if not parcours:
|
||||
parcours = ""
|
||||
if cursus["diplome"]:
|
||||
diplome = cursus["diplome"]
|
||||
else:
|
||||
diplome = "indéterminé"
|
||||
|
||||
administratif[etudid] = {
|
||||
"etudid": etudiant.id,
|
||||
"INE": etudiant.code_ine or "",
|
||||
"NIP": etudiant.code_nip or "",
|
||||
"Nom": etudiant.nom,
|
||||
"Prenom": etudiant.prenom,
|
||||
"Civilite": etudiant.civilite_str,
|
||||
"Age": pe_comp.calcul_age(etudiant.date_naissance),
|
||||
"Parcours": parcours,
|
||||
"Date entree": cursus["entree"],
|
||||
"Date diplome": diplome,
|
||||
"Nb semestres": len(formsemestres),
|
||||
}
|
||||
|
||||
# Ajout des noms de semestres parcourus
|
||||
etapes = etapes_du_cursus(formsemestres, nbre_semestres_max)
|
||||
administratif[etudid] |= etapes
|
||||
|
||||
# Construction du dataframe
|
||||
df = pd.DataFrame.from_dict(administratif, orient="index")
|
||||
|
||||
# Tri par nom/prénom
|
||||
df.sort_values(by=["Nom", "Prenom"], inplace=True)
|
||||
return df
|
||||
|
||||
|
||||
def get_semestres_significatifs(formsemestres, annee_diplome):
|
||||
"""Partant d'un ensemble de semestre, renvoie les semestres qui amèneraient les étudiants
|
||||
à être diplômé à l'année visée, y compris s'ils n'avaient pas redoublé et seraient donc
|
||||
diplômé plus tard.
|
||||
|
||||
De fait, supprime les semestres qui conduisent à une diplomation postérieure
|
||||
à celle visée.
|
||||
|
||||
Args:
|
||||
formsemestres: une liste de formsemestres
|
||||
annee_diplome: l'année du diplôme visée
|
||||
|
||||
Returns:
|
||||
Un dictionnaire ``{fid: FormSemestre(fid)}`` dans lequel les semestres
|
||||
amènent à une diplômation antérieur à celle de la diplômation visée par le jury
|
||||
"""
|
||||
# semestres_etudiant = self.cursus[etudid]["formsemestres"]
|
||||
semestres_significatifs = {}
|
||||
for fid in formsemestres:
|
||||
semestre = formsemestres[fid]
|
||||
if pe_comp.get_annee_diplome_semestre(semestre) <= annee_diplome:
|
||||
semestres_significatifs[fid] = semestre
|
||||
return semestres_significatifs
|
||||
|
||||
|
||||
def get_etudiants_dans_semestres(semestres: dict[int, FormSemestre]) -> set:
|
||||
"""Ensemble d'identifiants des étudiants (identifiés via leur ``etudid``)
|
||||
inscrits à l'un des semestres de la liste de ``semestres``.
|
||||
|
||||
Remarque : Les ``cosemestres`` sont généralement obtenus avec ``sco_formsemestre.do_formsemestre_list()``
|
||||
|
||||
Args:
|
||||
semestres: Un dictionnaire ``{fid: Formsemestre(fid)}`` donnant un
|
||||
ensemble d'identifiant de semestres
|
||||
|
@ -397,10 +434,10 @@ def get_etudiants_dans_semestres(semestres: dict[int, FormSemestre]) -> set:
|
|||
"""
|
||||
|
||||
etudiants_ids = set()
|
||||
for fid, sem in semestres.items(): # pour chacun des semestres de la liste
|
||||
for sem in semestres.values(): # pour chacun des semestres de la liste
|
||||
etudiants_du_sem = {ins.etudid for ins in sem.inscriptions}
|
||||
|
||||
pe_print(f" --> {sem} : {len(etudiants_du_sem)} etudiants")
|
||||
pe_affichage.pe_print(f" --> {sem} : {len(etudiants_du_sem)} etudiants")
|
||||
etudiants_ids = (
|
||||
etudiants_ids | etudiants_du_sem
|
||||
) # incluant la suppression des doublons
|
||||
|
@ -408,7 +445,7 @@ def get_etudiants_dans_semestres(semestres: dict[int, FormSemestre]) -> set:
|
|||
return etudiants_ids
|
||||
|
||||
|
||||
def annee_diplome(identite: Identite) -> int:
|
||||
def get_annee_diplome(etud: Identite) -> int | None:
|
||||
"""L'année de diplôme prévue d'un étudiant en fonction de ses semestres
|
||||
d'inscription (pour un BUT).
|
||||
|
||||
|
@ -416,39 +453,65 @@ def annee_diplome(identite: Identite) -> int:
|
|||
identite: L'identité d'un étudiant
|
||||
|
||||
Returns:
|
||||
L'année prévue de sa diplômation
|
||||
|
||||
NOTE: Pourrait être déplacé dans app.models.etudiants.Identite
|
||||
L'année prévue de sa diplômation, ou None si aucun semestre
|
||||
"""
|
||||
formsemestres = identite.get_formsemestres()
|
||||
if formsemestres:
|
||||
return max(
|
||||
[pe_comp.get_annee_diplome_semestre(sem_base) for sem_base in formsemestres]
|
||||
)
|
||||
else:
|
||||
return None
|
||||
formsemestres_apc = get_semestres_apc(etud)
|
||||
|
||||
if formsemestres_apc:
|
||||
dates_possibles_diplome = []
|
||||
# Années de diplômation prédites en fonction des semestres
|
||||
# (d'une formation APC) d'un étudiant
|
||||
for sem_base in formsemestres_apc:
|
||||
annee = pe_comp.get_annee_diplome_semestre(sem_base)
|
||||
if annee:
|
||||
dates_possibles_diplome.append(annee)
|
||||
if dates_possibles_diplome:
|
||||
return max(dates_possibles_diplome)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def arret_de_formation(identite: Identite, cosemestres: list[FormSemestre]) -> bool:
|
||||
"""Détermine si un étudiant a arrêté sa formation. Il peut s'agir :
|
||||
def get_semestres_apc(identite: Identite) -> list:
|
||||
"""Liste des semestres d'un étudiant qui correspondent à une formation APC.
|
||||
|
||||
* d'une réorientation à l'initiative du jury de semestre ou d'une démission (on pourrait
|
||||
utiliser les code NAR pour réorienté & DEM pour démissionnaire des résultats du jury renseigné dans la BDD,
|
||||
mais pas nécessaire ici)
|
||||
Args:
|
||||
identite: L'identité d'un étudiant
|
||||
|
||||
* d'un arrêt volontaire : l'étudiant disparait des listes d'inscrits (sans pour autant avoir été indiqué NAR ou DEM).
|
||||
Returns:
|
||||
Liste de ``FormSemestre`` correspondant à une formation APC
|
||||
"""
|
||||
semestres = identite.get_formsemestres()
|
||||
semestres_apc = []
|
||||
for sem in semestres:
|
||||
if sem.formation.is_apc():
|
||||
semestres_apc.append(sem)
|
||||
return semestres_apc
|
||||
|
||||
Dans les cas, on considérera que l'étudiant a arrêté sa formation s'il n'est pas dans l'un des "derniers" cosemestres
|
||||
(semestres conduisant à la même année de diplômation) connu dans Scodoc.
|
||||
|
||||
Par ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc), l'étudiant doit appartenir à une
|
||||
instance des S5 qui conduisent à la diplomation dans l'année visée. S'il n'est que dans un S4, il a sans doute
|
||||
arrêté. A moins qu'il ne soit parti à l'étranger et là, pas de notes.
|
||||
def arret_de_formation(etud: Identite, cosemestres: dict[int, FormSemestre]) -> bool:
|
||||
"""Détermine si un étudiant a arrêté sa formation (volontairement ou non). Il peut s'agir :
|
||||
|
||||
* d'une réorientation à l'initiative du jury de semestre ou d'une démission
|
||||
(on pourrait utiliser les code NAR pour réorienté & DEM pour démissionnaire
|
||||
des résultats du jury renseigné dans la BDD, mais pas nécessaire ici)
|
||||
|
||||
* d'un arrêt volontaire : l'étudiant disparait des listes d'inscrits (sans pour
|
||||
autant avoir été indiqué NAR ou DEM).
|
||||
|
||||
Dans les cas, on considérera que l'étudiant a arrêté sa formation s'il n'est pas
|
||||
dans l'un des "derniers" cosemestres (semestres conduisant à la même année de diplômation)
|
||||
connu dans Scodoc. Par "derniers" cosemestres, est fait le choix d'analyser tous les cosemestres
|
||||
de rang/semestre_id supérieur (et donc de dates) au dernier semestre dans lequel il a été inscrit.
|
||||
|
||||
Par ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc),
|
||||
l'étudiant doit appartenir à une instance des S5 qui conduisent à la diplomation dans
|
||||
l'année visée. S'il n'est que dans un S4, il a sans doute arrêté. A moins qu'il ne soit
|
||||
parti à l'étranger et là, pas de notes.
|
||||
TODO:: Cas de l'étranger, à coder/tester
|
||||
|
||||
**Attention** : Cela suppose que toutes les instances d'un semestre donné (par ex: toutes les instances de S6
|
||||
accueillant un étudiant soient créées ; sinon les étudiants non inscrits dans un S6 seront considérés comme
|
||||
ayant abandonnés)
|
||||
**Attention** : Cela suppose que toutes les instances d'un semestre donné
|
||||
(par ex: toutes les instances de S6 accueillant un étudiant soient créées ; sinon les
|
||||
étudiants non inscrits dans un S6 seront considérés comme ayant abandonnés)
|
||||
TODO:: Peut-être à mettre en regard avec les propositions d'inscriptions d'étudiants dans un nouveau semestre
|
||||
|
||||
Pour chaque étudiant, recherche son dernier semestre en date (validé ou non) et
|
||||
|
@ -458,69 +521,133 @@ def arret_de_formation(identite: Identite, cosemestres: list[FormSemestre]) -> b
|
|||
dans lequel il aurait pu s'inscrire mais ne l'a pas fait.
|
||||
|
||||
Args:
|
||||
identite: L'identité d'un étudiant
|
||||
etud: L'identité d'un étudiant
|
||||
cosemestres: Les semestres donnant lieu à diplômation (sans redoublement) en date du jury
|
||||
|
||||
Returns:
|
||||
Est-il réorienté, démissionnaire ou a-t-il arrêté de son propre chef sa formation ?
|
||||
|
||||
TODO:: A reprendre pour le cas des étudiants à l'étranger
|
||||
TODO:: A reprendre si BUT avec semestres décalés
|
||||
"""
|
||||
etudid = identite.etudid
|
||||
|
||||
"""Son dernier semestre en date"""
|
||||
semestres = {sem.semestre_id: sem for sem in identite.get_formsemestres()}
|
||||
dernier_formsemestre = get_dernier_semestre_en_date(semestres)
|
||||
numero_dernier_formsemestre = dernier_formsemestre.semestre_id
|
||||
|
||||
"""Les numéro de semestres possible dans lesquels il pourrait s'incrire"""
|
||||
# semestre impair => passage de droit en semestre pair suivant (effet de l'annualisation)
|
||||
if numero_dernier_formsemestre % 2 == 1:
|
||||
numeros_possibles = list(
|
||||
range(numero_dernier_formsemestre + 1, pe_comp.NBRE_SEMESTRES_DIPLOMANT)
|
||||
)
|
||||
# semestre pair => passage en année supérieure ou redoublement
|
||||
else: #
|
||||
numeros_possibles = list(
|
||||
range(
|
||||
max(numero_dernier_formsemestre - 1, 1),
|
||||
pe_comp.NBRE_SEMESTRES_DIPLOMANT,
|
||||
)
|
||||
)
|
||||
|
||||
"""Y-a-t-il des cosemestres dans lesquels il aurait pu s'incrire ?"""
|
||||
formsestres_superieurs_possibles = []
|
||||
for fid, sem in cosemestres.items(): # Les semestres ayant des inscrits
|
||||
if (
|
||||
fid != dernier_formsemestre.formsemestre_id
|
||||
and sem.semestre_id in numeros_possibles
|
||||
and sem.date_debut.year >= dernier_formsemestre.date_debut.year
|
||||
): # date de debut des semestres possibles postérieur au dernier semestre de l'étudiant et de niveau plus élevé que le dernier semestre valide de l'étudiant
|
||||
formsestres_superieurs_possibles.append(fid)
|
||||
|
||||
if len(formsestres_superieurs_possibles) > 0:
|
||||
# Les semestres APC de l'étudiant
|
||||
semestres = get_semestres_apc(etud)
|
||||
semestres_apc = {sem.semestre_id: sem for sem in semestres}
|
||||
if not semestres_apc:
|
||||
return True
|
||||
|
||||
return False
|
||||
# Le dernier semestre de l'étudiant
|
||||
dernier_formsemestre = semestres[0]
|
||||
rang_dernier_semestre = dernier_formsemestre.semestre_id
|
||||
|
||||
# Les cosemestres de rang supérieur ou égal à celui de formsemestre, triés par rang,
|
||||
# sous la forme ``{semestre_id: [liste des comestres associé à ce semestre_id]}``
|
||||
cosemestres_tries_par_rang = pe_comp.tri_semestres_par_rang(cosemestres)
|
||||
|
||||
cosemestres_superieurs = {}
|
||||
for rang in cosemestres_tries_par_rang:
|
||||
if rang > rang_dernier_semestre:
|
||||
cosemestres_superieurs[rang] = cosemestres_tries_par_rang[rang]
|
||||
|
||||
# Si pas d'autres cosemestres postérieurs
|
||||
if not cosemestres_superieurs:
|
||||
return False
|
||||
|
||||
# Pour chaque rang de (co)semestres, y-a-il un dans lequel il est inscrit ?
|
||||
etat_inscriptions = {rang: False for rang in cosemestres_superieurs}
|
||||
for rang in etat_inscriptions:
|
||||
for sem in cosemestres_superieurs[rang]:
|
||||
etudiants_du_sem = {ins.etudid for ins in sem.inscriptions}
|
||||
if etud.etudid in etudiants_du_sem:
|
||||
etat_inscriptions[rang] = True
|
||||
|
||||
# Vérifie qu'il n'y a pas de "trous" dans les rangs des cosemestres
|
||||
rangs = sorted(etat_inscriptions.keys())
|
||||
if list(rangs) != list(range(min(rangs), max(rangs) + 1)):
|
||||
difference = set(range(min(rangs), max(rangs) + 1)) - set(rangs)
|
||||
affichage = ",".join([f"S{val}" for val in difference])
|
||||
raise ScoValueError(
|
||||
f"Il manque le(s) semestre(s) {affichage} au cursus de {etud.etat_civil} ({etud.etudid})."
|
||||
)
|
||||
|
||||
# Est-il inscrit à tous les semestres de rang supérieur ? Si non, est démissionnaire
|
||||
est_demissionnaire = sum(etat_inscriptions.values()) != len(rangs)
|
||||
if est_demissionnaire:
|
||||
non_inscrit_a = [
|
||||
rang for rang in etat_inscriptions if not etat_inscriptions[rang]
|
||||
]
|
||||
affichage = ", ".join([f"S{val}" for val in non_inscrit_a])
|
||||
pe_affichage.pe_print(
|
||||
f"--> ⛔ {etud.etat_civil} ({etud.etudid}), non inscrit dans {affichage} amenant à diplômation"
|
||||
)
|
||||
else:
|
||||
pe_affichage.pe_print(f"--> ✅ {etud.etat_civil} ({etud.etudid})")
|
||||
|
||||
return est_demissionnaire
|
||||
|
||||
|
||||
def get_dernier_semestre_en_date(semestres: dict[int, FormSemestre]) -> FormSemestre:
|
||||
"""Renvoie le dernier semestre en **date de fin** d'un dictionnaire
|
||||
de semestres (potentiellement non trié) de la forme ``{fid: FormSemestre(fid)}``.
|
||||
def etapes_du_cursus(
|
||||
semestres: dict[int, FormSemestre], nbre_etapes_max: int
|
||||
) -> list[str]:
|
||||
"""Partant d'un dictionnaire de semestres (qui retrace
|
||||
la scolarité d'un étudiant), liste les noms des
|
||||
semestres (en version abbrégée)
|
||||
qu'un étudiant a suivi au cours de sa scolarité à l'IUT.
|
||||
Les noms des semestres sont renvoyés dans un dictionnaire
|
||||
``{"etape i": nom_semestre_a_etape_i}``
|
||||
avec i variant jusqu'à nbre_semestres_max. (S'il n'y a pas de semestre à l'étape i,
|
||||
le nom affiché est vide.
|
||||
|
||||
La fonction suppose la liste des semestres triées par ordre
|
||||
décroissant de date.
|
||||
|
||||
Args:
|
||||
semestres: Un dictionnaire de semestres
|
||||
semestres: une liste de ``FormSemestre``
|
||||
nbre_etapes_max: le nombre d'étapes max prise en compte
|
||||
|
||||
Return:
|
||||
Le FormSemestre du semestre le plus récent
|
||||
Returns:
|
||||
Une liste de nom de semestre (dans le même ordre que les ``semestres``)
|
||||
|
||||
See also:
|
||||
app.pe.pe_affichage.nom_semestre_etape
|
||||
"""
|
||||
if semestres:
|
||||
fid_dernier_semestre = list(semestres.keys())[0]
|
||||
dernier_semestre: FormSemestre = semestres[fid_dernier_semestre]
|
||||
for fid in semestres:
|
||||
if semestres[fid].date_fin > dernier_semestre.date_fin:
|
||||
dernier_semestre = semestres[fid]
|
||||
return dernier_semestre
|
||||
else:
|
||||
return None
|
||||
assert len(semestres) <= nbre_etapes_max
|
||||
|
||||
noms = [nom_semestre_etape(sem, avec_fid=False) for (fid, sem) in semestres.items()]
|
||||
noms = noms[::-1] # trie par ordre croissant
|
||||
|
||||
dico = {f"Etape {i+1}": "" for i in range(nbre_etapes_max)}
|
||||
for i, nom in enumerate(noms): # Charge les noms de semestres
|
||||
dico[f"Etape {i+1}"] = nom
|
||||
return dico
|
||||
|
||||
|
||||
def nom_semestre_etape(semestre: FormSemestre, avec_fid=False) -> str:
|
||||
"""Nom d'un semestre à afficher dans le descriptif des étapes de la scolarité
|
||||
d'un étudiant.
|
||||
|
||||
Par ex: Pour un S2, affiche ``"Semestre 2 FI S014-2015 (129)"`` avec :
|
||||
|
||||
* 2 le numéro du semestre,
|
||||
* FI la modalité,
|
||||
* 2014-2015 les dates
|
||||
|
||||
Args:
|
||||
semestre: Un ``FormSemestre``
|
||||
avec_fid: Ajoute le n° du semestre à la description
|
||||
|
||||
Returns:
|
||||
La chaine de caractères décrivant succintement le semestre
|
||||
"""
|
||||
formation: Formation = semestre.formation
|
||||
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
|
||||
|
||||
description = [
|
||||
parcours.SESSION_NAME.capitalize(),
|
||||
str(semestre.semestre_id),
|
||||
semestre.modalite, # eg FI ou FC
|
||||
f"{semestre.date_debut.year}-{semestre.date_fin.year}",
|
||||
]
|
||||
if avec_fid:
|
||||
description.append(f"(#{semestre.formsemestre_id})")
|
||||
|
||||
return " ".join(description)
|
||||
|
|
|
@ -1,117 +0,0 @@
|
|||
from app.pe.pe_tabletags import TableTag
|
||||
from app.pe.pe_etudiant import EtudiantsJuryPE
|
||||
from app.pe.pe_trajectoire import Trajectoire, TrajectoiresJuryPE
|
||||
from app.pe.pe_trajectoiretag import TrajectoireTag
|
||||
from app.comp import moy_sem
|
||||
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
|
||||
|
||||
class AggregatInterclasseTag(TableTag):
|
||||
"""Interclasse l'ensemble des étudiants diplômés à une année
|
||||
donnée (celle du jury), pour un aggrégat donné (par ex: 'S2', '3S')
|
||||
en reportant :
|
||||
|
||||
* les moyennes obtenues sur la trajectoire qu'il ont suivi pour atteindre le numéro de semestre de fin de l'aggrégat (indépendamment de son
|
||||
formsemestres)
|
||||
* calculant le classement sur les étudiants diplômes
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------------------------------------------------
|
||||
def __init__(
|
||||
self,
|
||||
nom_aggregat: str,
|
||||
etudiants: EtudiantsJuryPE,
|
||||
trajectoires_jury_pe: TrajectoiresJuryPE,
|
||||
trajectoires_taggues: dict[tuple, TrajectoireTag],
|
||||
):
|
||||
# Table nommée au nom de l'aggrégat (par ex: 3S)
|
||||
TableTag.__init__(self, nom_aggregat)
|
||||
|
||||
"""Les étudiants diplômés et leurs trajectoires (cf. trajectoires.suivis)""" # TODO
|
||||
self.diplomes_ids = etudiants.etudiants_diplomes
|
||||
self.etudiants_diplomes = {etudid for etudid in self.diplomes_ids}
|
||||
|
||||
# Les trajectoires (et leur version tagguées), en ne gardant que celles associées à l'aggrégat
|
||||
self.trajectoires: dict[int, Trajectoire] = {}
|
||||
for trajectoire_id in trajectoires_jury_pe.trajectoires:
|
||||
trajectoire = trajectoires_jury_pe.trajectoires[trajectoire_id]
|
||||
if trajectoire_id[0] == nom_aggregat:
|
||||
self.trajectoires[trajectoire_id] = trajectoire
|
||||
|
||||
self.trajectoires_taggues: dict[int, Trajectoire] = {}
|
||||
for trajectoire_id in self.trajectoires:
|
||||
self.trajectoires_taggues[trajectoire_id] = trajectoires_taggues[
|
||||
trajectoire_id
|
||||
]
|
||||
|
||||
# Les trajectoires suivies par les étudiants du jury, en ne gardant que
|
||||
# celles associées aux diplomés
|
||||
self.suivi: dict[int, Trajectoire] = {}
|
||||
for etudid in self.diplomes_ids:
|
||||
self.suivi[etudid] = trajectoires_jury_pe.suivi[etudid][nom_aggregat]
|
||||
|
||||
"""Les tags"""
|
||||
self.tags_sorted = self.do_taglist()
|
||||
|
||||
# Construit la matrice de notes
|
||||
self.notes = self.compute_notes_matrice()
|
||||
|
||||
# Synthétise les moyennes/classements par tag
|
||||
self.moyennes_tags = {}
|
||||
for tag in self.tags_sorted:
|
||||
moy_gen_tag = self.notes[tag]
|
||||
class_gen_tag = moy_sem.comp_ranks_series(moy_gen_tag)[1] # en int
|
||||
self.moyennes_tags[tag] = {
|
||||
"notes": moy_gen_tag,
|
||||
"classements": class_gen_tag,
|
||||
"min": moy_gen_tag.min(),
|
||||
"max": moy_gen_tag.max(),
|
||||
"moy": moy_gen_tag.mean(),
|
||||
"nb_inscrits": len(moy_gen_tag),
|
||||
}
|
||||
|
||||
def get_repr(self) -> str:
|
||||
"""Une représentation textuelle"""
|
||||
return f"Aggrégat {self.nom}"
|
||||
|
||||
def do_taglist(self):
|
||||
"""Synthétise les tags à partir des trajectoires_tagguées
|
||||
|
||||
Returns:
|
||||
Une liste de tags triés par ordre alphabétique
|
||||
"""
|
||||
tags = []
|
||||
for trajectoire in self.trajectoires_taggues.values():
|
||||
tags.extend(trajectoire.tags_sorted)
|
||||
return sorted(set(tags))
|
||||
|
||||
def compute_notes_matrice(self):
|
||||
"""Construit la matrice de notes (etudid x tags)
|
||||
retraçant les moyennes obtenues par les étudiants dans les semestres associés à
|
||||
l'aggrégat (une trajectoire ayant pour numéro de semestre final, celui de l'aggrégat).
|
||||
"""
|
||||
# nb_tags = len(self.tags_sorted) unused ?
|
||||
# nb_etudiants = len(self.diplomes_ids)
|
||||
|
||||
# Index de la matrice (etudids -> dim 0, tags -> dim 1)
|
||||
etudids = list(self.diplomes_ids)
|
||||
tags = self.tags_sorted
|
||||
|
||||
# Partant d'un dataframe vierge
|
||||
df = pd.DataFrame(np.nan, index=etudids, columns=tags)
|
||||
|
||||
for trajectoire in self.trajectoires_taggues.values():
|
||||
# Charge les moyennes par tag de la trajectoire tagguée
|
||||
notes = trajectoire.notes
|
||||
# Etudiants/Tags communs entre la trajectoire_tagguée et les données interclassées
|
||||
etudids_communs = df.index.intersection(notes.index)
|
||||
tags_communs = df.columns.intersection(notes.columns)
|
||||
|
||||
# Injecte les notes par tag
|
||||
df.loc[etudids_communs, tags_communs] = notes.loc[
|
||||
etudids_communs, tags_communs
|
||||
]
|
||||
|
||||
return df
|
1120
app/pe/pe_jury.py
1120
app/pe/pe_jury.py
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,289 @@
|
|||
import app.pe.pe_comp
|
||||
from app.pe.rcss import pe_rcs, pe_trajectoires, pe_rcsemx
|
||||
import app.pe.pe_etudiant as pe_etudiant
|
||||
import app.pe.pe_comp as pe_comp
|
||||
from app.models import FormSemestre
|
||||
from app.pe import pe_affichage
|
||||
|
||||
|
||||
class RCSsJuryPE:
|
||||
"""Classe centralisant tous les regroupements cohérents de
|
||||
semestres (RCS) des étudiants à prendre en compte dans un jury PE
|
||||
|
||||
Args:
|
||||
annee_diplome: L'année de diplomation
|
||||
"""
|
||||
|
||||
def __init__(self, annee_diplome: int, etudiants: pe_etudiant.EtudiantsJuryPE):
|
||||
self.annee_diplome = annee_diplome
|
||||
"""Année de diplômation"""
|
||||
|
||||
self.etudiants = etudiants
|
||||
"""Les étudiants recensés"""
|
||||
|
||||
self.trajectoires: dict[tuple(int, str) : pe_trajectoires.Trajectoire] = {}
|
||||
"""Ensemble des trajectoires recensées (regroupement de (form)semestres BUT)"""
|
||||
|
||||
self.trajectoires_suivies: dict[int:dict] = {}
|
||||
"""Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS,
|
||||
sa Trajectoire : {etudid: {nom_RCS: Trajectoire}}"""
|
||||
|
||||
self.semXs: dict[tuple(int, str) : pe_trajectoires.SemX] = {}
|
||||
"""Ensemble des SemX recensés (regroupement de (form)semestre BUT de rang x) :
|
||||
{(nom_RCS, fid_terminal): SemX}"""
|
||||
|
||||
self.semXs_suivis: dict[int:dict] = {}
|
||||
"""Dictionnaire associant, pour chaque étudiant et pour chaque RCS de type Sx,
|
||||
son SemX : {etudid: {nom_RCS_de_type_Sx: SemX}}"""
|
||||
|
||||
self.rcsemxs: dict[tuple(int, str) : pe_rcsemx.RCSemX] = {}
|
||||
"""Ensemble des RCSemX (regroupement de SemX donnant les résultats aux sems de rang x)
|
||||
recensés : {(nom_RCS, fid_terminal): RCSemX}"""
|
||||
|
||||
self.rcsemxs_suivis: dict[int:str] = {}
|
||||
"""Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS,
|
||||
son RCSemX : {etudid: {nom_RCS: RCSemX}}"""
|
||||
|
||||
def cree_trajectoires(self):
|
||||
"""Créé toutes les trajectoires, au regard du cursus des étudiants
|
||||
analysés + les mémorise dans les données de l'étudiant
|
||||
|
||||
Args:
|
||||
etudiants: Les étudiants à prendre en compte dans le Jury PE
|
||||
"""
|
||||
|
||||
tous_les_aggregats = pe_rcs.TOUS_LES_RCS
|
||||
|
||||
for etudid in self.etudiants.cursus:
|
||||
self.trajectoires_suivies[etudid] = self.etudiants.trajectoires[etudid]
|
||||
|
||||
for nom_rcs in tous_les_aggregats:
|
||||
# L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre
|
||||
# terminal (par ex: S3) et son numéro (par ex: 3)
|
||||
noms_semestres = pe_rcs.TYPES_RCS[nom_rcs]["aggregat"]
|
||||
nom_semestre_final = noms_semestres[-1]
|
||||
|
||||
for etudid in self.etudiants.cursus:
|
||||
# Le (ou les) semestre(s) marquant la fin du cursus de l'étudiant
|
||||
sems_final = self.etudiants.cursus[etudid][nom_semestre_final]
|
||||
if sems_final:
|
||||
# Le formsemestre final (dernier en date) de l'étudiant,
|
||||
# marquant la fin de son aggrégat (par ex: son dernier S3 en date)
|
||||
formsemestre_final = app.pe.pe_comp.get_dernier_semestre_en_date(
|
||||
sems_final
|
||||
)
|
||||
|
||||
# Ajout (si nécessaire) et récupération du RCS associé
|
||||
rcs_id = (nom_rcs, formsemestre_final.formsemestre_id)
|
||||
if rcs_id not in self.trajectoires:
|
||||
self.trajectoires[rcs_id] = pe_trajectoires.Trajectoire(
|
||||
nom_rcs, formsemestre_final
|
||||
)
|
||||
rcs = self.trajectoires[rcs_id]
|
||||
|
||||
# La liste des semestres de l'étudiant à prendre en compte
|
||||
# pour cette trajectoire
|
||||
semestres_a_aggreger = get_rcs_etudiant(
|
||||
self.etudiants.cursus[etudid], formsemestre_final, nom_rcs
|
||||
)
|
||||
|
||||
# Ajout des semestres au RCS
|
||||
rcs.add_semestres(semestres_a_aggreger)
|
||||
|
||||
# Mémorise le RCS suivi par l'étudiant
|
||||
self.trajectoires_suivies[etudid][nom_rcs] = rcs
|
||||
self.etudiants.trajectoires[etudid][nom_rcs] = rcs
|
||||
|
||||
def cree_semxs(self):
|
||||
"""Créé les SemXs (trajectoires/combinaisons de semestre de même rang x),
|
||||
en ne conservant dans les trajectoires que les regroupements
|
||||
de type Sx"""
|
||||
self.semXs = {}
|
||||
for rcs_id, trajectoire in self.trajectoires.items():
|
||||
if trajectoire.nom in pe_rcs.TOUS_LES_SEMESTRES:
|
||||
self.semXs[rcs_id] = pe_trajectoires.SemX(trajectoire)
|
||||
|
||||
# L'association (pour chaque étudiant entre chaque Sx et le SemX associé)
|
||||
self.semXs_suivis = {}
|
||||
for etudid in self.etudiants.trajectoires:
|
||||
self.semXs_suivis[etudid] = {
|
||||
agregat: None for agregat in pe_rcs.TOUS_LES_SEMESTRES
|
||||
}
|
||||
for agregat in pe_rcs.TOUS_LES_SEMESTRES:
|
||||
trajectoire = self.etudiants.trajectoires[etudid][agregat]
|
||||
if trajectoire:
|
||||
rcs_id = trajectoire.rcs_id
|
||||
semX = self.semXs[rcs_id]
|
||||
self.semXs_suivis[etudid][agregat] = semX
|
||||
self.etudiants.semXs[etudid][agregat] = semX
|
||||
|
||||
def cree_rcsemxs(self, options={"moyennes_ues_rcues": True}):
|
||||
"""Créé tous les RCSemXs, au regard du cursus des étudiants
|
||||
analysés (trajectoires traduisant son parcours dans les
|
||||
différents semestres) + les mémorise dans les données de l'étudiant
|
||||
"""
|
||||
self.rcsemxs_suivis = {}
|
||||
self.rcsemxs = {}
|
||||
|
||||
if "moyennes_ues_rcues" in options and options["moyennes_ues_rcues"] == False:
|
||||
# Pas de RCSemX généré
|
||||
pe_affichage.pe_print("⚠️ Pas de RCSemX générés")
|
||||
return
|
||||
|
||||
# Pour tous les étudiants du jury
|
||||
pas_de_semestres = []
|
||||
for etudid in self.trajectoires_suivies:
|
||||
self.rcsemxs_suivis[etudid] = {
|
||||
nom_rcs: None for nom_rcs in pe_rcs.TOUS_LES_RCS_AVEC_PLUSIEURS_SEM
|
||||
}
|
||||
|
||||
# Pour chaque aggréggat de type xA ou Sx ou xS
|
||||
for agregat in pe_rcs.TOUS_LES_RCS:
|
||||
trajectoire = self.trajectoires_suivies[etudid][agregat]
|
||||
if not trajectoire:
|
||||
self.rcsemxs_suivis[etudid][agregat] = None
|
||||
else:
|
||||
# Identifiant de la trajectoire => donnera ceux du RCSemX
|
||||
tid = trajectoire.rcs_id
|
||||
# Ajout du RCSemX
|
||||
if tid not in self.rcsemxs:
|
||||
self.rcsemxs[tid] = pe_rcsemx.RCSemX(
|
||||
trajectoire.nom, trajectoire.formsemestre_final
|
||||
)
|
||||
|
||||
# Récupére les SemX (RC de type Sx) associés aux semestres de son cursus
|
||||
# Par ex: dans S1+S2+S1+S2+S3 => les 2 S1 devient le SemX('S1'), les 2 S2 le SemX('S2'), etc..
|
||||
|
||||
# Les Sx pris en compte dans l'aggrégat
|
||||
noms_sems_aggregat = pe_rcs.TYPES_RCS[agregat]["aggregat"]
|
||||
|
||||
semxs_a_aggreger = {}
|
||||
for Sx in noms_sems_aggregat:
|
||||
semestres_etudiants = self.etudiants.cursus[etudid][Sx]
|
||||
if not semestres_etudiants:
|
||||
pas_de_semestres += [
|
||||
f"{Sx} pour {self.etudiants.identites[etudid].nomprenom}"
|
||||
]
|
||||
else:
|
||||
semx_id = get_semx_from_semestres_aggreges(
|
||||
self.semXs, semestres_etudiants
|
||||
)
|
||||
if not semx_id:
|
||||
raise (
|
||||
"Il manque un SemX pour créer les RCSemX dans cree_rcsemxs"
|
||||
)
|
||||
# Les SemX à ajouter au RCSemX
|
||||
semxs_a_aggreger[semx_id] = self.semXs[semx_id]
|
||||
|
||||
# Ajout des SemX à ceux à aggréger dans le RCSemX
|
||||
rcsemx = self.rcsemxs[tid]
|
||||
rcsemx.add_semXs(semxs_a_aggreger)
|
||||
|
||||
# Mémoire du RCSemX aux informations de suivi de l'étudiant
|
||||
self.rcsemxs_suivis[etudid][agregat] = rcsemx
|
||||
self.etudiants.rcsemXs[etudid][agregat] = rcsemx
|
||||
|
||||
# Affichage des étudiants pour lesquels il manque un semestre
|
||||
pas_de_semestres = sorted(set(pas_de_semestres))
|
||||
if pas_de_semestres:
|
||||
pe_affichage.pe_print("⚠️ Semestres manquants :")
|
||||
pe_affichage.pe_print(
|
||||
"\n".join([" " * 10 + psd for psd in pas_de_semestres])
|
||||
)
|
||||
|
||||
|
||||
def get_rcs_etudiant(
|
||||
semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str
|
||||
) -> dict[int, FormSemestre]:
|
||||
"""Ensemble des semestres parcourus (trajectoire)
|
||||
par un étudiant dans le cadre
|
||||
d'un RCS de type Sx, iA ou iS et ayant pour semestre terminal `formsemestre_final`.
|
||||
|
||||
Par ex: pour un RCS "3S", dont le formsemestre_terminal est un S3, regroupe
|
||||
le ou les S1 qu'il a suivi (1 ou 2 si redoublement) + le ou les S2 + le ou les S3.
|
||||
|
||||
Les semestres parcourus sont antérieurs (en terme de date de fin)
|
||||
au formsemestre_terminal.
|
||||
|
||||
Args:
|
||||
cursus: Dictionnaire {fid: Formsemestre} donnant l'ensemble des semestres
|
||||
dans lesquels l'étudiant a été inscrit
|
||||
formsemestre_final: le semestre final visé
|
||||
nom_rcs: Nom du RCS visé
|
||||
"""
|
||||
numero_semestre_terminal = formsemestre_final.semestre_id
|
||||
# semestres_significatifs = self.get_semestres_significatifs(etudid)
|
||||
semestres_significatifs = {}
|
||||
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
|
||||
semestres_significatifs = semestres_significatifs | semestres[f"S{i}"]
|
||||
|
||||
if nom_rcs.startswith("S"): # les semestres
|
||||
numero_semestres_possibles = [numero_semestre_terminal]
|
||||
elif nom_rcs.endswith("A"): # les années
|
||||
numero_semestres_possibles = [
|
||||
int(sem[-1]) for sem in pe_rcs.TYPES_RCS[nom_rcs]["aggregat"]
|
||||
]
|
||||
assert numero_semestre_terminal in numero_semestres_possibles
|
||||
else: # les xS = tous les semestres jusqu'à Sx (eg S1, S2, S3 pour un S3 terminal)
|
||||
numero_semestres_possibles = list(range(1, numero_semestre_terminal + 1))
|
||||
|
||||
semestres_aggreges = {}
|
||||
for fid, semestre in semestres_significatifs.items():
|
||||
# Semestres parmi ceux de n° possibles & qui lui sont antérieurs
|
||||
if (
|
||||
semestre.semestre_id in numero_semestres_possibles
|
||||
and semestre.date_fin <= formsemestre_final.date_fin
|
||||
):
|
||||
semestres_aggreges[fid] = semestre
|
||||
return semestres_aggreges
|
||||
|
||||
|
||||
def get_semx_from_semestres_aggreges(
|
||||
semXs: dict[(str, int) : pe_trajectoires.SemX],
|
||||
semestres_a_aggreger: dict[(str, int):FormSemestre],
|
||||
) -> (str, int):
|
||||
"""Partant d'un dictionnaire de SemX (de la forme
|
||||
``{ (nom_rcs, fid): SemX }, et connaissant une liste
|
||||
de (form)semestres suivis, renvoie l'identifiant
|
||||
(nom_rcs, fid) du SemX qui lui correspond.
|
||||
|
||||
Le SemX qui correspond est tel que :
|
||||
|
||||
* le semestre final du SemX correspond au dernier semestre en date des
|
||||
semestres_a_aggreger
|
||||
* le rang du SemX est le même que celui des semestres_aggreges
|
||||
* les semestres_a_aggreger (plus large, car contenant plusieurs
|
||||
parcours), matchent avec les semestres aggrégés
|
||||
par le SemX
|
||||
|
||||
|
||||
Returns:
|
||||
rcf_id: L'identifiant du RCF trouvé
|
||||
"""
|
||||
assert semestres_a_aggreger, "Pas de semestres à aggréger"
|
||||
rangs_a_aggreger = [sem.semestre_id for fid, sem in semestres_a_aggreger.items()]
|
||||
assert (
|
||||
len(set(rangs_a_aggreger)) == 1
|
||||
), "Tous les sem à aggréger doivent être de même rang"
|
||||
|
||||
# Le dernier semestre des semestres à regrouper
|
||||
dernier_sem_a_aggreger = pe_comp.get_dernier_semestre_en_date(semestres_a_aggreger)
|
||||
|
||||
semxs_ids = [] # Au cas où il y ait plusieurs solutions
|
||||
for semx_id, semx in semXs.items():
|
||||
# Même semestre final ?
|
||||
if semx.get_formsemestre_id_final() == dernier_sem_a_aggreger.formsemestre_id:
|
||||
# Les fids
|
||||
fids_a_aggreger = set(semestres_a_aggreger.keys())
|
||||
# Ceux du semx
|
||||
fids_semx = set(semx.semestres_aggreges.keys())
|
||||
if fids_a_aggreger.issubset(
|
||||
fids_semx
|
||||
): # tous les semestres du semx correspond à des sems de la trajectoire
|
||||
semxs_ids += [semx_id]
|
||||
if len(semxs_ids) == 0:
|
||||
return None # rien trouvé
|
||||
elif len(semxs_ids) == 1:
|
||||
return semxs_ids[0]
|
||||
else:
|
||||
raise "Plusieurs solutions :)"
|
|
@ -1,488 +0,0 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Fri Sep 9 09:15:05 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
from app import db, log
|
||||
from app.comp import res_sem, moy_ue, moy_sem
|
||||
from app.comp.res_compat import NotesTableCompat
|
||||
from app.comp.res_sem import load_formsemestre_results
|
||||
from app.models import FormSemestre
|
||||
from app.models.moduleimpls import ModuleImpl
|
||||
|
||||
from app.scodoc import sco_tag_module
|
||||
from app.scodoc.codes_cursus import UE_SPORT
|
||||
import app.pe.pe_comp as pe_comp
|
||||
from app.pe.pe_tabletags import (TableTag, TAGS_RESERVES)
|
||||
|
||||
class SemestreTag(TableTag):
|
||||
"""Un SemestreTag représente les résultats des étudiants à un semestre, en donnant
|
||||
accès aux moyennes par tag.
|
||||
Il s'appuie principalement sur FormSemestre et sur ResultatsSemestreBUT.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fonctions d'initialisation
|
||||
# -----------------------------------------------------------------------------
|
||||
def __init__(self, nom: str, formsemestre_id: int):
|
||||
"""
|
||||
Args:
|
||||
nom: Nom à donner au SemestreTag
|
||||
formsemestre_id: Identifiant du FormSemestre sur lequel il se base
|
||||
"""
|
||||
TableTag.__init__(self, nom=nom)
|
||||
|
||||
"""Le semestre"""
|
||||
self.formsemestre_id = formsemestre_id
|
||||
self.formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
|
||||
"""Les résultats du semestre"""
|
||||
self.nt = load_formsemestre_results(self.formsemestre)
|
||||
|
||||
"""Les étudiants"""
|
||||
self.etuds = self.nt.etuds
|
||||
self.etudiants = {etud.etudid: etud.etat_civil for etud in self.etuds}
|
||||
|
||||
"""Les notes, les modules implémentés triés, les étudiants, les coeffs,
|
||||
récupérés notamment de py:mod:`res_but`
|
||||
"""
|
||||
self.sem_cube = self.nt.sem_cube
|
||||
self.modimpls_sorted = self.nt.formsemestre.modimpls_sorted
|
||||
self.modimpl_coefs_df = self.nt.modimpl_coefs_df
|
||||
|
||||
"""Les inscriptions au module et les dispenses d'UE"""
|
||||
self.modimpl_inscr_df = self.nt.modimpl_inscr_df
|
||||
self.ues = self.nt.ues
|
||||
self.ues_inscr_parcours_df = self.nt.load_ues_inscr_parcours()
|
||||
self.dispense_ues = self.nt.dispense_ues
|
||||
|
||||
"""Les tags (en supprimant les tags réservés)"""
|
||||
self.tags = get_synthese_tags_semestre(self.nt.formsemestre)
|
||||
for tag in TAGS_RESERVES:
|
||||
if tag in self.tags:
|
||||
del self.tags[tag]
|
||||
|
||||
"""Calcul des moyennes & les classements de chaque étudiant à chaque tag"""
|
||||
self.moyennes_tags = {}
|
||||
|
||||
for tag in self.tags:
|
||||
pe_comp.pe_print(f" -> Traitement du tag {tag}")
|
||||
moy_gen_tag = self.compute_moyenne_tag(tag)
|
||||
class_gen_tag = moy_sem.comp_ranks_series(moy_gen_tag)[1] # en int
|
||||
self.moyennes_tags[tag] = {
|
||||
"notes": moy_gen_tag,
|
||||
"classements": class_gen_tag,
|
||||
"min": moy_gen_tag.min(),
|
||||
"max": moy_gen_tag.max(),
|
||||
"moy": moy_gen_tag.mean(),
|
||||
"nb_inscrits": len(moy_gen_tag),
|
||||
}
|
||||
|
||||
"""Ajoute les moyennes générales de BUT pour le semestre considéré"""
|
||||
pe_comp.pe_print(f" -> Traitement du tag but")
|
||||
moy_gen_but = self.nt.etud_moy_gen
|
||||
class_gen_but = self.nt.etud_moy_gen_ranks_int
|
||||
self.moyennes_tags["but"] = {
|
||||
"notes": moy_gen_but,
|
||||
"classements": class_gen_but,
|
||||
"min": moy_gen_but.min(),
|
||||
"max": moy_gen_but.max(),
|
||||
"moy": moy_gen_but.mean(),
|
||||
"nb_inscrits": len(moy_gen_but),
|
||||
}
|
||||
|
||||
"""Synthétise l'ensemble des moyennes dans un dataframe"""
|
||||
self.tags_sorted = sorted(self.moyennes_tags) # les tags par ordre alphabétique
|
||||
self.notes = self.df_tagtable() # Le dataframe synthétique des notes (=moyennes par tag)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def get_etudids(self):
|
||||
"""Renvoie la liste des etud_id des étudiants inscrits au semestre"""
|
||||
return [etud["etudid"] for etud in self.inscrlist]
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def compute_moyenne_tag(self, tag: str) -> list:
|
||||
"""Calcule la moyenne des étudiants pour le tag indiqué,
|
||||
pour ce SemestreTag.
|
||||
|
||||
Sont pris en compte les modules implémentés associés au tag,
|
||||
avec leur éventuel coefficient de **repondération**, en utilisant les notes
|
||||
chargées pour ce SemestreTag.
|
||||
|
||||
Force ou non le calcul de la moyenne lorsque des notes sont manquantes.
|
||||
|
||||
Renvoie les informations sous la forme d'une liste
|
||||
[ (moy, somme_coeff_normalise, etudid), ...]
|
||||
"""
|
||||
|
||||
"""Adaptation du mask de calcul des moyennes au tag visé"""
|
||||
modimpls_mask = [
|
||||
modimpl.module.ue.type != UE_SPORT
|
||||
for modimpl in self.formsemestre.modimpls_sorted
|
||||
]
|
||||
|
||||
"""Désactive tous les modules qui ne sont pas pris en compte pour ce tag"""
|
||||
for i, modimpl in enumerate(self.formsemestre.modimpls_sorted):
|
||||
if modimpl.moduleimpl_id not in self.tags[tag]:
|
||||
modimpls_mask[i] = False
|
||||
|
||||
"""Applique la pondération des coefficients"""
|
||||
modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy()
|
||||
for modimpl_id in self.tags[tag]:
|
||||
ponderation = self.tags[tag][modimpl_id]["ponderation"]
|
||||
modimpl_coefs_ponderes_df[modimpl_id] *= ponderation
|
||||
|
||||
"""Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)"""
|
||||
moyennes_ues_tag = moy_ue.compute_ue_moys_apc(
|
||||
self.sem_cube,
|
||||
self.etuds,
|
||||
self.formsemestre.modimpls_sorted,
|
||||
self.modimpl_inscr_df,
|
||||
modimpl_coefs_ponderes_df,
|
||||
modimpls_mask,
|
||||
self.dispense_ues,
|
||||
block=self.formsemestre.block_moyennes,
|
||||
)
|
||||
|
||||
"""Les ects"""
|
||||
ects = self.ues_inscr_parcours_df.fillna(0.0) * [
|
||||
ue.ects for ue in self.ues if ue.type != UE_SPORT
|
||||
]
|
||||
|
||||
"""Calcule la moyenne générale dans le semestre (pondérée par le ECTS)"""
|
||||
moy_gen_tag = moy_sem.compute_sem_moys_apc_using_ects(
|
||||
moyennes_ues_tag,
|
||||
ects,
|
||||
formation_id=self.formsemestre.formation_id,
|
||||
skip_empty_ues=True,
|
||||
)
|
||||
|
||||
return moy_gen_tag
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def get_noteEtCoeff_modimpl(self, modimpl_id, etudid, profondeur=2):
|
||||
"""Renvoie un couple donnant la note et le coeff normalisé d'un étudiant à un module d'id modimpl_id.
|
||||
La note et le coeff sont extraits :
|
||||
1) soit des données du semestre en normalisant le coefficient par rapport à la somme des coefficients des modules du semestre,
|
||||
2) soit des données des UE précédemment capitalisées, en recherchant un module de même CODE que le modimpl_id proposé,
|
||||
le coefficient normalisé l'étant alors par rapport au total des coefficients du semestre auquel appartient l'ue capitalisée
|
||||
"""
|
||||
(note, coeff_norm) = (None, None)
|
||||
|
||||
modimpl = get_moduleimpl(modimpl_id) # Le module considéré
|
||||
if modimpl == None or profondeur < 0:
|
||||
return (None, None)
|
||||
|
||||
# Y-a-t-il eu capitalisation d'UE ?
|
||||
ue_capitalisees = self.get_ue_capitalisees(
|
||||
etudid
|
||||
) # les ue capitalisées des étudiants
|
||||
ue_capitalisees_id = {
|
||||
ue_cap["ue_id"] for ue_cap in ue_capitalisees
|
||||
} # les id des ue capitalisées
|
||||
|
||||
# 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 or 0.0 # le coeff (! non compatible BUT)
|
||||
coeff_norm = (
|
||||
coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0
|
||||
) # le coeff normalisé
|
||||
|
||||
# Si le module fait partie d'une UE capitalisée
|
||||
elif len(ue_capitalisees) > 0:
|
||||
moy_ue_actuelle = get_moy_ue_from_nt(
|
||||
self.nt, etudid, modimpl_id
|
||||
) # la moyenne actuelle
|
||||
# A quel semestre correspond l'ue capitalisée et quelles sont ses notes ?
|
||||
fids_prec = [
|
||||
ue_cap["formsemestre_id"]
|
||||
for ue_cap in ue_capitalisees
|
||||
if ue_cap["ue_code"] == modimpl.module.ue.ue_code
|
||||
] # and ue['semestre_id'] == semestre_id]
|
||||
if len(fids_prec) > 0:
|
||||
# => le formsemestre_id du semestre dont vient la capitalisation
|
||||
fid_prec = fids_prec[0]
|
||||
# Lecture des notes de ce semestre
|
||||
# le tableau de note du semestre considéré:
|
||||
formsemestre_prec = FormSemestre.get_formsemestre(fid_prec)
|
||||
nt_prec: NotesTableCompat = res_sem.load_formsemestre_results(
|
||||
formsemestre_prec
|
||||
)
|
||||
|
||||
# Y-a-t-il un module équivalent c'est à dire correspondant au même code (le module_id n'étant pas significatif en cas de changement de PPN)
|
||||
|
||||
modimpl_prec = [
|
||||
modi
|
||||
for modi in nt_prec.formsemestre.modimpls_sorted
|
||||
if modi.module.code == modimpl.module.code
|
||||
]
|
||||
if len(modimpl_prec) > 0: # si une correspondance est trouvée
|
||||
modprec_id = modimpl_prec[0].id
|
||||
moy_ue_capitalisee = get_moy_ue_from_nt(nt_prec, etudid, modprec_id)
|
||||
if (
|
||||
moy_ue_capitalisee is None
|
||||
) or moy_ue_actuelle >= moy_ue_capitalisee: # on prend la meilleure ue
|
||||
note = self.nt.get_etud_mod_moy(
|
||||
modimpl_id, etudid
|
||||
) # lecture de la note
|
||||
coeff = modimpl.module.coefficient # le coeff
|
||||
# nota: self.somme_coeffs peut être None
|
||||
coeff_norm = (
|
||||
coeff / self.somme_coeffs if self.somme_coeffs else 0
|
||||
) # le coeff normalisé
|
||||
else:
|
||||
semtag_prec = SemestreTag(nt_prec, nt_prec.sem)
|
||||
(note, coeff_norm) = semtag_prec.get_noteEtCoeff_modimpl(
|
||||
modprec_id, etudid, profondeur=profondeur - 1
|
||||
) # lecture de la note via le semtag associé au modimpl capitalisé
|
||||
|
||||
# Sinon - pas de notes à prendre en compte
|
||||
return (note, coeff_norm)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def get_ue_capitalisees(self, etudid) -> list[dict]:
|
||||
"""Renvoie la liste des capitalisation effectivement capitalisées par un étudiant"""
|
||||
if etudid in self.nt.validations.ue_capitalisees.index:
|
||||
return self.nt.validations.ue_capitalisees.loc[[etudid]].to_dict("records")
|
||||
return []
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Fonctions d'affichage (et d'export csv) des données du semestre en mode debug
|
||||
# -----------------------------------------------------------------------------
|
||||
def str_detail_resultat_d_un_tag(self, tag, etudid=None, delim=";"):
|
||||
"""Renvoie une chaine de caractère décrivant les résultats d'étudiants à un tag :
|
||||
rappelle les notes obtenues dans les modules à prendre en compte, les moyennes et les rangs calculés.
|
||||
Si etudid=None, tous les étudiants inscrits dans le semestre sont pris en compte. Sinon seuls les étudiants indiqués sont affichés.
|
||||
"""
|
||||
# Entete
|
||||
chaine = delim.join(["%15s" % "nom", "etudid"]) + delim
|
||||
taglist = self.get_all_tags()
|
||||
if tag in taglist:
|
||||
for mod in self.tagdict[tag].values():
|
||||
chaine += mod["module_code"] + delim
|
||||
chaine += ("%1.1f" % mod["ponderation"]) + delim
|
||||
chaine += "coeff" + delim
|
||||
chaine += delim.join(
|
||||
["moyenne", "rang", "nbinscrit", "somme_coeff", "somme_coeff"]
|
||||
) # ligne 1
|
||||
chaine += "\n"
|
||||
|
||||
# Différents cas de boucles sur les étudiants (de 1 à plusieurs)
|
||||
if etudid == None:
|
||||
lesEtuds = self.get_etudids()
|
||||
elif isinstance(etudid, str) and etudid in self.get_etudids():
|
||||
lesEtuds = [etudid]
|
||||
elif isinstance(etudid, list):
|
||||
lesEtuds = [eid for eid in self.get_etudids() if eid in etudid]
|
||||
else:
|
||||
lesEtuds = []
|
||||
|
||||
for etudid in lesEtuds:
|
||||
descr = (
|
||||
"%15s" % self.nt.get_nom_short(etudid)[:15]
|
||||
+ delim
|
||||
+ str(etudid)
|
||||
+ delim
|
||||
)
|
||||
if tag in taglist:
|
||||
for modimpl_id in self.tagdict[tag]:
|
||||
(note, coeff) = self.get_noteEtCoeff_modimpl(modimpl_id, etudid)
|
||||
descr += (
|
||||
(
|
||||
"%2.2f" % note
|
||||
if note != None and isinstance(note, float)
|
||||
else str(note)
|
||||
)
|
||||
+ delim
|
||||
+ (
|
||||
"%1.5f" % coeff
|
||||
if coeff != None and isinstance(coeff, float)
|
||||
else str(coeff)
|
||||
)
|
||||
+ delim
|
||||
+ (
|
||||
"%1.5f" % (coeff * self.somme_coeffs)
|
||||
if coeff != None and isinstance(coeff, float)
|
||||
else "???" # str(coeff * self._sum_coeff_semestre) # voir avec Cléo
|
||||
)
|
||||
+ delim
|
||||
)
|
||||
moy = self.get_moy_from_resultats(tag, etudid)
|
||||
rang = self.get_rang_from_resultats(tag, etudid)
|
||||
coeff = self.get_coeff_from_resultats(tag, etudid)
|
||||
tot = (
|
||||
coeff * self.somme_coeffs
|
||||
if coeff != None
|
||||
and self.somme_coeffs != None
|
||||
and isinstance(coeff, float)
|
||||
else None
|
||||
)
|
||||
descr += (
|
||||
pe_tagtable.TableTag.str_moytag(
|
||||
moy, rang, len(self.get_etudids()), delim=delim
|
||||
)
|
||||
+ delim
|
||||
)
|
||||
descr += (
|
||||
(
|
||||
"%1.5f" % coeff
|
||||
if coeff != None and isinstance(coeff, float)
|
||||
else str(coeff)
|
||||
)
|
||||
+ delim
|
||||
+ (
|
||||
"%.2f" % (tot)
|
||||
if tot != None
|
||||
else str(coeff) + "*" + str(self.somme_coeffs)
|
||||
)
|
||||
)
|
||||
chaine += descr
|
||||
chaine += "\n"
|
||||
return chaine
|
||||
|
||||
def str_tagsModulesEtCoeffs(self):
|
||||
"""Renvoie une chaine affichant la liste des tags associés au semestre,
|
||||
les modules qui les concernent et les coeffs de pondération.
|
||||
Plus concrètement permet d'afficher le contenu de self._tagdict"""
|
||||
chaine = "Semestre %s d'id %d" % (self.nom, id(self)) + "\n"
|
||||
chaine += " -> somme de coeffs: " + str(self.somme_coeffs) + "\n"
|
||||
taglist = self.get_all_tags()
|
||||
for tag in taglist:
|
||||
chaine += " > " + tag + ": "
|
||||
for modid, mod in self.tagdict[tag].items():
|
||||
chaine += (
|
||||
mod["module_code"]
|
||||
+ " ("
|
||||
+ str(mod["coeff"])
|
||||
+ "*"
|
||||
+ str(mod["ponderation"])
|
||||
+ ") "
|
||||
+ str(modid)
|
||||
+ ", "
|
||||
)
|
||||
chaine += "\n"
|
||||
return chaine
|
||||
|
||||
|
||||
# ************************************************************************
|
||||
# Fonctions diverses
|
||||
# ************************************************************************
|
||||
|
||||
|
||||
# *********************************************
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def get_moduleimpl(modimpl_id) -> dict:
|
||||
"""Renvoie l'objet modimpl dont l'id est modimpl_id"""
|
||||
modimpl = db.session.get(ModuleImpl, modimpl_id)
|
||||
if modimpl:
|
||||
return modimpl
|
||||
if SemestreTag.DEBUG:
|
||||
log(
|
||||
"SemestreTag.get_moduleimpl( %s ) : le modimpl recherche n'existe pas"
|
||||
% (modimpl_id)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# **********************************************
|
||||
def get_moy_ue_from_nt(nt, etudid, modimpl_id) -> float:
|
||||
"""Renvoie la moyenne de l'UE d'un etudid dans laquelle se trouve
|
||||
le module de modimpl_id
|
||||
"""
|
||||
# ré-écrit
|
||||
modimpl = get_moduleimpl(modimpl_id) # le module
|
||||
ue_status = nt.get_etud_ue_status(etudid, modimpl.module.ue.id)
|
||||
if ue_status is None:
|
||||
return None
|
||||
return ue_status["moy"]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def get_synthese_tags_semestre(formsemestre: FormSemestre):
|
||||
"""Etant données les implémentations des modules du semestre (modimpls),
|
||||
synthétise les tags les concernant (tags saisis dans le programme pédagogique)
|
||||
en les associant aux modimpls qui les concernent (modimpl_id, module_id,
|
||||
le code du module, coeff et pondération fournie avec le tag (par défaut 1 si non indiquée)).
|
||||
|
||||
{ tagname1: { modimpl_id1: { 'module_id': ...,
|
||||
'coeff': ...,
|
||||
'coeff_norm': ...,
|
||||
'ponderation': ...,
|
||||
'module_code': ...,
|
||||
'ue_xxx': ...},
|
||||
}
|
||||
}
|
||||
|
||||
Args:
|
||||
formsemestre: Le formsemestre à la base de la recherche des tags
|
||||
"""
|
||||
synthese_tags = {}
|
||||
|
||||
"""Instance des modules du semestre"""
|
||||
modimpls = formsemestre.modimpls_sorted
|
||||
|
||||
for modimpl in modimpls:
|
||||
modimpl_id = modimpl.id
|
||||
|
||||
"""Liste des tags pour le module concerné"""
|
||||
tags = sco_tag_module.module_tag_list(modimpl.module.id)
|
||||
|
||||
"""Traitement des tags recensés, chacun pouvant étant de la forme
|
||||
"mathématiques", "théorie", "pe:0", "maths:2"
|
||||
"""
|
||||
for tag in tags:
|
||||
"""Extraction du nom du tag et du coeff de pondération"""
|
||||
(tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag)
|
||||
|
||||
"""Ajout d'une clé pour le tag"""
|
||||
if tagname not in synthese_tags:
|
||||
synthese_tags[tagname] = {}
|
||||
|
||||
"""Ajout du module (modimpl) au tagname considéré"""
|
||||
synthese_tags[tagname][modimpl_id] = {
|
||||
"modimpl": modimpl, # les données sur le module
|
||||
# "coeff": modimpl.module.coefficient, # le coeff du module dans le semestre
|
||||
"ponderation": ponderation, # la pondération demandée pour le tag sur le module
|
||||
# "module_code": modimpl.module.code, # le code qui doit se retrouver à l'identique dans des ue capitalisee
|
||||
# "ue_id": modimpl.module.ue.id, # les données sur l'ue
|
||||
# "ue_code": modimpl.module.ue.ue_code,
|
||||
# "ue_acronyme": modimpl.module.ue.acronyme,
|
||||
}
|
||||
|
||||
return synthese_tags
|
|
@ -1,367 +0,0 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
|
||||
"""
|
||||
Created on Thu Sep 8 09:36:33 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import numpy as np
|
||||
|
||||
from app.scodoc import sco_utils as scu
|
||||
import pandas as pd
|
||||
|
||||
|
||||
TAGS_RESERVES = ["but"]
|
||||
|
||||
|
||||
class TableTag(object):
|
||||
"""
|
||||
Classe mémorisant les moyennes des étudiants à différents tags et permettant de
|
||||
calculer des rangs et des statistiques.
|
||||
|
||||
Ses attributs sont:
|
||||
|
||||
* nom : Nom représentatif des données de la Table
|
||||
* inscrlist : Les étudiants inscrits dans le TagTag avec leur information de la forme :
|
||||
{ etudid : dictionnaire d'info extrait de Scodoc, ...}
|
||||
* taglist : Liste triée des noms des tags
|
||||
* resultats : Dictionnaire donnant les notes-moyennes de chaque étudiant par tag et la somme commulée
|
||||
des coeff utilisées dans le calcul de la moyenne pondérée, sous la forme :
|
||||
{ tag : { etudid: (note_moy, somme_coeff_norm),
|
||||
...}
|
||||
* rangs : Dictionnaire donnant les rang par tag de chaque étudiant de la forme :
|
||||
{ tag : {etudid: rang, ...} }
|
||||
* nbinscrits : Nombre d'inscrits dans le semestre (pas de distinction entre les tags)
|
||||
* statistiques : Dictionnaire donnant les statistiques (moyenne, min, max) des résultats par tag de la forme :
|
||||
{ tag : (moy, min, max), ...}
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, nom: str):
|
||||
"""Les attributs basiques des TagTable, qui seront initialisés
|
||||
dans les classes dérivées
|
||||
"""
|
||||
self.nom = nom
|
||||
"""Les étudiants"""
|
||||
self.etudiants = {}
|
||||
"""Les moyennes par tag"""
|
||||
self.moyennes_tags = {}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------------------------------------
|
||||
def get_all_tags(self):
|
||||
"""Liste des tags de la table, triée par ordre alphabétique
|
||||
|
||||
Returns:
|
||||
Liste de tags triés par ordre alphabétique
|
||||
"""
|
||||
return sorted(self.moyennes_tags.keys())
|
||||
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# Accesseurs
|
||||
# *****************************************************************************************************************
|
||||
|
||||
# -----------------------------------------------------------------------------------------------------------
|
||||
def get_moy_from_resultats(self, tag, etudid):
|
||||
"""Renvoie la moyenne obtenue par un étudiant à un tag donné au regard du format de self.resultats"""
|
||||
return (
|
||||
self.moyennes_tags[tag][etudid][0]
|
||||
if tag in self.moyennes_tags and etudid in self.moyennes_tags[tag]
|
||||
else None
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------------------------------------
|
||||
def get_rang_from_resultats(self, tag, etudid):
|
||||
"""Renvoie le rang à un tag d'un étudiant au regard du format de self.resultats"""
|
||||
return (
|
||||
self.rangs[tag][etudid]
|
||||
if tag in self.moyennes_tags and etudid in self.moyennes_tags[tag]
|
||||
else None
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------------------------------------
|
||||
def get_coeff_from_resultats(self, tag, etudid):
|
||||
"""Renvoie la somme des coeffs de pondération normalisée utilisés dans le calcul de la moyenne à un tag d'un étudiant
|
||||
au regard du format de self.resultats.
|
||||
"""
|
||||
return (
|
||||
self.moyennes_tags[tag][etudid][1]
|
||||
if tag in self.moyennes_tags and etudid in self.moyennes_tags[tag]
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------------------------------------
|
||||
def get_nbinscrits(self):
|
||||
"""Renvoie le nombre d'inscrits"""
|
||||
return len(self.inscrlist)
|
||||
|
||||
# -----------------------------------------------------------------------------------------------------------
|
||||
def get_moy_from_stats(self, tag):
|
||||
"""Renvoie la moyenne des notes calculées pour d'un tag donné"""
|
||||
return self.statistiques[tag][0] if tag in self.statistiques else None
|
||||
|
||||
def get_min_from_stats(self, tag):
|
||||
"""Renvoie la plus basse des notes calculées pour d'un tag donné"""
|
||||
return self.statistiques[tag][1] if tag in self.statistiques else None
|
||||
|
||||
def get_max_from_stats(self, tag):
|
||||
"""Renvoie la plus haute des notes calculées pour d'un tag donné"""
|
||||
return self.statistiques[tag][2] if tag in self.statistiques else None
|
||||
|
||||
# -----------------------------------------------------------------------------------------------------------
|
||||
# La structure des données mémorisées pour chaque tag dans le dictionnaire de synthèse
|
||||
# d'un jury PE
|
||||
FORMAT_DONNEES_ETUDIANTS = (
|
||||
"note",
|
||||
"coeff",
|
||||
"rang",
|
||||
"nbinscrits",
|
||||
"moy",
|
||||
"max",
|
||||
"min",
|
||||
)
|
||||
|
||||
def get_resultatsEtud(self, tag, etudid):
|
||||
"""Renvoie un tuple (note, coeff, rang, nb_inscrit, moy, min, max) synthétisant les résultats d'un étudiant
|
||||
à un tag donné. None sinon"""
|
||||
return (
|
||||
self.get_moy_from_resultats(tag, etudid),
|
||||
self.get_coeff_from_resultats(tag, etudid),
|
||||
self.get_rang_from_resultats(tag, etudid),
|
||||
self.get_nbinscrits(),
|
||||
self.get_moy_from_stats(tag),
|
||||
self.get_min_from_stats(tag),
|
||||
self.get_max_from_stats(tag),
|
||||
)
|
||||
|
||||
# return self.tag_stats[tag]
|
||||
# else :
|
||||
# return self.pe_stats
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# Ajout des notes
|
||||
# *****************************************************************************************************************
|
||||
|
||||
# -----------------------------------------------------------------------------------------------------------
|
||||
def add_moyennesTag(self, tag, listMoyEtCoeff) -> bool:
|
||||
"""
|
||||
Mémorise les moyennes, les coeffs de pondération et les etudid dans resultats
|
||||
avec calcul du rang
|
||||
:param tag: Un tag
|
||||
:param listMoyEtCoeff: Une liste donnant [ (moy, coeff, etudid) ]
|
||||
|
||||
TODO:: Inutile maintenant ?
|
||||
"""
|
||||
# ajout des moyennes au dictionnaire résultat
|
||||
if listMoyEtCoeff:
|
||||
self.moyennes_tags[tag] = {
|
||||
etudid: (moyenne, somme_coeffs)
|
||||
for (moyenne, somme_coeffs, etudid) in listMoyEtCoeff
|
||||
}
|
||||
|
||||
# Calcule les rangs
|
||||
lesMoyennesTriees = sorted(
|
||||
listMoyEtCoeff,
|
||||
reverse=True,
|
||||
key=lambda col: col[0]
|
||||
if isinstance(col[0], float)
|
||||
else 0, # remplace les None et autres chaines par des zéros
|
||||
) # triées
|
||||
self.rangs[tag] = scu.comp_ranks(lesMoyennesTriees) # les rangs
|
||||
|
||||
# calcul des stats
|
||||
self.comp_stats_d_un_tag(tag)
|
||||
return True
|
||||
return False
|
||||
|
||||
# *****************************************************************************************************************
|
||||
# Méthodes dévolues aux calculs de statistiques (min, max, moy) sur chaque moyenne taguée
|
||||
# *****************************************************************************************************************
|
||||
|
||||
def comp_stats_d_un_tag(self, tag):
|
||||
"""
|
||||
Calcule la moyenne generale, le min, le max pour un tag donné,
|
||||
en ne prenant en compte que les moyennes significatives. Mémorise le resultat dans
|
||||
self.statistiques
|
||||
"""
|
||||
stats = ("-NA-", "-", "-")
|
||||
if tag not in self.moyennes_tags:
|
||||
return stats
|
||||
|
||||
notes = [
|
||||
self.get_moy_from_resultats(tag, etudid)
|
||||
for etudid in self.moyennes_tags[tag]
|
||||
] # les notes du tag
|
||||
notes_valides = [
|
||||
note for note in notes if isinstance(note, float) and note != None
|
||||
]
|
||||
nb_notes_valides = len(notes_valides)
|
||||
if nb_notes_valides > 0:
|
||||
(moy, _) = moyenne_ponderee_terme_a_terme(notes_valides, force=True)
|
||||
self.statistiques[tag] = (moy, max(notes_valides), min(notes_valides))
|
||||
|
||||
# ************************************************************************
|
||||
# Méthodes dévolues aux affichages -> a revoir
|
||||
# ************************************************************************
|
||||
def str_resTag_d_un_etudiant(self, tag, etudid, delim=";"):
|
||||
"""Renvoie une chaine de caractères (valable pour un csv)
|
||||
décrivant la moyenne et le rang d'un étudiant, pour un tag donné ;
|
||||
"""
|
||||
if tag not in self.get_all_tags() or etudid not in self.moyennes_tags[tag]:
|
||||
return ""
|
||||
|
||||
moystr = TableTag.str_moytag(
|
||||
self.get_moy_from_resultats(tag, etudid),
|
||||
self.get_rang_from_resultats(tag, etudid),
|
||||
self.get_nbinscrits(),
|
||||
delim=delim,
|
||||
)
|
||||
return moystr
|
||||
|
||||
def str_res_d_un_etudiant(self, etudid, delim=";"):
|
||||
"""Renvoie sur une ligne les résultats d'un étudiant à tous les tags (par ordre alphabétique)."""
|
||||
return delim.join(
|
||||
[self.str_resTag_d_un_etudiant(tag, etudid) for tag in self.get_all_tags()]
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
def str_moytag(cls, moyenne, rang, nbinscrit, delim=";"):
|
||||
"""Renvoie une chaine de caractères représentant une moyenne (float ou string) et un rang
|
||||
pour différents formats d'affichage : HTML, debug ligne de commande, csv"""
|
||||
moystr = (
|
||||
"%2.2f%s%s%s%d" % (moyenne, delim, rang, delim, nbinscrit)
|
||||
if isinstance(moyenne, float)
|
||||
else str(moyenne) + delim + str(rang) + delim + str(nbinscrit)
|
||||
)
|
||||
return moystr
|
||||
|
||||
str_moytag = classmethod(str_moytag)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
def df_tagtable(self):
|
||||
"""Renvoie un dataframe (etudid x tag) listant toutes les moyennes par tags
|
||||
|
||||
Returns:
|
||||
Un dataframe etudids x tag (avec tag par ordre alphabétique)
|
||||
"""
|
||||
tags = self.get_all_tags()
|
||||
if tags:
|
||||
dict_series = {tag: self.moyennes_tags[tag]["notes"] for tag in tags}
|
||||
df = pd.DataFrame(dict_series)
|
||||
return df
|
||||
else:
|
||||
return None
|
||||
|
||||
def str_tagtable(self):
|
||||
"""Renvoie une chaine de caractère listant toutes les moyennes,
|
||||
les rangs des étudiants pour tous les tags."""
|
||||
|
||||
etudiants = self.etudiants
|
||||
df = pd.DataFrame.from_dict(etudiants, orient="index", columns=["nom"])
|
||||
|
||||
for tag in self.get_all_tags():
|
||||
df = df.join(self.moyennes_tags[tag]["notes"].rename(f"moy {tag}"))
|
||||
df = df.join(self.moyennes_tags[tag]["classements"].rename(f"class {tag}"))
|
||||
|
||||
return df.to_csv(sep=";")
|
||||
|
||||
|
||||
# ************************************************************************
|
||||
# Fonctions diverses
|
||||
# ************************************************************************
|
||||
|
||||
|
||||
# *********************************************
|
||||
def moyenne_ponderee_terme_a_terme(notes, coefs=None, force=False):
|
||||
"""
|
||||
Calcule la moyenne pondérée d'une liste de notes avec d'éventuels coeffs de pondération.
|
||||
Renvoie le résultat sous forme d'un tuple (moy, somme_coeff)
|
||||
|
||||
La liste de notes contient soit :
|
||||
1) des valeurs numériques
|
||||
2) des strings "-NA-" (pas de notes) ou "-NI-" (pas inscrit) ou "-c-" ue capitalisée,
|
||||
3) None.
|
||||
|
||||
Le paramètre force indique si le calcul de la moyenne doit être forcée ou non, c'est à
|
||||
dire s'il y a ou non omission des notes non numériques (auquel cas la moyenne est
|
||||
calculée sur les notes disponibles) ; sinon renvoie (None, None).
|
||||
"""
|
||||
# Vérification des paramètres d'entrée
|
||||
if not isinstance(notes, list) or (
|
||||
coefs != None and not isinstance(coefs, list) and len(coefs) != len(notes)
|
||||
):
|
||||
raise ValueError("Erreur de paramètres dans moyenne_ponderee_terme_a_terme")
|
||||
|
||||
# Récupération des valeurs des paramètres d'entrée
|
||||
coefs = [1] * len(notes) if coefs is None else coefs
|
||||
|
||||
# S'il n'y a pas de notes
|
||||
if not notes: # Si notes = []
|
||||
return (None, None)
|
||||
|
||||
# Liste indiquant les notes valides
|
||||
notes_valides = [
|
||||
(isinstance(note, float) and not np.isnan(note)) or isinstance(note, int)
|
||||
for note in notes
|
||||
]
|
||||
# Si on force le calcul de la moyenne ou qu'on ne le force pas
|
||||
# et qu'on a le bon nombre de notes
|
||||
if force or sum(notes_valides) == len(notes):
|
||||
moyenne, ponderation = 0.0, 0.0
|
||||
for i in range(len(notes)):
|
||||
if notes_valides[i]:
|
||||
moyenne += coefs[i] * notes[i]
|
||||
ponderation += coefs[i]
|
||||
return (
|
||||
(moyenne / (ponderation * 1.0), ponderation)
|
||||
if ponderation != 0
|
||||
else (None, 0)
|
||||
)
|
||||
# Si on ne force pas le calcul de la moyenne
|
||||
return (None, None)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------------------------
|
||||
def conversionDate_StrToDate(date_fin):
|
||||
"""Conversion d'une date fournie sous la forme d'une chaine de caractère de
|
||||
type 'jj/mm/aaaa' en un objet date du package datetime.
|
||||
Fonction servant au tri des semestres par date
|
||||
"""
|
||||
(d, m, y) = [int(x) for x in date_fin.split("/")]
|
||||
date_fin_dst = datetime.date(y, m, d)
|
||||
return date_fin_dst
|
|
@ -1,150 +0,0 @@
|
|||
import app.pe.pe_comp as pe_tools
|
||||
from app.models import FormSemestre
|
||||
from app.pe.pe_etudiant import EtudiantsJuryPE, get_dernier_semestre_en_date
|
||||
|
||||
|
||||
class Trajectoire:
|
||||
"""Modélise, pour un aggrégat visé (par ex: 'S2', '3S', '2A')
|
||||
et un ensemble d'étudiants donnés,
|
||||
la combinaison des formsemestres des étudiants amenant à un semestre
|
||||
terminal visé.
|
||||
|
||||
Si l'aggrégat est un semestre de type Si, elle stocke le (ou les)
|
||||
formsemestres de numéro i qu'ont suivis l'étudiant pour atteindre le Si
|
||||
(en général 1 si personnes n'a redoublé, mais 2 s'il y a des redoublants)
|
||||
|
||||
Pour des aggrégats de type iS ou iA (par ex, 3A=S1+S2+S3), elle identifie
|
||||
les semestres que les étudiants ont suivis pour les amener jusqu'au semestre
|
||||
terminal de la trajectoire (par ex: ici un S3).
|
||||
Ces semestres peuvent être :
|
||||
* des S1+S2+S1+S2+S3 si redoublement de la 1ère année
|
||||
* des S1+S2+(année de césure)+S3 si césure, ...
|
||||
"""
|
||||
|
||||
def __init__(self, nom_aggregat: str, semestre_final: FormSemestre):
|
||||
"""Modélise un ensemble de formsemestres d'étudiants
|
||||
amenant à un semestre terminal
|
||||
|
||||
Args:
|
||||
nom_aggregat: Un nom d'aggrégat (par ex: '5S')
|
||||
semestre_final: Le semestre final de l'aggrégat
|
||||
"""
|
||||
self.nom = nom_aggregat
|
||||
self.semestre_final = semestre_final
|
||||
self.trajectoire_id = (nom_aggregat, semestre_final.formsemestre_id)
|
||||
|
||||
"""Les semestres à aggréger"""
|
||||
self.semestres_aggreges = {}
|
||||
|
||||
|
||||
def add_semestres_a_aggreger(self, semestres: dict[int: FormSemestre]):
|
||||
"""Ajoute des semestres au semestre à aggréger
|
||||
|
||||
Args:
|
||||
semestres: Dictionnaire ``{fid: FormSemestre(fid)} à ajouter``
|
||||
"""
|
||||
self.semestres_aggreges = self.semestres_aggreges | semestres
|
||||
|
||||
|
||||
|
||||
def get_repr(self):
|
||||
"""Représentation textuelle d'une trajectoire
|
||||
basée sur ses semestres aggrégés"""
|
||||
noms = []
|
||||
for fid in self.semestres_aggreges:
|
||||
semestre = self.semestres_aggreges[fid]
|
||||
noms.append(f"S{semestre.semestre_id}({fid})")
|
||||
noms = sorted(noms)
|
||||
repr = f"{self.nom} ({self.semestre_final.formsemestre_id}) {self.semestre_final.date_fin.year}"
|
||||
if noms:
|
||||
repr += " - " + "+".join(noms)
|
||||
return repr
|
||||
|
||||
|
||||
class TrajectoiresJuryPE:
|
||||
"""Centralise toutes les trajectoires du jury PE"""
|
||||
|
||||
def __init__(self, annee_diplome: int):
|
||||
"""
|
||||
Args:
|
||||
annee_diplome: L'année de diplomation
|
||||
"""
|
||||
|
||||
self.annee_diplome = annee_diplome
|
||||
"""Toutes les trajectoires possibles"""
|
||||
self.trajectoires: dict[tuple: Trajectoire] = {}
|
||||
"""Quelle trajectoires pour quel étudiant :
|
||||
dictionnaire {etudid: {nom_aggregat: Trajectoire}}"""
|
||||
self.suivi: dict[int: str] = {}
|
||||
|
||||
|
||||
def cree_trajectoires(self, etudiants: EtudiantsJuryPE):
|
||||
"""Créé toutes les trajectoires, au regard du cursus des étudiants
|
||||
analysés + les mémorise dans les données de l'étudiant
|
||||
"""
|
||||
|
||||
for nom_aggregat in pe_tools.TOUS_LES_SEMESTRES + pe_tools.TOUS_LES_AGGREGATS:
|
||||
|
||||
"""L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre terminal (par ex: S3) et son numéro (par ex: 3)"""
|
||||
noms_semestre_de_aggregat = pe_tools.PARCOURS[nom_aggregat]["aggregat"]
|
||||
nom_semestre_terminal = noms_semestre_de_aggregat[-1]
|
||||
|
||||
for etudid in etudiants.cursus:
|
||||
if etudid not in self.suivi:
|
||||
self.suivi[etudid] = {aggregat: None
|
||||
for aggregat in pe_tools.TOUS_LES_SEMESTRES + pe_tools.TOUS_LES_AGGREGATS}
|
||||
|
||||
"""Le formsemestre terminal (dernier en date) associé au
|
||||
semestre marquant la fin de l'aggrégat
|
||||
(par ex: son dernier S3 en date)"""
|
||||
semestres = etudiants.cursus[etudid][nom_semestre_terminal]
|
||||
if semestres:
|
||||
formsemestre_final = get_dernier_semestre_en_date(semestres)
|
||||
|
||||
"""Ajout ou récupération de la trajectoire"""
|
||||
trajectoire_id = (nom_aggregat, formsemestre_final.formsemestre_id)
|
||||
if trajectoire_id not in self.trajectoires:
|
||||
trajectoire = Trajectoire(nom_aggregat, formsemestre_final)
|
||||
self.trajectoires[trajectoire_id] = trajectoire
|
||||
else:
|
||||
trajectoire = self.trajectoires[trajectoire_id]
|
||||
|
||||
"""La liste des semestres de l'étudiant à prendre en compte
|
||||
pour cette trajectoire"""
|
||||
semestres_a_aggreger = etudiants.get_trajectoire(etudid, formsemestre_final)
|
||||
|
||||
"""Ajout des semestres à la trajectoire"""
|
||||
trajectoire.add_semestres_a_aggreger(semestres_a_aggreger)
|
||||
|
||||
"""Mémoire la trajectoire suivie par l'étudiant"""
|
||||
self.suivi[etudid][nom_aggregat] = trajectoire
|
||||
|
||||
"""Vérifications"""
|
||||
# dernier_semestre_aggregat = get_dernier_semestre_en_date(semestres_aggreges)
|
||||
# assert dernier_semestre_aggregat == formsemestre_terminal
|
||||
|
||||
|
||||
def get_semestres_a_aggreger(self, aggregat: str, formsemestre_id_terminal: int):
|
||||
"""Pour un nom d'aggrégat donné (par ex: 'S3') et un semestre terminal cible
|
||||
identifié par son formsemestre_id (par ex: 'S3 2022-2023'),
|
||||
renvoie l'ensemble des semestres à prendre en compte dans
|
||||
l'aggrégat sous la forme d'un dictionnaire {fid: FormSemestre(fid)}.
|
||||
|
||||
Fusionne les cursus individuels des étudiants, dont le cursus correspond
|
||||
à l'aggrégat visé.
|
||||
|
||||
Args:
|
||||
aggregat: Un aggrégat (par ex. 1A, 2A, 3S, 6S)
|
||||
formsemestre_id_terminal: L'identifiant du formsemestre terminal de l'aggrégat, devant correspondre au
|
||||
dernier semestre de l'aggrégat
|
||||
"""
|
||||
noms_semestres_aggreges = pe_tools.PARCOURS[aggregat]["aggregat"]
|
||||
|
||||
formsemestres = {}
|
||||
for etudid in self.cursus:
|
||||
cursus_etudiant = self.cursus[etudid][aggregat]
|
||||
if formsemestre_id_terminal in cursus_etudiant:
|
||||
formsemestres_etudiant = cursus_etudiant[formsemestre_id_terminal]
|
||||
formsemestres = formsemestres | formsemestres_etudiant
|
||||
return formsemestres
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
# -*- mode: python -*-
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gestion scolarite IUT
|
||||
#
|
||||
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
|
||||
#
|
||||
# This program is free software; you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 2 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program; if not, write to the Free Software
|
||||
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
#
|
||||
# Emmanuel Viennet emmanuel.viennet@viennet.net
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on Fri Sep 9 09:15:05 2016
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
from app.comp import moy_sem
|
||||
from app.comp.res_sem import load_formsemestre_results
|
||||
from app.pe.pe_semtag import SemestreTag
|
||||
from app.pe import pe_tabletags
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from app.pe.pe_trajectoire import Trajectoire
|
||||
|
||||
from app.pe.pe_etudiant import EtudiantsJuryPE
|
||||
from app.pe.pe_tabletags import TableTag
|
||||
|
||||
|
||||
class TrajectoireTag(TableTag):
|
||||
"""Calcule les moyennes par tag d'une combinaison de semestres
|
||||
(trajectoires), identifiée par un nom d'aggrégat (par ex: '3S') et
|
||||
par un semestre terminal, pour extraire les classements par tag pour un
|
||||
groupe d'étudiants donnés. Le groupe d'étudiants est formé par ceux ayant tous
|
||||
participé au semestre terminal.
|
||||
|
||||
Par ex: fusion d'un parcours ['S1', 'S2', 'S3'] donnant un nom_combinaison = '3S'
|
||||
|
||||
"""
|
||||
|
||||
# -------------------------------------------------------------------------------------------------------------------
|
||||
def __init__(
|
||||
self,
|
||||
nom: str,
|
||||
trajectoire: Trajectoire,
|
||||
semestres_taggues: dict[int, SemestreTag],
|
||||
donnees_etudiants: EtudiantsJuryPE,
|
||||
):
|
||||
""" """
|
||||
TableTag.__init__(self, nom=nom)
|
||||
|
||||
"""La trajectoire associée"""
|
||||
self.trajectoire_id = trajectoire.trajectoire_id
|
||||
self.trajectoire = trajectoire
|
||||
|
||||
"""Le formsemestre terminal et les semestres aggrégés"""
|
||||
self.formsemestre_terminal = trajectoire.semestre_final
|
||||
nt = load_formsemestre_results(self.formsemestre_terminal)
|
||||
|
||||
self.semestres_aggreges = trajectoire.semestres_aggreges
|
||||
|
||||
"""Les semestres tags associés aux semestres aggrégés"""
|
||||
try:
|
||||
self.semestres_tags_aggreges = {
|
||||
frmsem_id: semestres_taggues[frmsem_id]
|
||||
for frmsem_id in semestres_taggues
|
||||
}
|
||||
except:
|
||||
raise ValueError("Semestres taggués manquants")
|
||||
|
||||
"""Les étudiants (état civil + cursus connu)"""
|
||||
self.etuds = nt.etuds
|
||||
# assert self.etuds == trajectoire.suivi # manque-t-il des étudiants ?
|
||||
self.etudiants = {etud.etudid: etud.etat_civil for etud in self.etuds}
|
||||
|
||||
"""Les tags extraits de tous les semestres"""
|
||||
self.tags_sorted = self.do_taglist()
|
||||
|
||||
"""Construit le cube de notes"""
|
||||
self.notes_cube = self.compute_notes_cube()
|
||||
|
||||
"""Calcul les moyennes par tag sous forme d'un dataframe"""
|
||||
etudids = list(self.etudiants.keys())
|
||||
self.notes = compute_tag_moy(self.notes_cube, etudids, self.tags_sorted)
|
||||
|
||||
"""Synthétise les moyennes/classements par tag"""
|
||||
self.moyennes_tags = {}
|
||||
for tag in self.tags_sorted:
|
||||
moy_gen_tag = self.notes[tag]
|
||||
class_gen_tag = moy_sem.comp_ranks_series(moy_gen_tag)[1] # en int
|
||||
self.moyennes_tags[tag] = {
|
||||
"notes": moy_gen_tag,
|
||||
"classements": class_gen_tag,
|
||||
"min": moy_gen_tag.min(),
|
||||
"max": moy_gen_tag.max(),
|
||||
"moy": moy_gen_tag.mean(),
|
||||
"nb_inscrits": len(moy_gen_tag),
|
||||
}
|
||||
|
||||
def get_repr(self):
|
||||
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
|
||||
est basée)"""
|
||||
return self.trajectoire.get_repr()
|
||||
|
||||
def compute_notes_cube(self):
|
||||
"""Construit le cube de notes (etudid x tags x semestre_aggregé)
|
||||
nécessaire au calcul des moyennes de l'aggrégat
|
||||
"""
|
||||
nb_tags = len(self.tags_sorted)
|
||||
nb_etudiants = len(self.etuds)
|
||||
nb_semestres = len(self.semestres_tags_aggreges)
|
||||
|
||||
"""Index du cube (etudids -> dim 0, tags -> dim 1)"""
|
||||
etudids = [etud.etudid for etud in self.etuds]
|
||||
tags = self.tags_sorted
|
||||
semestres_id = list(self.semestres_tags_aggreges.keys())
|
||||
|
||||
dfs = {}
|
||||
|
||||
for frmsem_id in semestres_id:
|
||||
"""Partant d'un dataframe vierge"""
|
||||
df = pd.DataFrame(np.nan, index=etudids, columns=tags)
|
||||
|
||||
"""Charge les notes du semestre tag"""
|
||||
notes = self.semestres_tags_aggreges[frmsem_id].notes
|
||||
|
||||
"""Les étudiants & les tags commun au dataframe final et aux notes du semestre)"""
|
||||
etudids_communs = df.index.intersection(notes.index)
|
||||
tags_communs = df.columns.intersection(notes.columns)
|
||||
|
||||
"""Injecte les notes par tag"""
|
||||
df.loc[etudids_communs, tags_communs] = notes.loc[
|
||||
etudids_communs, tags_communs
|
||||
]
|
||||
|
||||
"""Stocke le df"""
|
||||
dfs[frmsem_id] = df
|
||||
|
||||
"""Réunit les notes sous forme d'un cube etdids x tags x semestres"""
|
||||
semestres_x_etudids_x_tags = [dfs[fid].values for fid in dfs]
|
||||
etudids_x_tags_x_semestres = np.stack(semestres_x_etudids_x_tags, axis=-1)
|
||||
|
||||
return etudids_x_tags_x_semestres
|
||||
|
||||
|
||||
|
||||
def do_taglist(self):
|
||||
"""Synthétise les tags à partir des semestres (taggués) aggrégés
|
||||
|
||||
Returns:
|
||||
Une liste de tags triés par ordre alphabétique
|
||||
"""
|
||||
tags = []
|
||||
for frmsem_id in self.semestres_tags_aggreges:
|
||||
tags.extend(self.semestres_tags_aggreges[frmsem_id].tags_sorted)
|
||||
return sorted(set(tags))
|
||||
|
||||
|
||||
def compute_tag_moy(set_cube: np.array, etudids: list, tags: list):
|
||||
"""Calcul de la moyenne par tag sur plusieurs semestres.
|
||||
La moyenne est un nombre (note/20), ou NaN si pas de notes disponibles
|
||||
|
||||
*Remarque* : Adaptation de moy_ue.compute_ue_moys_apc au cas des moyennes de tag
|
||||
par aggrégat de plusieurs semestres.
|
||||
|
||||
Args:
|
||||
set_cube: notes moyennes aux modules ndarray
|
||||
(etuds x modimpls x UEs), des floats avec des NaN
|
||||
etudids: liste des étudiants (dim. 0 du cube)
|
||||
tags: liste des tags (dim. 1 du cube)
|
||||
Returns:
|
||||
Un DataFrame avec pour columns les moyennes par tags,
|
||||
et pour rows les etudid
|
||||
"""
|
||||
nb_etuds, nb_tags, nb_semestres = set_cube.shape
|
||||
assert nb_etuds == len(etudids)
|
||||
assert nb_tags == len(tags)
|
||||
|
||||
# Quelles entrées du cube contiennent des notes ?
|
||||
mask = ~np.isnan(set_cube)
|
||||
|
||||
# Enlève les NaN du cube pour les entrées manquantes
|
||||
set_cube_no_nan = np.nan_to_num(set_cube, nan=0.0)
|
||||
|
||||
# Les moyennes par tag
|
||||
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
|
||||
etud_moy_tag = np.sum(set_cube_no_nan, axis=2) / np.sum(mask, axis=2)
|
||||
|
||||
# Le dataFrame
|
||||
etud_moy_tag_df = pd.DataFrame(
|
||||
etud_moy_tag,
|
||||
index=etudids, # les etudids
|
||||
columns=tags, # les tags
|
||||
)
|
||||
|
||||
return etud_moy_tag_df
|
|
@ -35,119 +35,89 @@
|
|||
|
||||
"""
|
||||
|
||||
from flask import send_file, request
|
||||
from flask import flash, g, redirect, render_template, request, send_file, url_for
|
||||
|
||||
from app.decorators import permission_required, scodoc
|
||||
from app.forms.pe.pe_sem_recap import ParametrageClasseurPE
|
||||
from app.models import FormSemestre
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.scodoc import html_sco_header
|
||||
from app.scodoc import sco_preferences
|
||||
|
||||
from app.pe import pe_comp
|
||||
from app.pe import pe_jury
|
||||
from app.views import ScoData
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
from app.views import notes_bp as bp
|
||||
|
||||
|
||||
def _pe_view_sem_recap_form(formsemestre_id):
|
||||
sem_base = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
if not sem_base.formation.is_apc() or sem_base.formation.get_cursus().NB_SEM < 6:
|
||||
H = [
|
||||
html_sco_header.sco_header(page_title="Avis de poursuite d'études"),
|
||||
"""<h2 class="formsemestre">Génération des avis de poursuites d'études (V2 BUT EXPERIMENTALE)</h2>
|
||||
<p class="help">
|
||||
Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de
|
||||
poursuites d'études.
|
||||
<br>
|
||||
De nombreux aspects sont paramétrables:
|
||||
<a href="https://scodoc.org/AvisPoursuiteEtudes"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
voir la documentation (en cours de révision)
|
||||
</a>.
|
||||
Cette fonction (en Scodoc9) n'est prévue que pour le BUT.
|
||||
<br>
|
||||
Rendez-vous donc sur un semestre de BUT.
|
||||
</p>
|
||||
""",
|
||||
]
|
||||
return "\n".join(H) + html_sco_header.sco_footer()
|
||||
|
||||
# L'année du diplome
|
||||
diplome = pe_comp.get_annee_diplome_semestre(sem_base)
|
||||
|
||||
H = [
|
||||
html_sco_header.sco_header(page_title="Avis de poursuite d'études"),
|
||||
f"""<h2 class="formsemestre">Génération des avis de poursuites d'études (V2 BUT EXPERIMENTALE)</h2>
|
||||
|
||||
<div class="alert-warning">
|
||||
Fonction expérimentale pour le BUT : travaux en cours, merci de tester
|
||||
et de faire part de vos expériences sur le Discord.
|
||||
</div>
|
||||
|
||||
<p class="help">
|
||||
Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de
|
||||
poursuites d'études pour les étudiants diplômés en {diplome}.
|
||||
<br>
|
||||
De nombreux aspects sont paramétrables:
|
||||
<a href="https://scodoc.org/AvisPoursuiteEtudes"
|
||||
target="_blank" rel="noopener noreferrer">
|
||||
voir la documentation
|
||||
</a> (en cours de révision).
|
||||
</p>
|
||||
<form method="post" action="pe_view_sem_recap" id="pe_view_sem_recap_form"
|
||||
enctype="multipart/form-data">
|
||||
<div class="pe_template_up">
|
||||
Les templates sont généralement installés sur le serveur ou dans le
|
||||
paramétrage de ScoDoc.
|
||||
<br>
|
||||
Au besoin, vous pouvez spécifier ici votre propre fichier de template
|
||||
(<tt>un_avis.tex</tt>):
|
||||
<div class="pe_template_upb">Template:
|
||||
<input type="file" size="30" name="avis_tmpl_file"/>
|
||||
</div>
|
||||
<div class="pe_template_upb">Pied de page:
|
||||
<input type="file" size="30" name="footer_tmpl_file"/>
|
||||
</div>
|
||||
</div>
|
||||
<input type="submit" value="Générer les documents"/>
|
||||
<input type="hidden" name="formsemestre_id" value="{formsemestre_id}">
|
||||
</form>
|
||||
""",
|
||||
]
|
||||
return "\n".join(H) + html_sco_header.sco_footer()
|
||||
|
||||
|
||||
# called from the web, POST or GET
|
||||
def pe_view_sem_recap(
|
||||
formsemestre_id,
|
||||
avis_tmpl_file=None,
|
||||
footer_tmpl_file=None,
|
||||
):
|
||||
@bp.route("/pe_view_sem_recap/<int:formsemestre_id>", methods=("GET", "POST"))
|
||||
@scodoc
|
||||
@permission_required(Permission.ScoView)
|
||||
def pe_view_sem_recap(formsemestre_id: int):
|
||||
"""Génération des avis de poursuite d'étude"""
|
||||
if request.method == "GET":
|
||||
return _pe_view_sem_recap_form(formsemestre_id)
|
||||
prefs = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id)
|
||||
|
||||
sem_base = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
if not sem_base.formation.is_apc():
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
if not formsemestre.formation.is_apc():
|
||||
raise ScoValueError(
|
||||
"Le module de Poursuites d'Etudes avec Scodoc 9 n'est disponible que pour des formations BUT"
|
||||
"""Le module de Poursuites d'Etudes
|
||||
n'est disponible que pour des formations BUT"""
|
||||
)
|
||||
|
||||
if sem_base.formation.get_cursus().NB_SEM < 6:
|
||||
if formsemestre.formation.get_cursus().NB_SEM < 6:
|
||||
raise ScoValueError(
|
||||
"Le module de Poursuites d'Etudes avec Scodoc 9 n'est pas prévu pour une formation de moins de 6 semestres"
|
||||
"""Le module de Poursuites d'Etudes n'est pas prévu
|
||||
pour une formation de moins de 6 semestres"""
|
||||
)
|
||||
|
||||
# L'année du diplome
|
||||
diplome = pe_comp.get_annee_diplome_semestre(sem_base)
|
||||
annee_diplome = pe_comp.get_annee_diplome_semestre(formsemestre)
|
||||
|
||||
jury = pe_jury.JuryPE(diplome, sem_base.formation.formation_id)
|
||||
# Cosemestres diplomants
|
||||
cosemestres = pe_comp.get_cosemestres_diplomants(annee_diplome)
|
||||
|
||||
data = jury.get_zipped_data()
|
||||
form = ParametrageClasseurPE()
|
||||
|
||||
return send_file(
|
||||
data,
|
||||
mimetype="application/zip",
|
||||
download_name=scu.sanitize_filename(jury.nom_export_zip + ".zip"),
|
||||
as_attachment=True,
|
||||
cosemestres_tries = pe_comp.tri_semestres_par_rang(cosemestres)
|
||||
affichage_cosemestres_tries = {
|
||||
rang: ", ".join([sem.titre_annee() for sem in cosemestres_tries[rang]])
|
||||
for rang in cosemestres_tries
|
||||
}
|
||||
if request.method == "GET":
|
||||
return render_template(
|
||||
"pe/pe_view_sem_recap.j2",
|
||||
annee_diplome=annee_diplome,
|
||||
form=form,
|
||||
formsemestre=formsemestre,
|
||||
sco=ScoData(formsemestre=formsemestre),
|
||||
cosemestres=affichage_cosemestres_tries,
|
||||
rangs_tries=sorted(affichage_cosemestres_tries.keys()),
|
||||
)
|
||||
|
||||
# request.method == "POST"
|
||||
if form.validate_on_submit():
|
||||
jury = pe_jury.JuryPE(annee_diplome, formsemestre_id, options=form.data)
|
||||
if not jury.diplomes_ids:
|
||||
flash("aucun étudiant à considérer !")
|
||||
return redirect(
|
||||
url_for(
|
||||
"notes.pe_view_sem_recap",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=formsemestre_id,
|
||||
)
|
||||
)
|
||||
|
||||
data = jury.get_zipped_data()
|
||||
|
||||
return send_file(
|
||||
data,
|
||||
mimetype="application/zip",
|
||||
download_name=scu.sanitize_filename(jury.nom_export_zip + ".zip"),
|
||||
as_attachment=True,
|
||||
)
|
||||
|
||||
return redirect(
|
||||
url_for(
|
||||
"notes.formsemestre_status",
|
||||
scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=formsemestre_id,
|
||||
)
|
||||
)
|
||||
|
|
|
@ -0,0 +1,131 @@
|
|||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on 01-2024
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
from app.models import FormSemestre
|
||||
|
||||
TYPES_RCS = {
|
||||
"S1": {
|
||||
"aggregat": ["S1"],
|
||||
"descr": "Semestre 1 (S1)",
|
||||
},
|
||||
"S2": {
|
||||
"aggregat": ["S2"],
|
||||
"descr": "Semestre 2 (S2)",
|
||||
},
|
||||
"1A": {
|
||||
"aggregat": ["S1", "S2"],
|
||||
"descr": "BUT1 (S1+S2)",
|
||||
},
|
||||
"S3": {
|
||||
"aggregat": ["S3"],
|
||||
"descr": "Semestre 3 (S3)",
|
||||
},
|
||||
"S4": {
|
||||
"aggregat": ["S4"],
|
||||
"descr": "Semestre 4 (S4)",
|
||||
},
|
||||
"2A": {
|
||||
"aggregat": ["S3", "S4"],
|
||||
"descr": "BUT2 (S3+S4)",
|
||||
},
|
||||
"3S": {
|
||||
"aggregat": ["S1", "S2", "S3"],
|
||||
"descr": "Moyenne du S1 au S3 (S1+S2+S3)",
|
||||
},
|
||||
"4S": {
|
||||
"aggregat": ["S1", "S2", "S3", "S4"],
|
||||
"descr": "Moyenne du S1 au S4 (S1+S2+S3+S4)",
|
||||
},
|
||||
"S5": {
|
||||
"aggregat": ["S5"],
|
||||
"descr": "Semestre 5 (S5)",
|
||||
},
|
||||
"S6": {
|
||||
"aggregat": ["S6"],
|
||||
"descr": "Semestre 6 (S6)",
|
||||
},
|
||||
"3A": {
|
||||
"aggregat": ["S5", "S6"],
|
||||
"descr": "BUT3 (S5+S6)",
|
||||
},
|
||||
"5S": {
|
||||
"aggregat": ["S1", "S2", "S3", "S4", "S5"],
|
||||
"descr": "Moyenne du S1 au S5 (S1+S2+S3+S4+S5)",
|
||||
},
|
||||
"6S": {
|
||||
"aggregat": ["S1", "S2", "S3", "S4", "S5", "S6"],
|
||||
"descr": "Moyenne globale (S1+S2+S3+S4+S5+S6)",
|
||||
},
|
||||
}
|
||||
"""Dictionnaire détaillant les différents regroupements cohérents
|
||||
de semestres (RCS), en leur attribuant un nom et en détaillant
|
||||
le nom des semestres qu'ils regroupent et l'affichage qui en sera fait
|
||||
dans les tableurs de synthèse.
|
||||
"""
|
||||
|
||||
TOUS_LES_RCS_AVEC_PLUSIEURS_SEM = [cle for cle in TYPES_RCS if not cle.startswith("S")]
|
||||
TOUS_LES_RCS = list(TYPES_RCS.keys())
|
||||
TOUS_LES_SEMESTRES = [cle for cle in TYPES_RCS if cle.startswith("S")]
|
||||
|
||||
|
||||
def get_descr_rcs(nom_rcs: str) -> str:
|
||||
"""Renvoie la description pour les tableurs de synthèse
|
||||
Excel d'un nom de RCS"""
|
||||
return TYPES_RCS[nom_rcs]["descr"]
|
||||
|
||||
|
||||
class RCS:
|
||||
"""Modélise un regroupement cohérent de semestres,
|
||||
tous se terminant par un (form)semestre final.
|
||||
"""
|
||||
|
||||
def __init__(self, nom: str, semestre_final: FormSemestre):
|
||||
self.nom: str = nom
|
||||
"""Nom du RCS"""
|
||||
assert self.nom in TOUS_LES_RCS, "Le nom d'un RCS doit être un aggrégat"
|
||||
|
||||
self.aggregat: list[str] = TYPES_RCS[nom]["aggregat"]
|
||||
"""Aggrégat (liste des nom des semestres aggrégés)"""
|
||||
|
||||
self.formsemestre_final: FormSemestre = semestre_final
|
||||
"""(Form)Semestre final du RCS"""
|
||||
|
||||
self.rang_final = self.formsemestre_final.semestre_id
|
||||
"""Rang du formsemestre final"""
|
||||
|
||||
self.rcs_id: (str, int) = (nom, semestre_final.formsemestre_id)
|
||||
"""Identifiant du RCS sous forme (nom_rcs, id du semestre_terminal)"""
|
||||
|
||||
self.fid_final: int = self.formsemestre_final.formsemestre_id
|
||||
"""Identifiant du (Form)Semestre final"""
|
||||
|
||||
def get_formsemestre_id_final(self) -> int:
|
||||
"""Renvoie l'identifiant du formsemestre final du RCS
|
||||
|
||||
Returns:
|
||||
L'id du formsemestre final (marquant la fin) du RCS
|
||||
"""
|
||||
return self.formsemestre_final.formsemestre_id
|
||||
|
||||
def __str__(self):
|
||||
"""Représentation textuelle d'un RCS"""
|
||||
return f"{self.nom}[#{self.formsemestre_final.formsemestre_id}✟{self.formsemestre_final.date_fin.year}]"
|
||||
|
||||
def get_repr(self, verbose=True):
|
||||
"""Représentation textuelle d'un RCS"""
|
||||
return self.__str__()
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Egalité de RCS"""
|
||||
return (
|
||||
self.nom == other.nom
|
||||
and self.formsemestre_final == other.formsemestre_final
|
||||
)
|
|
@ -0,0 +1,59 @@
|
|||
##############################################################################
|
||||
# Module "Avis de poursuite d'étude"
|
||||
# conçu et développé par Cléo Baras (IUT de Grenoble)
|
||||
##############################################################################
|
||||
|
||||
"""
|
||||
Created on 01-2024
|
||||
|
||||
@author: barasc
|
||||
"""
|
||||
|
||||
from app.models import FormSemestre
|
||||
from app.pe.moys import pe_sxtag
|
||||
from app.pe.rcss import pe_rcs, pe_trajectoires
|
||||
|
||||
|
||||
class RCSemX(pe_rcs.RCS):
|
||||
"""Modélise un regroupement cohérent de SemX (en même regroupant
|
||||
des semestres Sx combinés pour former les résultats des étudiants
|
||||
au semestre de rang x) dans le but de synthétiser les résultats
|
||||
du S1 jusqu'au semestre final ciblé par le RCSemX (dépendant de l'aggrégat
|
||||
visé).
|
||||
|
||||
Par ex: Si l'aggrégat du RCSemX est '3S' (=S1+S2+S3),
|
||||
regroupement le SemX du S1 + le SemX du S2 + le SemX du S3 (chacun
|
||||
incluant des infos sur les redoublements).
|
||||
|
||||
Args:
|
||||
nom: Un nom du RCS (par ex: '5S')
|
||||
semestre_final: Le semestre final du RCS
|
||||
"""
|
||||
|
||||
def __init__(self, nom: str, semestre_final: FormSemestre):
|
||||
pe_rcs.RCS.__init__(self, nom, semestre_final)
|
||||
|
||||
self.semXs_aggreges: dict[(str, int) : pe_sxtag.SxTag] = {}
|
||||
"""Les semX à aggréger"""
|
||||
|
||||
def add_semXs(self, semXs: dict[(str, int) : pe_trajectoires.SemX]):
|
||||
"""Ajoute des semXs aux semXs à regrouper dans le RCSemX
|
||||
|
||||
Args:
|
||||
semXs: Dictionnaire ``{(str,fid): RCF}`` à ajouter
|
||||
"""
|
||||
self.semXs_aggreges = self.semXs_aggreges | semXs
|
||||
|
||||
def get_repr(self, verbose=True) -> str:
|
||||
"""Représentation textuelle d'un RCSF
|
||||
basé sur ses RCF aggrégés"""
|
||||
title = f"""{self.__class__.__name__} {pe_rcs.RCS.__str__(self)}"""
|
||||
if verbose:
|
||||
noms = []
|
||||
for semx_id, semx in self.semXs_aggreges.items():
|
||||
noms.append(semx.get_repr(verbose=False))
|
||||
if noms:
|
||||
title += " <<" + "+".join(noms) + ">>"
|
||||
else:
|
||||
title += " <<vide>>"
|
||||
return title
|
|
@ -0,0 +1,87 @@
|
|||
from app.models import FormSemestre
|
||||
import app.pe.rcss.pe_rcs as pe_rcs
|
||||
|
||||
|
||||
class Trajectoire(pe_rcs.RCS):
|
||||
"""Regroupement Cohérent de Semestres ciblant un type d'aggrégat (par ex.
|
||||
'S2', '3S', '1A') et un semestre final, et dont les données regroupées
|
||||
sont des **FormSemestres** suivis par les étudiants.
|
||||
|
||||
Une *Trajectoire* traduit la succession de semestres
|
||||
qu'ont pu suivre des étudiants pour aller d'un semestre S1 jusqu'au semestre final
|
||||
de l'aggrégat.
|
||||
|
||||
Une *Trajectoire* peut être :
|
||||
|
||||
* un RCS de semestre de type "Sx" (cf. classe "SemX"), qui stocke les
|
||||
formsemestres de rang x qu'ont suivi l'étudiant pour valider le Sx
|
||||
(en général 1 formsemestre pour les non-redoublants et 2 pour les redoublants)
|
||||
|
||||
* un RCS de type iS ou iA (par ex, 3A=S1+S2+S3), qui identifie
|
||||
les formsemestres que des étudiants ont suivis pour les amener jusqu'au semestre
|
||||
terminal du RCS. Par ex: si le RCS est un 3S:
|
||||
|
||||
* des S1+S2+S1+S2+S3 si redoublement de la 1ère année
|
||||
* des S1+S2+(année de césure)+S3 si césure, ...
|
||||
|
||||
Args:
|
||||
nom: Un nom du RCS (par ex: '5S')
|
||||
semestre_final: Le formsemestre final du RCS
|
||||
"""
|
||||
|
||||
def __init__(self, nom: str, semestre_final: FormSemestre):
|
||||
pe_rcs.RCS.__init__(self, nom, semestre_final)
|
||||
|
||||
self.semestres_aggreges: dict[int:FormSemestre] = {}
|
||||
"""Formsemestres regroupés dans le RCS"""
|
||||
|
||||
def add_semestres(self, semestres: dict[int:FormSemestre]):
|
||||
"""Ajout de semestres aux semestres à regrouper
|
||||
|
||||
Args:
|
||||
semestres: Dictionnaire ``{fid: Formsemestre)``
|
||||
"""
|
||||
for sem in semestres.values():
|
||||
assert isinstance(
|
||||
sem, FormSemestre
|
||||
), "Les données aggrégées d'une Trajectoire doivent être des FormSemestres"
|
||||
self.semestres_aggreges = self.semestres_aggreges | semestres
|
||||
|
||||
def get_repr(self, verbose=True) -> str:
|
||||
"""Représentation textuelle d'un RCS
|
||||
basé sur ses semestres aggrégés"""
|
||||
title = f"""{self.__class__.__name__} {pe_rcs.RCS.__str__(self)}"""
|
||||
if verbose:
|
||||
noms = []
|
||||
for fid in self.semestres_aggreges:
|
||||
semestre = self.semestres_aggreges[fid]
|
||||
noms.append(f"S{semestre.semestre_id}#{fid}")
|
||||
noms = sorted(noms)
|
||||
if noms:
|
||||
title += " <" + "+".join(noms) + ">"
|
||||
else:
|
||||
title += " <vide>"
|
||||
return title
|
||||
|
||||
|
||||
class SemX(Trajectoire):
|
||||
"""Trajectoire (regroupement cohérent de (form)semestres
|
||||
dans laquelle tous les semestres regroupés sont de même rang `x`.
|
||||
|
||||
Les SemX stocke les
|
||||
formsemestres de rang x qu'ont suivi l'étudiant pour valider le Sx
|
||||
(en général 1 formsemestre pour les non-redoublants et 2 pour les redoublants).
|
||||
|
||||
Ils servent à calculer les SemXTag (moyennes par tag des RCS de type `Sx`).
|
||||
"""
|
||||
|
||||
def __init__(self, trajectoire: Trajectoire):
|
||||
Trajectoire.__init__(self, trajectoire.nom, trajectoire.formsemestre_final)
|
||||
|
||||
semestres_aggreges = trajectoire.semestres_aggreges
|
||||
for sem in semestres_aggreges.values():
|
||||
assert (
|
||||
sem.semestre_id == trajectoire.rang_final
|
||||
), "Tous les semestres aggrégés d'un SemX doivent être de même rang"
|
||||
|
||||
self.semestres_aggreges = trajectoire.semestres_aggreges
|
|
@ -396,7 +396,7 @@ class TF(object):
|
|||
self.values[field] = int(self.values[field])
|
||||
except ValueError:
|
||||
msg.append(
|
||||
f"valeur invalide ({self.values[field]}) pour le champs {field}"
|
||||
f"valeur invalide ({self.values[field]}) pour le champ {field}"
|
||||
)
|
||||
ok = False
|
||||
elif typ == "float" or typ == "real":
|
||||
|
@ -404,7 +404,7 @@ class TF(object):
|
|||
self.values[field] = float(self.values[field].replace(",", "."))
|
||||
except ValueError:
|
||||
msg.append(
|
||||
f"valeur invalide ({self.values[field]}) pour le champs {field}"
|
||||
f"valeur invalide ({self.values[field]}) pour le champ {field}"
|
||||
)
|
||||
ok = False
|
||||
if ok:
|
||||
|
|
|
@ -85,17 +85,6 @@ UE_ELECTIVE = 4 # UE "élective" dans certains cursus (UCAC?, ISCID)
|
|||
UE_PROFESSIONNELLE = 5 # UE "professionnelle" (ISCID, ...)
|
||||
UE_OPTIONNELLE = 6 # UE non fondamentales (ILEPS, ...)
|
||||
|
||||
|
||||
def ue_is_fondamentale(ue_type):
|
||||
return ue_type in (UE_STANDARD, UE_STAGE_LP, UE_PROFESSIONNELLE)
|
||||
|
||||
|
||||
def ue_is_professionnelle(ue_type):
|
||||
return (
|
||||
ue_type == UE_PROFESSIONNELLE
|
||||
) # NB: les UE_PROFESSIONNELLE sont à la fois fondamentales et pro
|
||||
|
||||
|
||||
UE_TYPE_NAME = {
|
||||
UE_STANDARD: "Standard",
|
||||
UE_SPORT: "Sport/Culture (points bonus)",
|
||||
|
@ -104,8 +93,6 @@ UE_TYPE_NAME = {
|
|||
UE_ELECTIVE: "Elective (ISCID)",
|
||||
UE_PROFESSIONNELLE: "Professionnelle (ISCID)",
|
||||
UE_OPTIONNELLE: "Optionnelle",
|
||||
# UE_FONDAMENTALE : '"Fondamentale" (eg UCAC)',
|
||||
# UE_OPTIONNELLE : '"Optionnelle" (UCAC)'
|
||||
}
|
||||
|
||||
# Couleurs RGB (dans [0.,1.]) des UE pour les bulletins:
|
||||
|
@ -409,6 +396,7 @@ class CursusBUT(TypeCursus):
|
|||
APC_SAE = True
|
||||
USE_REFERENTIEL_COMPETENCES = True
|
||||
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
|
||||
ECTS_DIPLOME = 180
|
||||
|
||||
|
||||
register_cursus(CursusBUT())
|
||||
|
|
|
@ -44,13 +44,16 @@ import random
|
|||
from collections import OrderedDict
|
||||
from xml.etree import ElementTree
|
||||
import json
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
|
||||
|
||||
from openpyxl.utils import get_column_letter
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak
|
||||
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame
|
||||
import reportlab
|
||||
from reportlab.platypus import Paragraph, Spacer
|
||||
from reportlab.platypus import Table, KeepInFrame
|
||||
from reportlab.lib.colors import Color
|
||||
from reportlab.lib import styles
|
||||
from reportlab.lib.units import inch, cm, mm
|
||||
from reportlab.rl_config import defaultPageSize # pylint: disable=no-name-in-module
|
||||
from reportlab.lib.units import cm
|
||||
|
||||
from app.scodoc import html_sco_header
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
@ -62,16 +65,32 @@ from app.scodoc.sco_pdf import SU
|
|||
from app import log, ScoDocJSONEncoder
|
||||
|
||||
|
||||
def mark_paras(L, tags) -> list[str]:
|
||||
"""Put each (string) element of L between <tag>...</tag>,
|
||||
def mark_paras(items: list[Any], tags: list[str]) -> list[str]:
|
||||
"""Put each string element of items between <tag>...</tag>,
|
||||
for each supplied tag.
|
||||
Leave non string elements untouched.
|
||||
"""
|
||||
for tag in tags:
|
||||
start = "<" + tag + ">"
|
||||
end = "</" + tag.split()[0] + ">"
|
||||
L = [(start + (x or "") + end) if isinstance(x, str) else x for x in L]
|
||||
return L
|
||||
items = [(start + (x or "") + end) if isinstance(x, str) else x for x in items]
|
||||
return items
|
||||
|
||||
|
||||
def add_query_param(url: str, key: str, value: str) -> str:
|
||||
"add parameter key=value to the given URL"
|
||||
# Parse the URL
|
||||
parsed_url = urlparse(url)
|
||||
# Parse the query parameters
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
# Add or update the query parameter
|
||||
query_params[key] = [value]
|
||||
# Encode the query parameters
|
||||
encoded_query_params = urlencode(query_params, doseq=True)
|
||||
# Construct the new URL
|
||||
new_url_parts = parsed_url._replace(query=encoded_query_params)
|
||||
new_url = urlunparse(new_url_parts)
|
||||
return new_url
|
||||
|
||||
|
||||
class DEFAULT_TABLE_PREFERENCES(object):
|
||||
|
@ -157,6 +176,7 @@ class GenTable:
|
|||
self.xml_link = xml_link
|
||||
# HTML parameters:
|
||||
if not table_id: # random id
|
||||
log("Warning: GenTable() called without table_id")
|
||||
self.table_id = "gt_" + str(random.randint(0, 1000000))
|
||||
else:
|
||||
self.table_id = table_id
|
||||
|
@ -245,16 +265,16 @@ class GenTable:
|
|||
colspan_count -= 1
|
||||
# if colspan_count > 0:
|
||||
# continue # skip cells after a span
|
||||
if pdf_mode:
|
||||
content = row.get(f"_{cid}_pdf", False) or row.get(cid, "")
|
||||
elif xls_mode:
|
||||
content = row.get(f"_{cid}_xls", False) or row.get(cid, "")
|
||||
if pdf_mode and f"_{cid}_pdf" in row:
|
||||
content = row[f"_{cid}_pdf"]
|
||||
elif xls_mode and f"_{cid}_xls" in row:
|
||||
content = row[f"_{cid}_xls"]
|
||||
else:
|
||||
content = row.get(cid, "")
|
||||
# Convert None to empty string ""
|
||||
content = "" if content is None else content
|
||||
|
||||
colspan = row.get("_%s_colspan" % cid, 0)
|
||||
colspan = row.get(f"_{cid}_colspan", 0)
|
||||
if colspan > 1:
|
||||
pdf_style_list.append(
|
||||
(
|
||||
|
@ -477,13 +497,15 @@ class GenTable:
|
|||
H.append('<span class="gt_export_icons">')
|
||||
if self.xls_link:
|
||||
H.append(
|
||||
' <a href="%s&fmt=xls">%s</a>' % (self.base_url, scu.ICON_XLS)
|
||||
f""" <a href="{add_query_param(self.base_url, "fmt", "xls")
|
||||
}">{scu.ICON_XLS}</a>"""
|
||||
)
|
||||
if self.xls_link and self.pdf_link:
|
||||
H.append(" ")
|
||||
if self.pdf_link:
|
||||
H.append(
|
||||
' <a href="%s&fmt=pdf">%s</a>' % (self.base_url, scu.ICON_PDF)
|
||||
f""" <a href="{add_query_param(self.base_url, "fmt", "pdf")
|
||||
}">{scu.ICON_PDF}</a>"""
|
||||
)
|
||||
H.append("</span>")
|
||||
H.append("</p>")
|
||||
|
@ -582,9 +604,11 @@ class GenTable:
|
|||
for line in data_list:
|
||||
Pt.append(
|
||||
[
|
||||
Paragraph(SU(str(x)), CellStyle)
|
||||
if (not isinstance(x, Paragraph))
|
||||
else x
|
||||
(
|
||||
Paragraph(SU(str(x)), CellStyle)
|
||||
if (not isinstance(x, Paragraph))
|
||||
else x
|
||||
)
|
||||
for x in line
|
||||
]
|
||||
)
|
||||
|
@ -654,6 +678,7 @@ class GenTable:
|
|||
fmt="html",
|
||||
page_title="",
|
||||
filename=None,
|
||||
cssstyles=[],
|
||||
javascripts=[],
|
||||
with_html_headers=True,
|
||||
publish=True,
|
||||
|
@ -674,6 +699,7 @@ class GenTable:
|
|||
H.append(
|
||||
self.html_header
|
||||
or html_sco_header.sco_header(
|
||||
cssstyles=cssstyles,
|
||||
page_title=page_title,
|
||||
javascripts=javascripts,
|
||||
init_qtip=init_qtip,
|
||||
|
@ -699,7 +725,7 @@ class GenTable:
|
|||
)
|
||||
else:
|
||||
return pdf_doc
|
||||
elif fmt == "xls" or fmt == "xlsx": # dans les 2 cas retourne du xlsx
|
||||
elif fmt in ("xls", "xlsx"): # dans les 2 cas retourne du xlsx
|
||||
xls = self.excel()
|
||||
if publish:
|
||||
return scu.send_file(
|
||||
|
@ -708,8 +734,7 @@ class GenTable:
|
|||
suffix=scu.XLSX_SUFFIX,
|
||||
mime=scu.XLSX_MIMETYPE,
|
||||
)
|
||||
else:
|
||||
return xls
|
||||
return xls
|
||||
elif fmt == "text":
|
||||
return self.text()
|
||||
elif fmt == "csv":
|
||||
|
@ -789,7 +814,10 @@ if __name__ == "__main__":
|
|||
document,
|
||||
)
|
||||
)
|
||||
document.build(objects)
|
||||
try:
|
||||
document.build(objects)
|
||||
except (ValueError, KeyError, reportlab.platypus.doctemplate.LayoutError) as exc:
|
||||
raise ScoPDFFormatError(str(exc)) from exc
|
||||
data = doc.getvalue()
|
||||
with open("/tmp/gen_table.pdf", "wb") as f:
|
||||
f.write(data)
|
||||
|
|
|
@ -30,7 +30,7 @@
|
|||
|
||||
import html
|
||||
|
||||
from flask import g, render_template
|
||||
from flask import g, render_template, url_for
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
|
||||
|
@ -163,7 +163,7 @@ def sco_header(
|
|||
params = {
|
||||
"page_title": page_title or sco_version.SCONAME,
|
||||
"no_side_bar": no_side_bar,
|
||||
"ScoURL": scu.ScoURL(),
|
||||
"ScoURL": url_for("scolar.index_html", scodoc_dept=g.scodoc_dept),
|
||||
"encoding": scu.SCO_ENCODING,
|
||||
"titrebandeau_mkup": "<td>" + titrebandeau + "</td>",
|
||||
"authuser": current_user.user_name,
|
||||
|
@ -179,6 +179,7 @@ def sco_header(
|
|||
|
||||
H = [
|
||||
"""<!DOCTYPE html><html lang="fr">
|
||||
<!-- ScoDoc legacy -->
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>%(page_title)s</title>
|
||||
|
@ -219,7 +220,7 @@ def sco_header(
|
|||
<script>
|
||||
window.onload=function(){{enableTooltips("gtrcontent")}};
|
||||
|
||||
const SCO_URL="{scu.ScoURL()}";
|
||||
const SCO_URL="{url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)}";
|
||||
const SCO_TIMEZONE="{scu.TIME_ZONE}";
|
||||
</script>"""
|
||||
)
|
||||
|
@ -303,13 +304,16 @@ def sco_header(
|
|||
# div pour affichage messages temporaires
|
||||
H.append('<div id="sco_msg" class="head_message"></div>')
|
||||
#
|
||||
H.append('<div class="sco-app-content">')
|
||||
return "".join(H)
|
||||
|
||||
|
||||
def sco_footer():
|
||||
"""Main HTMl pages footer"""
|
||||
return (
|
||||
"""</div><!-- /gtrcontent -->""" + scu.CUSTOM_HTML_FOOTER + """</body></html>"""
|
||||
"""</div></div><!-- /gtrcontent -->"""
|
||||
+ scu.CUSTOM_HTML_FOOTER
|
||||
+ """</body></html>"""
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -33,7 +33,7 @@ from flask import g, request
|
|||
from flask_login import current_user
|
||||
|
||||
from app import db
|
||||
from app.models import Evaluation, GroupDescr, ModuleImpl, Partition
|
||||
from app.models import Evaluation, GroupDescr, Identite, ModuleImpl, Partition
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
|
@ -102,25 +102,33 @@ def sidebar_common():
|
|||
<a href="{home_link}" class="sidebar">Accueil</a> <br>
|
||||
<div id="authuser"><a id="authuserlink" href="{
|
||||
url_for("users.user_info_page",
|
||||
scodoc_dept=g.scodoc_dept, user_name=current_user.user_name)
|
||||
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>
|
||||
</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="{
|
||||
url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)
|
||||
}" class="sidebar">Semestres</a> <br>
|
||||
<a href="{
|
||||
url_for("notes.index_html", scodoc_dept=g.scodoc_dept)
|
||||
}" class="sidebar">Formations</a> <br>
|
||||
"""
|
||||
]
|
||||
if current_user.has_permission(Permission.AbsChange):
|
||||
H.append(
|
||||
f""" <a href="{scu.AssiduitesURL()}" class="sidebar">Assiduité</a> <br> """
|
||||
f""" <a href="{
|
||||
url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept)
|
||||
}" class="sidebar">Assiduité</a> <br> """
|
||||
)
|
||||
if current_user.has_permission(
|
||||
Permission.UsersAdmin
|
||||
) or current_user.has_permission(Permission.UsersView):
|
||||
H.append(
|
||||
f"""<a href="{scu.UsersURL()}" class="sidebar">Utilisateurs</a> <br>"""
|
||||
f"""<a href="{
|
||||
url_for("users.index_html", scodoc_dept=g.scodoc_dept)
|
||||
}" class="sidebar">Utilisateurs</a> <br>"""
|
||||
)
|
||||
|
||||
if current_user.has_permission(Permission.EditPreferences):
|
||||
|
@ -141,7 +149,9 @@ def sidebar(etudid: int = None):
|
|||
params = {}
|
||||
|
||||
H = [
|
||||
f"""<div class="sidebar">
|
||||
f"""
|
||||
<!-- sidebar py -->
|
||||
<div class="sidebar">
|
||||
{ sidebar_common() }
|
||||
<div class="box-chercheetud">Chercher étudiant:<br>
|
||||
<form method="get" id="form-chercheetud"
|
||||
|
@ -160,28 +170,32 @@ def sidebar(etudid: int = None):
|
|||
etudid = request.form.get("etudid", None)
|
||||
|
||||
if etudid is not None:
|
||||
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
|
||||
params.update(etud)
|
||||
params["fiche_url"] = url_for(
|
||||
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid
|
||||
)
|
||||
etud = Identite.get_etud(etudid)
|
||||
# compte les absences du semestre en cours
|
||||
H.append(
|
||||
"""<h2 id="insidebar-etud"><a href="%(fiche_url)s" class="sidebar">
|
||||
<font color="#FF0000">%(civilite_str)s %(nom_disp)s</font></a>
|
||||
</h2>
|
||||
<b>Absences</b>"""
|
||||
% params
|
||||
f"""<h2 id="insidebar-etud"><a href="{
|
||||
url_for(
|
||||
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid
|
||||
)
|
||||
}" class="sidebar">
|
||||
<font color="#FF0000">{etud.civilite_str} {etud.nom_disp()}</font></a>
|
||||
</h2>
|
||||
<b>Absences</b>"""
|
||||
)
|
||||
if etud["cursem"]:
|
||||
cur_sem = etud["cursem"]
|
||||
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, cur_sem)
|
||||
nbabsnj = nbabs - nbabsjust
|
||||
inscription = etud.inscription_courante()
|
||||
if inscription:
|
||||
formsemestre = inscription.formsemestre
|
||||
nbabsnj, nbabsjust, _ = sco_assiduites.formsemestre_get_assiduites_count(
|
||||
etudid, formsemestre
|
||||
)
|
||||
H.append(
|
||||
f"""<span title="absences du { cur_sem["date_debut"] } au {
|
||||
cur_sem["date_fin"] }">({
|
||||
sco_preferences.get_preference("assi_metrique", None)})
|
||||
<br>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
|
||||
f"""<span title="absences du {
|
||||
formsemestre.date_debut.strftime(scu.DATE_FMT)
|
||||
} au {
|
||||
formsemestre.date_fin.strftime(scu.DATE_FMT)
|
||||
}">({
|
||||
sco_preferences.get_preference("assi_metrique", None)})
|
||||
<br>{nbabsjust:1g} J., {nbabsnj:1g} N.J.</span>"""
|
||||
)
|
||||
H.append("<ul>")
|
||||
if current_user.has_permission(Permission.AbsChange):
|
||||
|
@ -189,21 +203,24 @@ def sidebar(etudid: int = None):
|
|||
cur_formsemestre_id = retreive_formsemestre_from_request()
|
||||
H.append(
|
||||
f"""
|
||||
<li><a href="{ url_for('assiduites.ajout_assiduite_etud',
|
||||
scodoc_dept=g.scodoc_dept, etudid=etudid)
|
||||
}">Ajouter</a></li>
|
||||
<li><a href="{ url_for('assiduites.ajout_justificatif_etud',
|
||||
scodoc_dept=g.scodoc_dept, etudid=etudid,
|
||||
formsemestre_id=cur_formsemestre_id,
|
||||
)
|
||||
}">Justifier</a></li>
|
||||
<li><a href="{
|
||||
url_for('assiduites.ajout_assiduite_etud',
|
||||
scodoc_dept=g.scodoc_dept, etudid=etudid)
|
||||
}">Ajouter</a></li>
|
||||
<li><a href="{
|
||||
url_for('assiduites.ajout_justificatif_etud',
|
||||
scodoc_dept=g.scodoc_dept, etudid=etudid,
|
||||
formsemestre_id=cur_formsemestre_id,
|
||||
)
|
||||
}">Justifier</a></li>
|
||||
"""
|
||||
)
|
||||
if sco_preferences.get_preference("handle_billets_abs"):
|
||||
H.append(
|
||||
f"""<li><a href="{ url_for('absences.billets_etud',
|
||||
scodoc_dept=g.scodoc_dept, etudid=etudid)
|
||||
}">Billets</a></li>"""
|
||||
f"""<li><a href="{
|
||||
url_for('absences.billets_etud',
|
||||
scodoc_dept=g.scodoc_dept, etudid=etudid)
|
||||
}">Billets</a></li>"""
|
||||
)
|
||||
H.append(
|
||||
f"""
|
||||
|
|
|
@ -12,6 +12,7 @@ import psycopg2.extras
|
|||
|
||||
from app import log
|
||||
from app.scodoc.sco_exceptions import ScoException, ScoValueError, NoteProcessError
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
quote_html = html.escape
|
||||
|
||||
|
@ -265,7 +266,7 @@ def DBUpdateArgs(cnx, table, vals, where=None, commit=False, convert_empty_to_nu
|
|||
# log('vals=%s\n'%vals)
|
||||
except psycopg2.errors.StringDataRightTruncation as exc:
|
||||
cnx.rollback()
|
||||
raise ScoValueError("champs de texte trop long !") from exc
|
||||
raise ScoValueError("champ de texte trop long !") from exc
|
||||
except:
|
||||
cnx.rollback() # get rid of this transaction
|
||||
log('Exception in DBUpdateArgs:\n\treq="%s"\n\tvals="%s"\n' % (req, vals))
|
||||
|
@ -300,6 +301,7 @@ class EditableTable(object):
|
|||
output_formators={},
|
||||
input_formators={},
|
||||
aux_tables=[],
|
||||
convert_empty_to_nulls=True, # les arguments vides sont traduits en NULL
|
||||
convert_null_outputs_to_empty=True,
|
||||
html_quote=False, # changed in 9.0.10
|
||||
fields_creators={}, # { field : [ sql_command_to_create_it ] }
|
||||
|
@ -321,6 +323,7 @@ class EditableTable(object):
|
|||
self.output_formators = output_formators
|
||||
self.input_formators = input_formators
|
||||
self.convert_null_outputs_to_empty = convert_null_outputs_to_empty
|
||||
self.convert_empty_to_nulls = convert_empty_to_nulls
|
||||
self.html_quote = html_quote
|
||||
self.fields_creators = fields_creators
|
||||
self.filter_nulls = filter_nulls
|
||||
|
@ -351,6 +354,7 @@ class EditableTable(object):
|
|||
self.table_name,
|
||||
vals,
|
||||
commit=True,
|
||||
convert_empty_to_nulls=self.convert_empty_to_nulls,
|
||||
return_id=(self.id_name is not None),
|
||||
ignore_conflicts=self.insert_ignore_conflicts,
|
||||
)
|
||||
|
@ -444,7 +448,7 @@ def dictfilter(d, fields, filter_nulls=True):
|
|||
"""returns a copy of d with only keys listed in "fields" and non null values"""
|
||||
r = {}
|
||||
for f in fields:
|
||||
if f in d and (d[f] != None or not filter_nulls):
|
||||
if f in d and (d[f] is not None or not filter_nulls):
|
||||
try:
|
||||
val = d[f].strip()
|
||||
except:
|
||||
|
@ -457,7 +461,8 @@ def dictfilter(d, fields, filter_nulls=True):
|
|||
# --- Misc Tools
|
||||
|
||||
|
||||
def DateDMYtoISO(dmy: str, null_is_empty=False) -> str | None: # XXX deprecated
|
||||
# XXX deprecated, voir convert_fr_date
|
||||
def DateDMYtoISO(dmy: str, null_is_empty=False) -> str | None:
|
||||
"""Convert date string from french format (or ISO) to ISO.
|
||||
If null_is_empty (default false), returns "" if no input.
|
||||
"""
|
||||
|
@ -471,7 +476,7 @@ def DateDMYtoISO(dmy: str, null_is_empty=False) -> str | None: # XXX deprecated
|
|||
if not isinstance(dmy, str):
|
||||
raise ScoValueError(f'Date (j/m/a) invalide: "{dmy}"')
|
||||
try:
|
||||
dt = datetime.datetime.strptime(dmy, "%d/%m/%Y")
|
||||
dt = datetime.datetime.strptime(dmy, scu.DATE_FMT)
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.datetime.fromisoformat(dmy)
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -34,6 +34,7 @@ from app.models.absences import BilletAbsence
|
|||
from app.models.etudiants import Identite
|
||||
from app.scodoc.gen_tables import GenTable
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
||||
def query_billets_etud(etudid: int = None, etat: bool = None) -> Query:
|
||||
|
@ -89,12 +90,12 @@ def table_billets(
|
|||
m = " matin"
|
||||
else:
|
||||
m = " après-midi"
|
||||
billet_dict["abs_begin_str"] = billet.abs_begin.strftime("%d/%m/%Y") + m
|
||||
billet_dict["abs_begin_str"] = billet.abs_begin.strftime(scu.DATE_FMT) + m
|
||||
if billet.abs_end.hour < 12:
|
||||
m = " matin"
|
||||
else:
|
||||
m = " après-midi"
|
||||
billet_dict["abs_end_str"] = billet.abs_end.strftime("%d/%m/%Y") + m
|
||||
billet_dict["abs_end_str"] = billet.abs_end.strftime(scu.DATE_FMT) + m
|
||||
if billet.etat == 0:
|
||||
if billet.justified:
|
||||
billet_dict["etat_str"] = "à traiter"
|
||||
|
@ -124,9 +125,9 @@ def table_billets(
|
|||
else:
|
||||
billet_dict["nomprenom"] = billet.etudiant.nomprenom
|
||||
billet_dict["_nomprenom_order"] = billet.etudiant.sort_key
|
||||
billet_dict[
|
||||
"_nomprenom_td_attrs"
|
||||
] = f'id="{billet.etudiant.id}" class="etudinfo"'
|
||||
billet_dict["_nomprenom_td_attrs"] = (
|
||||
f'id="{billet.etudiant.id}" class="etudinfo"'
|
||||
)
|
||||
if with_links:
|
||||
billet_dict["_nomprenom_target"] = url_for(
|
||||
"scolar.fiche_etud",
|
||||
|
@ -156,5 +157,6 @@ def table_billets(
|
|||
rows=rows,
|
||||
html_sortable=True,
|
||||
html_class="table_leftalign",
|
||||
table_id="table_billets",
|
||||
)
|
||||
return tab
|
||||
|
|
|
@ -34,7 +34,7 @@ Il suffit d'appeler abs_notify() après chaque ajout d'absence.
|
|||
import datetime
|
||||
from typing import Optional
|
||||
|
||||
from flask import g, url_for
|
||||
from flask import flash, g, url_for
|
||||
from flask_mail import Message
|
||||
|
||||
from app import db
|
||||
|
@ -46,7 +46,6 @@ from app.models.etudiants import Identite
|
|||
from app.models.events import Scolog
|
||||
from app.models.formsemestre import FormSemestre
|
||||
import app.scodoc.notesdb as ndb
|
||||
from app.scodoc import sco_etud
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_utils as scu
|
||||
|
||||
|
@ -68,7 +67,7 @@ def abs_notify(etudid: int, date: str | datetime.datetime):
|
|||
if not formsemestre:
|
||||
return # non inscrit a la date, pas de notification
|
||||
|
||||
nbabs, nbabsjust = sco_assiduites.get_assiduites_count_in_interval(
|
||||
_, nbabsjust, nbabs = sco_assiduites.get_assiduites_count_in_interval(
|
||||
etudid,
|
||||
metrique=scu.translate_assiduites_metric(
|
||||
sco_preferences.get_preference(
|
||||
|
@ -268,10 +267,12 @@ def abs_notification_message(
|
|||
"""
|
||||
from app.scodoc import sco_bulletins
|
||||
|
||||
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
|
||||
etud = Identite.get_etud(etudid)
|
||||
|
||||
# Variables accessibles dans les balises du template: %(nom_variable)s :
|
||||
values = sco_bulletins.make_context_dict(formsemestre, etud)
|
||||
values = sco_bulletins.make_context_dict(
|
||||
formsemestre, etud.to_dict_scodoc7(with_inscriptions=True)
|
||||
)
|
||||
|
||||
values["nbabs"] = nbabs
|
||||
values["nbabsjust"] = nbabsjust
|
||||
|
@ -281,13 +282,20 @@ def abs_notification_message(
|
|||
)
|
||||
|
||||
template = prefs["abs_notification_mail_tmpl"]
|
||||
txt = ""
|
||||
if template:
|
||||
txt = prefs["abs_notification_mail_tmpl"] % values
|
||||
try:
|
||||
txt = prefs["abs_notification_mail_tmpl"] % values
|
||||
except KeyError:
|
||||
flash("Mail non envoyé: format invalide (voir paramétrage)")
|
||||
log("abs_notification_message: invalid key in abs_notification_mail_tmpl")
|
||||
txt = ""
|
||||
else:
|
||||
log("abs_notification_message: empty template, not sending message")
|
||||
if not txt:
|
||||
return None
|
||||
|
||||
subject = f"""[ScoDoc] Trop d'absences pour {etud["nomprenom"]}"""
|
||||
subject = f"""[ScoDoc] Trop d'absences pour {etud.nomprenom}"""
|
||||
msg = Message(subject, sender=email.get_from_addr(formsemestre.departement.acronym))
|
||||
msg.body = txt
|
||||
return msg
|
||||
|
|
|
@ -288,6 +288,7 @@ def apo_table_compare_etud_results(A, B):
|
|||
html_class="table_leftalign",
|
||||
html_with_td_classes=True,
|
||||
preferences=sco_preferences.SemPreferences(),
|
||||
table_id="apo_table_compare_etud_results",
|
||||
)
|
||||
return T
|
||||
|
||||
|
|
|
@ -515,11 +515,13 @@ class ApoEtud(dict):
|
|||
# ne trouve pas de semestre impair
|
||||
self.validation_annee_but = None
|
||||
return
|
||||
self.validation_annee_but: ApcValidationAnnee = ApcValidationAnnee.query.filter_by(
|
||||
formsemestre_id=formsemestre.id,
|
||||
etudid=self.etud["etudid"],
|
||||
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
|
||||
).first()
|
||||
self.validation_annee_but: ApcValidationAnnee = (
|
||||
ApcValidationAnnee.query.filter_by(
|
||||
formsemestre_id=formsemestre.id,
|
||||
etudid=self.etud["etudid"],
|
||||
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
|
||||
).first()
|
||||
)
|
||||
self.is_nar = (
|
||||
self.validation_annee_but and self.validation_annee_but.code == NAR
|
||||
)
|
||||
|
@ -915,6 +917,7 @@ class ApoData:
|
|||
columns_ids=columns_ids,
|
||||
titles=dict(zip(columns_ids, columns_ids)),
|
||||
rows=rows,
|
||||
table_id="build_cr_table",
|
||||
xls_sheet_name="Decisions ScoDoc",
|
||||
)
|
||||
return T
|
||||
|
@ -967,6 +970,7 @@ class ApoData:
|
|||
"rcue": "RCUE",
|
||||
},
|
||||
rows=rows,
|
||||
table_id="adsup_table",
|
||||
xls_sheet_name="ADSUPs",
|
||||
)
|
||||
|
||||
|
@ -1003,7 +1007,7 @@ def comp_apo_sems(etape_apogee, annee_scolaire: int) -> list[dict]:
|
|||
def nar_etuds_table(apo_data, nar_etuds):
|
||||
"""Liste les NAR -> excel table"""
|
||||
code_etape = apo_data.etape_apogee
|
||||
today = datetime.datetime.today().strftime("%d/%m/%y")
|
||||
today = datetime.datetime.today().strftime(scu.DATE_FMT)
|
||||
rows = []
|
||||
nar_etuds.sort(key=lambda k: k["nom"])
|
||||
for e in nar_etuds:
|
||||
|
@ -1052,6 +1056,7 @@ def nar_etuds_table(apo_data, nar_etuds):
|
|||
columns_ids=columns_ids,
|
||||
titles=dict(zip(columns_ids, columns_ids)),
|
||||
rows=rows,
|
||||
table_id="nar_etuds_table",
|
||||
xls_sheet_name="NAR ScoDoc",
|
||||
)
|
||||
return table.excel()
|
||||
|
|
|
@ -49,11 +49,13 @@
|
|||
"""
|
||||
import datetime
|
||||
import glob
|
||||
import gzip
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import zlib
|
||||
|
||||
import chardet
|
||||
|
||||
|
@ -62,7 +64,7 @@ from flask import g
|
|||
import app.scodoc.sco_utils as scu
|
||||
from config import Config
|
||||
from app import log
|
||||
from app.scodoc.sco_exceptions import ScoValueError
|
||||
from app.scodoc.sco_exceptions import ScoException, ScoValueError
|
||||
|
||||
|
||||
class BaseArchiver:
|
||||
|
@ -241,11 +243,13 @@ class BaseArchiver:
|
|||
filename: str,
|
||||
data: str | bytes,
|
||||
dept_id: int = None,
|
||||
compress=False,
|
||||
):
|
||||
"""Store data in archive, under given filename.
|
||||
Filename may be modified (sanitized): return used filename
|
||||
The file is created or replaced.
|
||||
data may be str or bytes
|
||||
If compress, data is gziped and filename suffix ".gz" added.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = data.encode(scu.SCO_ENCODING)
|
||||
|
@ -255,8 +259,14 @@ class BaseArchiver:
|
|||
try:
|
||||
scu.GSL.acquire()
|
||||
fname = os.path.join(archive_id, filename)
|
||||
with open(fname, "wb") as f:
|
||||
f.write(data)
|
||||
if compress:
|
||||
if not fname.endswith(".gz"):
|
||||
fname += ".gz"
|
||||
with gzip.open(fname, "wb") as f:
|
||||
f.write(data)
|
||||
else:
|
||||
with open(fname, "wb") as f:
|
||||
f.write(data)
|
||||
except FileNotFoundError as exc:
|
||||
raise ScoValueError(
|
||||
f"Erreur stockage archive (dossier inexistant, chemin {fname})"
|
||||
|
@ -274,8 +284,17 @@ class BaseArchiver:
|
|||
fname = os.path.join(archive_id, filename)
|
||||
log(f"reading archive file {fname}")
|
||||
try:
|
||||
with open(fname, "rb") as f:
|
||||
data = f.read()
|
||||
if fname.endswith(".gz"):
|
||||
try:
|
||||
with gzip.open(fname) as f:
|
||||
data = f.read()
|
||||
except (OSError, EOFError, zlib.error) as exc:
|
||||
raise ScoValueError(
|
||||
f"Erreur lecture archive ({fname} invalide)"
|
||||
) from exc
|
||||
else:
|
||||
with open(fname, "rb") as f:
|
||||
data = f.read()
|
||||
except FileNotFoundError as exc:
|
||||
raise ScoValueError(
|
||||
f"Erreur lecture archive (inexistant, chemin {fname})"
|
||||
|
@ -288,6 +307,8 @@ class BaseArchiver:
|
|||
"""
|
||||
archive_id = self.get_id_from_name(oid, archive_name, dept_id=dept_id)
|
||||
data = self.get(archive_id, filename)
|
||||
if filename.endswith(".gz"):
|
||||
filename = filename[:-3]
|
||||
mime = mimetypes.guess_type(filename)[0]
|
||||
if mime is None:
|
||||
mime = "application/octet-stream"
|
||||
|
|
|
@ -138,21 +138,18 @@ def etud_upload_file_form(etudid):
|
|||
"""Page with a form to choose and upload a file, with a description."""
|
||||
# check permission
|
||||
if not can_edit_etud_archive(current_user):
|
||||
raise AccessDenied("opération non autorisée pour %s" % current_user)
|
||||
etuds = sco_etud.get_etud_info(filled=True)
|
||||
if not etuds:
|
||||
raise ScoValueError("étudiant inexistant")
|
||||
etud = etuds[0]
|
||||
raise AccessDenied(f"opération non autorisée pour {current_user}")
|
||||
etud = Identite.get_etud(etudid)
|
||||
|
||||
H = [
|
||||
html_sco_header.sco_header(
|
||||
page_title="Chargement d'un document associé à %(nomprenom)s" % etud,
|
||||
page_title=f"Chargement d'un document associé à {etud.nomprenom}",
|
||||
),
|
||||
"""<h2>Chargement d'un document associé à %(nomprenom)s</h2>
|
||||
"""
|
||||
% etud,
|
||||
"""<p>Le fichier ne doit pas dépasser %sMo.</p>
|
||||
"""
|
||||
% (scu.CONFIG.ETUD_MAX_FILE_SIZE // (1024 * 1024)),
|
||||
f"""<h2>Chargement d'un document associé à {etud.nomprenom}</h2>
|
||||
|
||||
<p>Le fichier ne doit pas dépasser {
|
||||
scu.CONFIG.ETUD_MAX_FILE_SIZE // (1024 * 1024)}Mo.</p>
|
||||
""",
|
||||
]
|
||||
tf = TrivialFormulator(
|
||||
request.base_url,
|
||||
|
@ -176,20 +173,12 @@ def etud_upload_file_form(etudid):
|
|||
if tf[0] == 0:
|
||||
return "\n".join(H) + tf[1] + html_sco_header.sco_footer()
|
||||
elif tf[0] == -1:
|
||||
return flask.redirect(
|
||||
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
|
||||
)
|
||||
else:
|
||||
data = tf[2]["datafile"].read()
|
||||
descr = tf[2]["description"]
|
||||
filename = tf[2]["datafile"].filename
|
||||
etud_archive_id = etud["etudid"]
|
||||
_store_etud_file_to_new_archive(
|
||||
etud_archive_id, data, filename, description=descr
|
||||
)
|
||||
return flask.redirect(
|
||||
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
|
||||
)
|
||||
return flask.redirect(etud.url_fiche())
|
||||
data = tf[2]["datafile"].read()
|
||||
descr = tf[2]["description"]
|
||||
filename = tf[2]["datafile"].filename
|
||||
_store_etud_file_to_new_archive(etudid, data, filename, description=descr)
|
||||
return flask.redirect(etud.url_fiche())
|
||||
|
||||
|
||||
def _store_etud_file_to_new_archive(
|
||||
|
@ -209,23 +198,20 @@ def etud_delete_archive(etudid, archive_name, dialog_confirmed=False):
|
|||
# check permission
|
||||
if not can_edit_etud_archive(current_user):
|
||||
raise AccessDenied(f"opération non autorisée pour {current_user}")
|
||||
etuds = sco_etud.get_etud_info(filled=True)
|
||||
if not etuds:
|
||||
raise ScoValueError("étudiant inexistant")
|
||||
etud = etuds[0]
|
||||
etud_archive_id = etud["etudid"]
|
||||
etud = Identite.get_etud(etudid)
|
||||
etud_archive_id = etudid
|
||||
archive_id = ETUDS_ARCHIVER.get_id_from_name(
|
||||
etud_archive_id, archive_name, dept_id=etud["dept_id"]
|
||||
etud_archive_id, archive_name, dept_id=etud.dept_id
|
||||
)
|
||||
if not dialog_confirmed:
|
||||
return scu.confirm_dialog(
|
||||
"""<h2>Confirmer la suppression des fichiers ?</h2>
|
||||
<p>Fichier associé le %s à l'étudiant %s</p>
|
||||
<p>La suppression sera définitive.</p>"""
|
||||
% (
|
||||
ETUDS_ARCHIVER.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M"),
|
||||
etud["nomprenom"],
|
||||
),
|
||||
f"""<h2>Confirmer la suppression des fichiers ?</h2>
|
||||
<p>Fichier associé le {
|
||||
ETUDS_ARCHIVER.get_archive_date(archive_id).strftime("%d/%m/%Y %H:%M")
|
||||
} à l'étudiant {etud.nomprenom}
|
||||
</p>
|
||||
<p>La suppression sera définitive.</p>
|
||||
""",
|
||||
dest_url="",
|
||||
cancel_url=url_for(
|
||||
"scolar.fiche_etud",
|
||||
|
@ -236,22 +222,17 @@ def etud_delete_archive(etudid, archive_name, dialog_confirmed=False):
|
|||
parameters={"etudid": etudid, "archive_name": archive_name},
|
||||
)
|
||||
|
||||
ETUDS_ARCHIVER.delete_archive(archive_id, dept_id=etud["dept_id"])
|
||||
ETUDS_ARCHIVER.delete_archive(archive_id, dept_id=etud.dept_id)
|
||||
flash("Archive supprimée")
|
||||
return flask.redirect(
|
||||
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
|
||||
)
|
||||
return flask.redirect(etud.url_fiche())
|
||||
|
||||
|
||||
def etud_get_archived_file(etudid, archive_name, filename):
|
||||
"""Send file to client."""
|
||||
etuds = sco_etud.get_etud_info(etudid=etudid, filled=True)
|
||||
if not etuds:
|
||||
raise ScoValueError("étudiant inexistant")
|
||||
etud = etuds[0]
|
||||
etud_archive_id = etud["etudid"]
|
||||
etud = Identite.get_etud(etudid)
|
||||
etud_archive_id = etud.id
|
||||
return ETUDS_ARCHIVER.get_archived_file(
|
||||
etud_archive_id, archive_name, filename, dept_id=etud["dept_id"]
|
||||
etud_archive_id, archive_name, filename, dept_id=etud.dept_id
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -68,7 +68,7 @@ PV_ARCHIVER = SemsArchiver()
|
|||
|
||||
|
||||
def do_formsemestre_archive(
|
||||
formsemestre_id,
|
||||
formsemestre: FormSemestre,
|
||||
group_ids: list[int] = None, # si indiqué, ne prend que ces groupes
|
||||
description="",
|
||||
date_jury="",
|
||||
|
@ -92,19 +92,18 @@ def do_formsemestre_archive(
|
|||
raise ScoValueError(
|
||||
"do_formsemestre_archive: version de bulletin demandée invalide"
|
||||
)
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
|
||||
sem_archive_id = formsemestre_id
|
||||
sem_archive_id = formsemestre.id
|
||||
archive_id = PV_ARCHIVER.create_obj_archive(
|
||||
sem_archive_id, description, formsemestre.dept_id
|
||||
)
|
||||
date = PV_ARCHIVER.get_archive_date(archive_id).strftime("%d/%m/%Y à %H:%M")
|
||||
date = PV_ARCHIVER.get_archive_date(archive_id).strftime(scu.DATEATIME_FMT)
|
||||
|
||||
if not group_ids:
|
||||
# tous les inscrits du semestre
|
||||
group_ids = [sco_groups.get_default_group(formsemestre_id)]
|
||||
group_ids = [sco_groups.get_default_group(formsemestre.id)]
|
||||
groups_infos = sco_groups_view.DisplayedGroupsInfos(
|
||||
group_ids, formsemestre_id=formsemestre_id
|
||||
group_ids, formsemestre_id=formsemestre.id
|
||||
)
|
||||
groups_filename = "-" + groups_infos.groups_filename
|
||||
etudids = [m["etudid"] for m in groups_infos.members]
|
||||
|
@ -142,19 +141,23 @@ def do_formsemestre_archive(
|
|||
)
|
||||
|
||||
# Bulletins en JSON
|
||||
data = gen_formsemestre_recapcomplet_json(formsemestre_id, xml_with_decisions=True)
|
||||
data = gen_formsemestre_recapcomplet_json(formsemestre.id, xml_with_decisions=True)
|
||||
data_js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder)
|
||||
if data:
|
||||
PV_ARCHIVER.store(
|
||||
archive_id, "Bulletins.json", data_js, dept_id=formsemestre.dept_id
|
||||
archive_id,
|
||||
"Bulletins.json",
|
||||
data_js,
|
||||
dept_id=formsemestre.dept_id,
|
||||
compress=True,
|
||||
)
|
||||
# Décisions de jury, en XLS
|
||||
if formsemestre.formation.is_apc():
|
||||
response = jury_but_pv.pvjury_page_but(formsemestre_id, fmt="xls")
|
||||
response = jury_but_pv.pvjury_page_but(formsemestre.id, fmt="xls")
|
||||
data = response.get_data()
|
||||
else: # formations classiques
|
||||
data = sco_pv_forms.formsemestre_pvjury(
|
||||
formsemestre_id, fmt="xls", publish=False
|
||||
formsemestre.id, fmt="xls", publish=False
|
||||
)
|
||||
if data:
|
||||
PV_ARCHIVER.store(
|
||||
|
@ -165,7 +168,7 @@ def do_formsemestre_archive(
|
|||
)
|
||||
# Classeur bulletins (PDF)
|
||||
data, _ = sco_bulletins_pdf.get_formsemestre_bulletins_pdf(
|
||||
formsemestre_id, version=bul_version
|
||||
formsemestre.id, version=bul_version
|
||||
)
|
||||
if data:
|
||||
PV_ARCHIVER.store(
|
||||
|
@ -173,10 +176,11 @@ def do_formsemestre_archive(
|
|||
"Bulletins.pdf",
|
||||
data,
|
||||
dept_id=formsemestre.dept_id,
|
||||
compress=True,
|
||||
)
|
||||
# Lettres individuelles (PDF):
|
||||
data = sco_pv_lettres_inviduelles.pdf_lettres_individuelles(
|
||||
formsemestre_id,
|
||||
formsemestre.id,
|
||||
etudids=etudids,
|
||||
date_jury=date_jury,
|
||||
date_commission=date_commission,
|
||||
|
@ -217,7 +221,7 @@ def formsemestre_archive(formsemestre_id, group_ids: list[int] = None):
|
|||
"""Make and store new archive for this formsemestre.
|
||||
(all students or only selected groups)
|
||||
"""
|
||||
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
if not formsemestre.can_edit_pv():
|
||||
raise ScoPermissionDenied(
|
||||
dest_url=url_for(
|
||||
|
@ -320,7 +324,7 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
|
|||
else:
|
||||
tf[2]["anonymous"] = False
|
||||
do_formsemestre_archive(
|
||||
formsemestre_id,
|
||||
formsemestre,
|
||||
group_ids=group_ids,
|
||||
description=tf[2]["description"],
|
||||
date_jury=tf[2]["date_jury"],
|
||||
|
@ -352,7 +356,7 @@ def formsemestre_list_archives(formsemestre_id):
|
|||
"""Page listing archives"""
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
sem_archive_id = formsemestre_id
|
||||
L = []
|
||||
archives_descr = []
|
||||
for archive_id in PV_ARCHIVER.list_obj_archives(
|
||||
sem_archive_id, dept_id=formsemestre.dept_id
|
||||
):
|
||||
|
@ -366,28 +370,30 @@ def formsemestre_list_archives(formsemestre_id):
|
|||
archive_id, dept_id=formsemestre.dept_id
|
||||
),
|
||||
}
|
||||
L.append(a)
|
||||
archives_descr.append(a)
|
||||
|
||||
H = [html_sco_header.html_sem_header("Archive des PV et résultats ")]
|
||||
if not L:
|
||||
if not archives_descr:
|
||||
H.append("<p>aucune archive enregistrée</p>")
|
||||
else:
|
||||
H.append("<ul>")
|
||||
for a in L:
|
||||
for a in archives_descr:
|
||||
archive_name = PV_ARCHIVER.get_archive_name(a["archive_id"])
|
||||
H.append(
|
||||
'<li>%s : <em>%s</em> (<a href="formsemestre_delete_archive?formsemestre_id=%s&archive_name=%s">supprimer</a>)<ul>'
|
||||
% (
|
||||
a["date"].strftime("%d/%m/%Y %H:%M"),
|
||||
a["description"],
|
||||
formsemestre_id,
|
||||
archive_name,
|
||||
)
|
||||
f"""<li>{a["date"].strftime("%d/%m/%Y %H:%M")} : <em>{a["description"]}</em>
|
||||
(<a href="{ url_for( "notes.formsemestre_delete_archive", scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=formsemestre_id, archive_name=archive_name
|
||||
)}">supprimer</a>)
|
||||
<ul>"""
|
||||
)
|
||||
for filename in a["content"]:
|
||||
H.append(
|
||||
'<li><a href="formsemestre_get_archived_file?formsemestre_id=%s&archive_name=%s&filename=%s">%s</a></li>'
|
||||
% (formsemestre_id, archive_name, filename, filename)
|
||||
f"""<li><a href="{
|
||||
url_for( "notes.formsemestre_get_archived_file", scodoc_dept=g.scodoc_dept,
|
||||
formsemestre_id=formsemestre_id,
|
||||
archive_name=archive_name,
|
||||
filename=filename
|
||||
)}">{filename[:-3] if filename.endswith(".gz") else filename}</a></li>"""
|
||||
)
|
||||
if not a["content"]:
|
||||
H.append("<li><em>aucun fichier !</em></li>")
|
||||
|
@ -399,7 +405,7 @@ def formsemestre_list_archives(formsemestre_id):
|
|||
|
||||
def formsemestre_get_archived_file(formsemestre_id, archive_name, filename):
|
||||
"""Send file to client."""
|
||||
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
|
||||
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
|
||||
sem_archive_id = formsemestre.id
|
||||
return PV_ARCHIVER.get_archived_file(
|
||||
sem_archive_id, archive_name, filename, dept_id=formsemestre.dept_id
|
||||
|
|
|
@ -19,9 +19,12 @@ class Trace:
|
|||
"""gestionnaire de la trace des fichiers justificatifs
|
||||
|
||||
Role des fichiers traces :
|
||||
- Sauvegarder la date de dépot du fichier
|
||||
- Sauvegarder la date de suppression du fichier (dans le cas de plusieurs fichiers pour un même justif)
|
||||
- Sauvegarder l'user_id de l'utilisateur ayant déposé le fichier (=> permet de montrer les fichiers qu'aux personnes qui l'on déposé / qui ont le rôle AssiJustifView)
|
||||
- Sauvegarder la date de dépôt du fichier
|
||||
- Sauvegarder la date de suppression du fichier
|
||||
(dans le cas de plusieurs fichiers pour un même justif)
|
||||
- Sauvegarder l'user_id de l'utilisateur ayant déposé le fichier
|
||||
(=> permet de montrer les fichiers qu'aux personnes
|
||||
qui l'on déposé / qui ont le rôle AssiJustifView)
|
||||
|
||||
_trace.csv :
|
||||
nom_fichier_srv,datetime_depot,datetime_suppr,user_id
|
||||
|
@ -116,7 +119,7 @@ class JustificatifArchiver(BaseArchiver):
|
|||
|
||||
TOTALK:
|
||||
- oid -> etudid
|
||||
- archive_id -> date de création de l'archive (une archive par dépot de document)
|
||||
- archive_id -> date de création de l'archive (une archive par dépôt de document)
|
||||
|
||||
justificatif
|
||||
└── <dept_id>
|
||||
|
|
|
@ -1,22 +1,28 @@
|
|||
"""
|
||||
Ecrit par Matthias Hartmann.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from pytz import UTC
|
||||
|
||||
from flask import g
|
||||
from flask_sqlalchemy.query import Query
|
||||
|
||||
from app import log, db, set_sco_dept
|
||||
import app.scodoc.sco_utils as scu
|
||||
from app.models.assiduites import Assiduite, Justificatif, compute_assiduites_justified
|
||||
from app.models.etudiants import Identite
|
||||
from app.models.formsemestre import FormSemestre, FormSemestreInscription
|
||||
from app.models import (
|
||||
Identite,
|
||||
FormSemestre,
|
||||
FormSemestreInscription,
|
||||
ModuleImpl,
|
||||
ModuleImplInscription,
|
||||
ScoDocSiteConfig,
|
||||
)
|
||||
from app.models.assiduites import Assiduite, Justificatif
|
||||
from app.scodoc import sco_formsemestre_inscriptions
|
||||
from app.scodoc import sco_preferences
|
||||
from app.scodoc import sco_cache
|
||||
from app.scodoc import sco_etud
|
||||
from app.models import ScoDocSiteConfig
|
||||
from flask import g
|
||||
import app.scodoc.sco_utils as scu
|
||||
|
||||
|
||||
class CountCalculator:
|
||||
|
@ -31,21 +37,34 @@ class CountCalculator:
|
|||
------------
|
||||
1. Initialisation : La classe peut être initialisée avec des horaires personnalisés
|
||||
pour le matin, le midi et le soir, ainsi qu'une durée de pause déjeuner.
|
||||
Si non spécifiés, les valeurs par défaut seront chargées depuis la configuration `ScoDocSiteConfig`.
|
||||
Si non spécifiés, les valeurs par défaut seront
|
||||
chargées depuis la configuration `ScoDocSiteConfig`.
|
||||
Exemple d'initialisation :
|
||||
calculator = CountCalculator(morning="08:00", noon="13:00", evening="18:00", nb_heures_par_jour=8)
|
||||
calculator = CountCalculator(
|
||||
morning="08:00",
|
||||
noon="13:00",
|
||||
evening="18:00",
|
||||
nb_heures_par_jour=8
|
||||
)
|
||||
|
||||
2. Ajout d'assiduités :
|
||||
Exemple d'ajout d'assiduité :
|
||||
- calculator.compute_assiduites(etudiant.assiduites)
|
||||
- calculator.compute_assiduites([<Assiduite>, <Assiduite>, <Assiduite>, <Assiduite>])
|
||||
- calculator.compute_assiduites([
|
||||
<Assiduite>,
|
||||
<Assiduite>,
|
||||
<Assiduite>,
|
||||
<Assiduite>
|
||||
])
|
||||
|
||||
3. Accès aux métriques : Après l'ajout des assiduités, on peut accéder aux métriques telles que :
|
||||
3. Accès aux métriques : Après l'ajout des assiduités,
|
||||
on peut accéder aux métriques telles que :
|
||||
le nombre total de jours, de demi-journées et d'heures calculées.
|
||||
Exemple d'accès aux métriques :
|
||||
metrics = calculator.to_dict()
|
||||
|
||||
4.Réinitialisation du comptage: Si besoin on peut réinitialisé le compteur sans perdre la configuration
|
||||
4.Réinitialisation du comptage: Si besoin on peut réinitialiser
|
||||
le compteur sans perdre la configuration
|
||||
(horaires personnalisés)
|
||||
Exemple de réinitialisation :
|
||||
calculator.reset()
|
||||
|
@ -55,8 +74,10 @@ class CountCalculator:
|
|||
- reset() : Réinitialise les compteurs de la classe.
|
||||
- add_half_day(day: date, is_morning: bool) : Ajoute une demi-journée au comptage.
|
||||
- add_day(day: date) : Ajoute un jour complet au comptage.
|
||||
- compute_long_assiduite(assi: Assiduite) : Traite les assiduités s'étendant sur plus d'un jour.
|
||||
- compute_assiduites(assiduites: Query | list) : Calcule les métriques pour une collection d'assiduités.
|
||||
- compute_long_assiduite(assi: Assiduite) : Traite les assiduités
|
||||
s'étendant sur plus d'un jour.
|
||||
- compute_assiduites(assiduites: Query | list) : Calcule les métriques pour
|
||||
une collection d'assiduités.
|
||||
- to_dict() : Retourne les métriques sous forme de dictionnaire.
|
||||
|
||||
Notes :
|
||||
|
@ -79,27 +100,20 @@ class CountCalculator:
|
|||
evening: str = None,
|
||||
nb_heures_par_jour: int = None,
|
||||
) -> None:
|
||||
# Transformation d'une heure "HH:MM" en time(h,m)
|
||||
STR_TIME = lambda x: time(*list(map(int, x.split(":"))))
|
||||
|
||||
self.morning: time = STR_TIME(
|
||||
self.morning: time = str_to_time(
|
||||
morning if morning else ScoDocSiteConfig.get("assi_morning_time", "08:00")
|
||||
)
|
||||
# Date pivot pour déterminer les demi-journées
|
||||
self.noon: time = STR_TIME(
|
||||
self.noon: time = str_to_time(
|
||||
noon if noon else ScoDocSiteConfig.get("assi_lunch_time", "13:00")
|
||||
)
|
||||
self.evening: time = STR_TIME(
|
||||
self.evening: time = str_to_time(
|
||||
evening if evening else ScoDocSiteConfig.get("assi_afternoon_time", "18:00")
|
||||
)
|
||||
|
||||
self.non_work_days: list[
|
||||
scu.NonWorkDays
|
||||
] = scu.NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
|
||||
|
||||
delta_total: timedelta = datetime.combine(
|
||||
date.min, self.evening
|
||||
) - datetime.combine(date.min, self.morning)
|
||||
self.non_work_days: list[scu.NonWorkDays] = (
|
||||
scu.NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
|
||||
)
|
||||
|
||||
# Sera utilisé pour les assiduités longues (> 1 journée)
|
||||
self.nb_heures_par_jour = (
|
||||
|
@ -334,26 +348,62 @@ class CountCalculator:
|
|||
|
||||
def setup_data(self):
|
||||
"""Met en forme les données
|
||||
pour les journées et les demi-journées : au lieu d'avoir list[str] on a le nombre (len(list[str]))
|
||||
pour les journées et les demi-journées :
|
||||
au lieu d'avoir list[str] on a le nombre (len(list[str]))
|
||||
"""
|
||||
for key in self.data:
|
||||
self.data[key]["journee"] = len(self.data[key]["journee"])
|
||||
self.data[key]["demi"] = len(self.data[key]["demi"])
|
||||
for value in self.data.values():
|
||||
value["journee"] = len(value["journee"])
|
||||
value["demi"] = len(value["demi"])
|
||||
|
||||
def to_dict(self, only_total: bool = True) -> dict[str, int | float]:
|
||||
"""Retourne les métriques sous la forme d'un dictionnaire"""
|
||||
return self.data["total"] if only_total else self.data
|
||||
|
||||
|
||||
def str_to_time(time_str: str) -> time:
|
||||
"""Convertit une chaîne de caractères représentant une heure en objet time
|
||||
exemples :
|
||||
- "08:00" -> time(8, 0)
|
||||
- "18:00:00" -> time(18, 0, 0)
|
||||
"""
|
||||
return time(*list(map(int, time_str.split(":"))))
|
||||
|
||||
|
||||
def get_assiduites_stats(
|
||||
assiduites: Query, metric: str = "all", filtered: dict[str, object] = None
|
||||
) -> dict[str, int | float]:
|
||||
"""Compte les assiduités en fonction des filtres"""
|
||||
# XXX TODO-assiduite : documenter !!!
|
||||
# Que sont les filtres ? Quelles valeurs ?
|
||||
# documenter permet de faire moins de bug: qualité du code non satisfaisante.
|
||||
#
|
||||
# + on se perd entre les clés en majuscules et en minuscules. Pourquoi
|
||||
"""
|
||||
Calcule les statistiques sur les assiduités
|
||||
(nombre de jours, demi-journées et heures passées,
|
||||
non justifiées, justifiées et total)
|
||||
|
||||
Les filtres :
|
||||
- etat : filtre les assiduités par leur état
|
||||
valeur : (absent, present, retard)
|
||||
- date_debut/date_fin : prend les assiduités qui se trouvent entre les dates
|
||||
valeur : datetime.datetime
|
||||
- moduleimpl_id : filtre les assiduités en fonction du moduleimpl_id
|
||||
valeur : int | None
|
||||
- formsemestre : prend les assiduités du formsemestre donné
|
||||
valeur : FormSemestre
|
||||
- formsemestre_modimpls : prend les assiduités avec un moduleimpl du formsemestre
|
||||
valeur : FormSemestre
|
||||
- est_just : filtre les assiduités en fonction de si elles sont justifiées ou non
|
||||
valeur : bool
|
||||
- user_id : filtre les assiduités en fonction de l'utilisateur qui les a créées
|
||||
valeur : int
|
||||
- split : effectue un comptage par état d'assiduité
|
||||
valeur : str (du moment que la clé est présente dans filtered)
|
||||
|
||||
Les métriques :
|
||||
- journee : comptage en nombre de journée
|
||||
- demi : comptage en nombre de demi journée
|
||||
- heure : comptage en heure
|
||||
- compte : nombre d'objets
|
||||
- all : renvoi toute les métriques
|
||||
|
||||
|
||||
"""
|
||||
|
||||
if filtered is not None:
|
||||
deb, fin = None, None
|
||||
|
@ -371,6 +421,10 @@ def get_assiduites_stats(
|
|||
assiduites = filter_by_formsemestre(
|
||||
assiduites, Assiduite, filtered[key]
|
||||
)
|
||||
case "formsemestre_modimpls":
|
||||
assiduites = filter_by_modimpls(
|
||||
assiduites, Assiduite, filtered[key]
|
||||
)
|
||||
case "est_just":
|
||||
assiduites = filter_assiduites_by_est_just(
|
||||
assiduites, filtered[key]
|
||||
|
@ -386,34 +440,71 @@ def get_assiduites_stats(
|
|||
calculator: CountCalculator = CountCalculator()
|
||||
calculator.compute_assiduites(assiduites)
|
||||
|
||||
# S'il n'y a pas de filtre ou que le filtre split n'est pas dans les filtres
|
||||
if filtered is None or "split" not in filtered:
|
||||
# On récupère le comptage total
|
||||
# only_total permet de ne récupérer que le total
|
||||
count: dict = calculator.to_dict(only_total=True)
|
||||
|
||||
# On ne garde que les métriques demandées
|
||||
for key, val in count.items():
|
||||
if key in metrics:
|
||||
output[key] = val
|
||||
# On renvoie le total si on a rien demandé (ou que metrics == ["all"])
|
||||
return output if output else count
|
||||
|
||||
# Récupération des états
|
||||
etats: list[str] = (
|
||||
filtered["etat"].split(",") if "etat" in filtered else scu.EtatAssiduite.all()
|
||||
)
|
||||
|
||||
# être sur que les états sont corrects
|
||||
etats = [etat for etat in etats if etat.upper() in scu.EtatAssiduite.all()]
|
||||
|
||||
# Préparation du dictionnaire de retour avec les valeurs du calcul
|
||||
count: dict = calculator.to_dict(only_total=False)
|
||||
|
||||
# Récupération des états depuis la saisie utilisateur
|
||||
etats: list[str] = (
|
||||
filtered["etat"].split(",") if "etat" in filtered else scu.EtatAssiduite.all()
|
||||
)
|
||||
for etat in etats:
|
||||
# TODO-assiduite: on se perd entre les lower et upper.
|
||||
# Pourquoi EtatAssiduite est en majuscules si tout le reste est en minuscules ?
|
||||
etat = etat.lower()
|
||||
# On vérifie que l'état est bien un état d'assiduité
|
||||
# sinon on passe à l'état suivant
|
||||
if not scu.EtatAssiduite.contains(etat):
|
||||
continue
|
||||
|
||||
# On récupère le comptage pour chaque état
|
||||
if etat != "present":
|
||||
output[etat] = count[etat]
|
||||
output[etat]["justifie"] = count[etat + "_just"]
|
||||
output[etat]["non_justifie"] = count[etat + "_non_just"]
|
||||
else:
|
||||
output[etat] = count[etat]
|
||||
|
||||
output["total"] = count["total"]
|
||||
|
||||
# le dictionnaire devrait ressembler à :
|
||||
# {
|
||||
# "absent": {
|
||||
# "journee": 1,
|
||||
# "demi": 2,
|
||||
# "heure": 3,
|
||||
# "compte": 4,
|
||||
# "justifie": {
|
||||
# "journee": 1,
|
||||
# "demi": 2,
|
||||
# "heure": 3,
|
||||
# "compte": 4
|
||||
# },
|
||||
# "non_justifie": {
|
||||
# "journee": 1,
|
||||
# "demi": 2,
|
||||
# "heure": 3,
|
||||
# "compte": 4
|
||||
# }
|
||||
# },
|
||||
# ...
|
||||
# "total": {
|
||||
# "journee": 1,
|
||||
# "demi": 2,
|
||||
# "heure": 3,
|
||||
# "compte": 4
|
||||
# }
|
||||
# }
|
||||
|
||||
return output
|
||||
|
||||
|
||||
|
@ -489,7 +580,9 @@ def filter_by_formsemestre(
|
|||
formsemestre: FormSemestre,
|
||||
) -> Query:
|
||||
"""
|
||||
Filtrage d'une collection en fonction d'un formsemestre
|
||||
Filtrage d'une collection : conserve les élements tels que
|
||||
- l'étudiant est inscrit au formsemestre
|
||||
- et la plage de dates intersecte celle du formsemestre
|
||||
"""
|
||||
|
||||
if formsemestre is None:
|
||||
|
@ -504,14 +597,46 @@ def filter_by_formsemestre(
|
|||
.filter(FormSemestreInscription.formsemestre_id == formsemestre.id)
|
||||
)
|
||||
|
||||
form_date_debut = formsemestre.date_debut + timedelta(days=1)
|
||||
form_date_fin = formsemestre.date_fin + timedelta(days=1)
|
||||
|
||||
collection_result = collection_result.filter(
|
||||
collection_class.date_debut >= form_date_debut
|
||||
collection_class.date_debut <= formsemestre.date_fin
|
||||
).filter(collection_class.date_fin >= formsemestre.date_debut)
|
||||
|
||||
return collection_result
|
||||
|
||||
|
||||
def filter_by_modimpls(
|
||||
collection_query: Assiduite | Justificatif,
|
||||
collection_class: Assiduite | Justificatif,
|
||||
formsemestre: FormSemestre,
|
||||
) -> Query:
|
||||
"""
|
||||
Filtrage d'une collection d'assiduités: conserve les élements
|
||||
- si l'étudiant est inscrit au formsemestre
|
||||
- Et que l'assiduité concerne un moduleimpl de ce formsemestre
|
||||
|
||||
Ne fait rien sur les justificatifs.
|
||||
Ne fait rien si formsemestre is None
|
||||
"""
|
||||
if (collection_class != Assiduite) or (formsemestre is None):
|
||||
return collection_query
|
||||
|
||||
# restreint aux inscrits:
|
||||
collection_result = (
|
||||
collection_query.join(Identite, collection_class.etudid == Identite.id)
|
||||
.join(
|
||||
FormSemestreInscription,
|
||||
Identite.id == FormSemestreInscription.etudid,
|
||||
)
|
||||
.filter(FormSemestreInscription.formsemestre_id == formsemestre.id)
|
||||
)
|
||||
|
||||
return collection_result.filter(collection_class.date_fin <= form_date_fin)
|
||||
collection_result = (
|
||||
collection_result.join(ModuleImpl)
|
||||
.join(ModuleImplInscription)
|
||||
.filter(ModuleImplInscription.etudid == collection_class.etudid)
|
||||
)
|
||||
|
||||
return collection_result
|
||||
|
||||
|
||||
def justifies(justi: Justificatif, obj: bool = False) -> list[int] | Query:
|
||||
|
@ -599,7 +724,7 @@ def create_absence_billet(
|
|||
db.session.add(justi)
|
||||
db.session.commit()
|
||||
|
||||
compute_assiduites_justified(etud.id, [justi])
|
||||
justi.justifier_assiduites()
|
||||
|
||||
calculator: CountCalculator = CountCalculator()
|
||||
calculator.compute_assiduites([assiduite_unique])
|
||||
|
@ -607,9 +732,9 @@ def create_absence_billet(
|
|||
|
||||
|
||||
# Gestion du cache
|
||||
def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int]:
|
||||
def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int, int]:
|
||||
"""Les comptes d'absences de cet étudiant dans ce semestre:
|
||||
tuple (nb abs non justifiées, nb abs justifiées)
|
||||
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
|
||||
Utilise un cache.
|
||||
"""
|
||||
metrique = sco_preferences.get_preference("assi_metrique", sem["formsemestre_id"])
|
||||
|
@ -623,19 +748,19 @@ def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int]:
|
|||
|
||||
def formsemestre_get_assiduites_count(
|
||||
etudid: int, formsemestre: FormSemestre, moduleimpl_id: int = None
|
||||
) -> tuple[int, int]:
|
||||
) -> tuple[int, int, int]:
|
||||
"""Les comptes d'absences de cet étudiant dans ce semestre:
|
||||
tuple (nb abs non justifiées, nb abs justifiées)
|
||||
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
|
||||
Utilise un cache.
|
||||
"""
|
||||
metrique = sco_preferences.get_preference("assi_metrique", formsemestre.id)
|
||||
return get_assiduites_count_in_interval(
|
||||
etudid,
|
||||
date_debut=scu.localize_datetime(
|
||||
datetime.combine(formsemestre.date_debut, time(8, 0))
|
||||
datetime.combine(formsemestre.date_debut, time(0, 0))
|
||||
),
|
||||
date_fin=scu.localize_datetime(
|
||||
datetime.combine(formsemestre.date_fin, time(18, 0))
|
||||
datetime.combine(formsemestre.date_fin, time(23, 0))
|
||||
),
|
||||
metrique=scu.translate_assiduites_metric(metrique),
|
||||
moduleimpl_id=moduleimpl_id,
|
||||
|
@ -650,14 +775,14 @@ def get_assiduites_count_in_interval(
|
|||
date_debut: datetime = None,
|
||||
date_fin: datetime = None,
|
||||
moduleimpl_id: int = None,
|
||||
):
|
||||
) -> tuple[int, int, int]:
|
||||
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
|
||||
tuple (nb abs, nb abs justifiées)
|
||||
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
|
||||
On peut spécifier les dates comme datetime ou iso.
|
||||
Utilise un cache.
|
||||
"""
|
||||
date_debut_iso = date_debut_iso or date_debut.isoformat()
|
||||
date_fin_iso = date_fin_iso or date_fin.isoformat()
|
||||
date_debut_iso = date_debut_iso or date_debut.strftime("%Y-%m-%d")
|
||||
date_fin_iso = date_fin_iso or date_fin.strftime("%Y-%m-%d")
|
||||
key = f"{etudid}_{date_debut_iso}_{date_fin_iso}_assiduites"
|
||||
|
||||
r = sco_cache.AbsSemEtudCache.get(key)
|
||||
|
@ -682,9 +807,10 @@ def get_assiduites_count_in_interval(
|
|||
if not ans:
|
||||
log("warning: get_assiduites_count failed to cache")
|
||||
|
||||
nb_abs: dict = r["absent"][metrique]
|
||||
nb_abs_just: dict = r["absent_just"][metrique]
|
||||
return (nb_abs, nb_abs_just)
|
||||
nb_abs: int = r["absent"][metrique]
|
||||
nb_abs_nj: int = r["absent_non_just"][metrique]
|
||||
nb_abs_just: int = r["absent_just"][metrique]
|
||||
return (nb_abs_nj, nb_abs_just, nb_abs)
|
||||
|
||||
|
||||
def invalidate_assiduites_count(etudid: int, sem: dict):
|
||||
|
@ -712,7 +838,6 @@ def invalidate_assiduites_etud_date(etudid: int, the_date: datetime):
|
|||
pour cet étudiant et cette date.
|
||||
Invalide cache absence et caches semestre
|
||||
"""
|
||||
from app.scodoc import sco_compute_moy
|
||||
|
||||
# Semestres a cette date:
|
||||
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)
|
||||
|
@ -732,17 +857,9 @@ def invalidate_assiduites_etud_date(etudid: int, the_date: datetime):
|
|||
# Invalide les PDF et les absences:
|
||||
for sem in sems:
|
||||
# Inval cache bulletin et/ou note_table
|
||||
if sco_compute_moy.formsemestre_expressions_use_abscounts(
|
||||
sem["formsemestre_id"]
|
||||
):
|
||||
# certaines formules utilisent les absences
|
||||
pdfonly = False
|
||||
else:
|
||||
# efface toujours le PDF car il affiche en général les absences
|
||||
pdfonly = True
|
||||
|
||||
# efface toujours le PDF car il affiche en général les absences
|
||||
sco_cache.invalidate_formsemestre(
|
||||
formsemestre_id=sem["formsemestre_id"], pdfonly=pdfonly
|
||||
formsemestre_id=sem["formsemestre_id"], pdfonly=True
|
||||
)
|
||||
|
||||
# Inval cache compteurs absences:
|
||||
|
@ -774,4 +891,4 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None):
|
|||
pattern=f"tableau-etud-{etudid}*"
|
||||
)
|
||||
# Invalide les tableaux "bilan dept"
|
||||
sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern=f"tableau-dept*")
|
||||
sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern="tableau-dept*")
|
||||
|
|
|
@ -55,7 +55,7 @@ from app.models import (
|
|||
ScoDocSiteConfig,
|
||||
)
|
||||
from app.scodoc.sco_permissions import Permission
|
||||
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
|
||||
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError, ScoTemporaryError
|
||||
from app.scodoc import html_sco_header
|
||||
from app.scodoc import htmlutils
|
||||
from app.scodoc import sco_assiduites
|
||||
|
@ -126,7 +126,7 @@ def make_context_dict(formsemestre: FormSemestre, etud: dict) -> dict:
|
|||
|
||||
# ajoute date courante
|
||||
t = time.localtime()
|
||||
C["date_dmy"] = time.strftime("%d/%m/%Y", t)
|
||||
C["date_dmy"] = time.strftime(scu.DATE_FMT, t)
|
||||
C["date_iso"] = time.strftime("%Y-%m-%d", t)
|
||||
|
||||
return C
|
||||
|
@ -196,7 +196,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
|
|||
pid = partition["partition_id"]
|
||||
partitions_etud_groups[pid] = sco_groups.get_etud_groups_in_partition(pid)
|
||||
# --- Absences
|
||||
I["nbabs"], I["nbabsjust"] = sco_assiduites.get_assiduites_count(etudid, nt.sem)
|
||||
_, I["nbabsjust"], I["nbabs"] = sco_assiduites.get_assiduites_count(etudid, nt.sem)
|
||||
|
||||
# --- Decision Jury
|
||||
infos, dpv = etud_descr_situation_semestre(
|
||||
|
@ -318,7 +318,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
|
|||
if nt.bonus_ues is not None:
|
||||
u["cur_moy_ue_txt"] += " (+ues)"
|
||||
u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"])
|
||||
if ue_status["coef_ue"] != None:
|
||||
if ue_status["coef_ue"] is not None:
|
||||
u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"])
|
||||
else:
|
||||
u["coef_ue_txt"] = "-"
|
||||
|
@ -346,14 +346,14 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
|
|||
# auparavant on filtrait les modules sans notes
|
||||
# si ue_status['cur_moy_ue'] != 'NA' alors u['modules'] = [] (pas de moyenne => pas de modules)
|
||||
|
||||
u[
|
||||
"modules_capitalized"
|
||||
] = [] # modules de l'UE capitalisée (liste vide si pas capitalisée)
|
||||
u["modules_capitalized"] = (
|
||||
[]
|
||||
) # modules de l'UE capitalisée (liste vide si pas capitalisée)
|
||||
if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None:
|
||||
sem_origin = db.session.get(FormSemestre, ue_status["formsemestre_id"])
|
||||
u[
|
||||
"ue_descr_txt"
|
||||
] = f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}'
|
||||
u["ue_descr_txt"] = (
|
||||
f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}'
|
||||
)
|
||||
u["ue_descr_html"] = (
|
||||
f"""<a href="{ url_for( 'notes.formsemestre_bulletinetud',
|
||||
scodoc_dept=g.scodoc_dept, formsemestre_id=sem_origin.id, etudid=etudid)}"
|
||||
|
@ -446,7 +446,8 @@ def _ue_mod_bulletin(
|
|||
):
|
||||
"""Infos sur les modules (et évaluations) dans une UE
|
||||
(ajoute les informations aux modimpls)
|
||||
Result: liste de modules de l'UE avec les infos dans chacun (seulement ceux où l'étudiant est inscrit).
|
||||
Result: liste de modules de l'UE avec les infos dans chacun (seulement
|
||||
ceux où l'étudiant est inscrit).
|
||||
"""
|
||||
bul_show_mod_rangs = sco_preferences.get_preference(
|
||||
"bul_show_mod_rangs", formsemestre_id
|
||||
|
@ -471,7 +472,7 @@ def _ue_mod_bulletin(
|
|||
) # peut etre 'NI'
|
||||
is_malus = mod["module"]["module_type"] == ModuleType.MALUS
|
||||
if bul_show_abs_modules:
|
||||
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, sem)
|
||||
_, nbabsjust, nbabs = sco_assiduites.get_assiduites_count(etudid, sem)
|
||||
mod_abs = [nbabs, nbabsjust]
|
||||
mod["mod_abs_txt"] = scu.fmt_abs(mod_abs)
|
||||
else:
|
||||
|
@ -558,6 +559,8 @@ def _ue_mod_bulletin(
|
|||
).order_by(Evaluation.numero, Evaluation.date_debut)
|
||||
# (plus ancienne d'abord)
|
||||
for e in all_evals:
|
||||
if e.is_blocked():
|
||||
continue # ignore évaluations bloquées
|
||||
if not e.visibulletin and version != "long":
|
||||
continue
|
||||
is_complete = e.id in complete_eval_ids
|
||||
|
@ -610,19 +613,22 @@ def _ue_mod_bulletin(
|
|||
e_dict["coef_txt"] = ""
|
||||
else:
|
||||
e_dict["coef_txt"] = scu.fmt_coef(e.coefficient)
|
||||
if e.evaluation_type == scu.EVALUATION_RATTRAPAGE:
|
||||
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE:
|
||||
e_dict["coef_txt"] = "rat."
|
||||
elif e.evaluation_type == scu.EVALUATION_SESSION2:
|
||||
elif e.evaluation_type == Evaluation.EVALUATION_SESSION2:
|
||||
e_dict["coef_txt"] = "Ses. 2"
|
||||
|
||||
if modimpl_results.evaluations_etat[e.id].nb_attente:
|
||||
mod_attente = True # une eval en attente dans ce module
|
||||
|
||||
if ((not is_malus) or (val != "NP")) and (
|
||||
(e.evaluation_type == scu.EVALUATION_NORMALE or not np.isnan(val))
|
||||
(
|
||||
e.evaluation_type == Evaluation.EVALUATION_NORMALE
|
||||
or not np.isnan(val)
|
||||
)
|
||||
):
|
||||
# ne liste pas les eval malus sans notes
|
||||
# ni les rattrapages et sessions 2 si pas de note
|
||||
# ni les rattrapages, sessions 2 et bonus si pas de note
|
||||
if e.id in complete_eval_ids:
|
||||
mod["evaluations"].append(e_dict)
|
||||
else:
|
||||
|
@ -731,7 +737,11 @@ def etud_descr_situation_semestre(
|
|||
infos["refcomp_specialite_long"] = ""
|
||||
if formsemestre.formation.is_apc():
|
||||
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
|
||||
parcour_id = res.etuds_parcour_id[etudid]
|
||||
try:
|
||||
parcour_id = res.etuds_parcour_id[etudid]
|
||||
except KeyError as exc:
|
||||
log("sco_bulletins: ScoTemporaryError 240222")
|
||||
raise ScoTemporaryError() from exc
|
||||
parcour: ApcParcours = (
|
||||
db.session.get(ApcParcours, parcour_id) if parcour_id is not None else None
|
||||
)
|
||||
|
@ -795,7 +805,9 @@ def etud_descr_situation_semestre(
|
|||
descr_mention = ""
|
||||
|
||||
# Décisions APC / BUT
|
||||
if pv.get("decision_annee", {}):
|
||||
if pv.get("decision_annee", {}) and sco_preferences.get_preference(
|
||||
"bul_but_code_annuel", formsemestre.id
|
||||
):
|
||||
infos["descr_decision_annee"] = "Décision année: " + pv.get(
|
||||
"decision_annee", {}
|
||||
).get("code", "")
|
||||
|
|
|
@ -50,21 +50,18 @@ import traceback
|
|||
|
||||
import reportlab
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate,
|
||||
DocIf,
|
||||
Paragraph,
|
||||
Spacer,
|
||||
Frame,
|
||||
PageBreak,
|
||||
)
|
||||
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame
|
||||
from reportlab.platypus import Table, KeepInFrame
|
||||
|
||||
from flask import request
|
||||
from flask_login import current_user
|
||||
|
||||
from app.models import FormSemestre, Identite, ScoDocSiteConfig
|
||||
from app.scodoc import sco_utils as scu
|
||||
from app.scodoc.sco_exceptions import NoteProcessError
|
||||
from app.scodoc.sco_exceptions import NoteProcessError, ScoPDFFormatError
|
||||
from app import log
|
||||
from app.scodoc import sco_formsemestre
|
||||
from app.scodoc import sco_pdf
|
||||
|
@ -213,26 +210,34 @@ class BulletinGenerator:
|
|||
story.append(PageBreak()) # insert page break at end
|
||||
|
||||
return story
|
||||
else:
|
||||
# Generation du document PDF
|
||||
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
|
||||
report = io.BytesIO() # in-memory document, no disk file
|
||||
document = sco_pdf.BaseDocTemplate(report)
|
||||
document.addPageTemplates(
|
||||
sco_pdf.ScoDocPageTemplate(
|
||||
document,
|
||||
author="%s %s (E. Viennet) [%s]"
|
||||
% (sco_version.SCONAME, sco_version.SCOVERSION, self.description),
|
||||
title=f"""Bulletin {sem["titremois"]} de {etat_civil}""",
|
||||
subject="Bulletin de note",
|
||||
margins=self.margins,
|
||||
server_name=self.server_name,
|
||||
filigranne=self.filigranne,
|
||||
preferences=sco_preferences.SemPreferences(formsemestre_id),
|
||||
)
|
||||
|
||||
# Generation du document PDF
|
||||
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
|
||||
report = io.BytesIO() # in-memory document, no disk file
|
||||
document = sco_pdf.BaseDocTemplate(report)
|
||||
document.addPageTemplates(
|
||||
sco_pdf.ScoDocPageTemplate(
|
||||
document,
|
||||
author=f"""{sco_version.SCONAME} {
|
||||
sco_version.SCOVERSION} (E. Viennet) [{self.description}]""",
|
||||
title=f"""Bulletin {sem["titremois"]} de {etat_civil}""",
|
||||
subject="Bulletin de note",
|
||||
margins=self.margins,
|
||||
server_name=self.server_name,
|
||||
filigranne=self.filigranne,
|
||||
preferences=sco_preferences.SemPreferences(formsemestre_id),
|
||||
)
|
||||
)
|
||||
try:
|
||||
document.build(story)
|
||||
data = report.getvalue()
|
||||
except (
|
||||
ValueError,
|
||||
KeyError,
|
||||
reportlab.platypus.doctemplate.LayoutError,
|
||||
) as exc:
|
||||
raise ScoPDFFormatError(str(exc)) from exc
|
||||
|
||||
data = report.getvalue()
|
||||
return data
|
||||
|
||||
def buildTableObject(self, P, pdfTableStyle, colWidths):
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue