Compare commits

...

210 Commits
edt ... master

Author SHA1 Message Date
a200be586a ue.titre peut être null + formattage code 2024-02-13 13:55:16 +01:00
607604f91e Assiduite: retire arg inutile qui faisait planter sur sems vides 2024-02-12 10:21:05 +01:00
8eedac0f03 orthographe 2024-02-12 10:12:46 +01:00
aea2204d9e PE: fix moy promo (max -> moy) 2024-02-12 09:26:23 +01:00
9c15cbe647 PE: Fix moys (thx @jmpulille). Ajoute log au zip. 2024-02-11 22:06:37 +01:00
6761f5a620 Ajoute une vérification sur les semestres BUT: association aux parcours 2024-02-11 21:19:45 +01:00
69a53adb55 Migration pour clés etudiant/annotations et modif clé ModuleImpl/responsable 2024-02-11 12:21:48 +01:00
b30ea5f5fd Annotations étudiants: API et tests 2024-02-11 12:05:43 +01:00
052fb3c7b9 Merge pull request 'Ajout des annotations dans l'API' (#857) from lyanis/ScoDoc:api-annot into master
Reviewed-on: https://scodoc.org/git/ScoDoc/ScoDoc/pulls/857
2024-02-11 10:09:50 +01:00
dbd0124c2c Retrait des annotations inutiles sur certaines routes API 2024-02-10 19:32:43 +01:00
e989a4ffa8 Restreint l'accès aux annotations via l'API à la permission ViewEtudData 2024-02-10 15:53:57 +01:00
6ae2b0eb5f Merge branch 'master' into api-annot 2024-02-10 15:14:14 +01:00
d7f3376103 fiche_etud: restreint l'accès aux annotations à la permission ViewEtudData 2024-02-10 15:02:18 +01:00
677415fbfc Ajout des annotations dans l'API 2024-02-10 14:11:34 +01:00
6cbeeedb1c Ajout colonne module sur page recap inscriptions 2024-02-09 15:36:37 +01:00
39e7ad3ad6 clarifie titre option publication bulletins 2024-02-09 15:32:48 +01:00
177d38428e Fix: jury BUT si une UE a été déassociée depuis la validation 2024-02-09 13:47:48 +01:00
f4c1d00046 PE: reformattage code, small bug fix. 2024-02-08 22:31:46 +01:00
86c12dee08 complete previous commit 2024-02-08 16:26:50 +01:00
8cf85f78a8 Assiduite: filtrage par formsemestre: intersection dates 2024-02-08 16:23:14 +01:00
9ec0ef27ba Assiduite: visu_assi_group avec restriction aux modules du semestre. 2024-02-08 15:56:58 +01:00
c8ac796347 Assiduite: fix format cell. nombre dans exports excel 2024-02-08 11:05:57 +01:00
2212990788 Corrige le bug lorsque plusieurs UE sont rattachées à la même compétence 2024-02-06 22:31:33 +01:00
719d14673d Merge branch 'scodoc-master' into pe-BUT-v2 2024-02-06 18:58:14 +01:00
98eb7699a0 Coquille de syntaxe 2024-02-06 18:57:54 +01:00
7b22d26095 Supprime les étudiants démissionnaires au dernier semestre du jury PE 2024-02-06 18:57:36 +01:00
371d7eff64 Affiche message erreur si utilisation de tags réservés 2024-02-06 18:25:31 +01:00
0adcbb7c0b Ajoute différentes infos à la page du site Web consacré aux PE (dont tentative de progress bar) 2024-02-06 17:53:38 +01:00
f10d46c230 Supprime le formation_id définitivement des cosemestres 2024-02-06 17:52:52 +01:00
4f41ef7050 Fix typo, bloquant archivage fichiers étudiants 2024-02-06 16:52:28 +01:00
ef4c2fa64b 9.6.935 2024-02-06 00:02:43 +01:00
be39245e25 Merge branch 'scodoc-master' into pe-BUT-v2 2024-02-05 19:47:36 +01:00
196dbab298 Amélioration nomenclature : interclass => RCSInterclasseTag + ménage et refonte codes (état intermédiaire n°2) 2024-02-05 19:46:16 +01:00
0594a659fa 9.6.934 2024-02-05 19:06:26 +01:00
072d013590 Fix edition resp. modimpl. Une migration devra etre ajoutée plus tard. 2024-02-05 17:35:46 +01:00
9c4e2627ba Amélioration nomenclature : trajectoire => RCS + ménage et refonte codes (état intermédiaire n°1) 2024-02-05 12:58:09 +01:00
bbdf5da2e8 Fix edition formsemestre 2024-02-05 11:03:34 +01:00
5828d4aaaf Corrige les erreurs de classement dans synthese_jury_par_tag 2024-02-05 10:23:51 +01:00
e6a544906e Fix: ajout enseignant module (oubli commit) 2024-02-05 09:07:23 +01:00
bacd734ab5 Fix: creation formsemestre 2024-02-05 09:03:53 +01:00
e611fa4bfc Fix: création 1ere évaluation 2024-02-05 08:46:21 +01:00
128b282186 API: remet /formsemestre/<int:formsemestre_id>/etat_evals 2024-02-05 00:10:56 +01:00
57d36927ac Modernise code evaluations/enseignants 2024-02-04 23:08:08 +01:00
d5fdd5b8b8 Affichage statut évaluations (attente) 2024-02-04 18:36:11 +01:00
7162d83f39 Ordre évaluations 2024-02-04 12:00:26 +01:00
7805a6cab9 Assiduité: fix UnboundLocalError in signale_evaluation_abs 2024-02-04 00:48:45 +01:00
2c840b7803 Bulletins BUT courts pdf: ajout parcours 2024-02-04 00:44:41 +01:00
0645db8ab0 Ajout explication sur colonne RCUE jury BUT 2024-02-04 00:22:49 +01:00
9e13b51669 Merge branch 'pe-BUT-v2' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-02-04 00:09:11 +01:00
034800ab9a small fixes 2024-02-04 00:07:14 +01:00
4b2e88c678 Diverses modernisation du code 2024-02-03 23:25:05 +01:00
c9af2345fb Réécrit le tableau de synthèse du jury PE par tag avec des DataFrame + limite l'affichage aux aggrégats significatifs (avec notes) 2024-02-03 15:26:58 +01:00
0bf0311f2f Ménage + Refactoring du code 2024-02-03 10:46:14 +01:00
5bbdc567f3 retrait API + fix 2024-02-02 18:45:58 +01:00
027f11e494 Merge branch 'pe-BUT-v2' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-02-02 18:24:00 +01:00
ef171364a6 Edition/import étudiants / traitement erreurs codes dupliqués 2024-02-02 18:23:35 +01:00
9b9d7b611b Coquilles orthographiques dans la doc 2024-02-02 17:17:26 +01:00
02bfb626cb Ajoute des info à l'édition des tags à propos de la gestion des compétences 2024-02-02 17:17:14 +01:00
597a28f86d Injecte les moyennes de compétences par semestre à la synthèse 2024-02-02 17:16:07 +01:00
2915f4e981 Fix: check arg formsemestre_set_elt_sem_apo 2024-02-02 16:34:27 +01:00
af659d5f09 Merge branch 'pe-BUT-v2' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-02-02 16:00:09 +01:00
838ae7cf7e Merge branch 'tmp' 2024-02-02 15:29:45 +01:00
e4c8637c41 Fix: TypeError in __init__ /opt/scodoc/app/but/jury_but.py 2024-02-02 15:26:59 +01:00
0bf3c22cd0 Modernisation code état évaluations / tableau de bord formsemestre 2024-02-02 15:16:55 +01:00
6700687e96 Supprime le formsemestre_id en tant que param (inutile) du jury PE 2024-02-02 13:48:07 +01:00
b8e20b6be8 Merge branch 'scodoc-master' into pe-BUT-v2 2024-02-02 13:31:14 +01:00
78eeb9c67f Export Excel : Ajoute un tableur donnant les informations par étudiants 2024-02-02 11:49:24 +01:00
66fbb0afbc Export Excel : Améliore les entêtes des colonnes + supprime l'affichage des classements des étudiants sans note 2024-02-02 06:11:21 +01:00
387af40b65 Améliore les entêtes de l'export Excel + sépare données min/moy/max 2024-02-01 18:10:21 +01:00
952132695f Jury BUT: l'option 'bloque moyennes' empeche la prise en compte en jury annuel 2024-01-31 23:07:03 +01:00
0b6a4b5c7e Fix: link notes.formation_tag_modules_by_type si "tous semestres" 2024-01-31 22:56:14 +01:00
556725b3ef 3 petites corrections 2024-01-31 14:27:08 +01:00
90bf31fc03 Tag module formation selon leur type 2024-01-30 22:12:55 +01:00
f7e41dc7fe Fix: api formsemestres_courants date fin incluse 2024-01-30 14:18:27 +01:00
eefbe70944 Fix: Assiduité: saisie/éditiion date dépôt justif. Fix #852 2024-01-30 11:47:57 +01:00
5446ac0ed2 PE: ajout coloness etudid, INE, NIP + some code cleaning 2024-01-30 11:02:28 +01:00
1f6f3620a2 Bul. BUT: traitement etud noin inscrit 2024-01-30 10:54:00 +01:00
04d1fbe272 col etudid in formsemestre_poursuite_report. Fix #849 2024-01-29 22:33:06 +01:00
c270c24c5b Bonus IUT Littoral: modif règle 2024-01-29 17:29:09 +01:00
8b751608e1 Bonus IUT Littoral: modif règle 2024-01-29 17:28:25 +01:00
0fb45fc9ca Bonus IUT Littoral: modif règle 2024-01-29 17:21:49 +01:00
8652ef2e7b API: numero groupe par defaut 2024-01-28 22:36:33 +01:00
9be77e4f37 PE: modernise vue 2024-01-27 13:37:01 +01:00
a00e2da461 PE: template vue 2024-01-27 13:13:44 +01:00
3481f7c1c2 PE: structure code et traite cas sans etudiants 2024-01-27 12:21:21 +01:00
64d7e1ed42 Merge PE , version. 2024-01-27 10:51:10 +01:00
d310304e9e Corrige bug ~np.isnan(set_cube) (efface notes chargées de type string) 2024-01-27 10:45:17 +01:00
fce23aa066 Ajoute un tableur pour visualiser les étudiants traités par le jury (diplômé ou redoublants/démissionnaires) 2024-01-27 10:13:04 +01:00
9c6d988fc3 Ménage + Ajout du détail des calculs (sem, trajectoires, interclassements taggués) au zip final dans un répertoire details (pouvant servir au debug) 2024-01-27 09:15:17 +01:00
cb5df2fffd Ménage + Ajout du détail des semestres taggués au zip final 2024-01-27 08:22:36 +01:00
3550e4290a Corrige les semestres pris en compte pour le calcul des moyennes d'aggrégat (bug QLIO UPHF) 2024-01-27 07:24:52 +01:00
787e514dca Ne propsoe pas chgt de mot de passe scodoc si CAS forcé pour cet user 2024-01-26 22:58:22 +01:00
e25f7d4fc9 Merge branch 'pe-BUT-v2' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-01-26 17:14:32 +01:00
f87902d1ac Corrige la boucle en erreur pour le calcul des moyennes d'aggrégat 2024-01-26 16:54:50 +01:00
39b3cd9e05 Fix: changement mot de passe par l'utilisateur lui même 2024-01-26 16:50:20 +01:00
d1074a8227 BUT: Option pour ne pas afficher décision annuelle sur bulletins 2024-01-26 16:14:08 +01:00
1b18034adb Coquille 2024-01-26 15:26:11 +01:00
871f5c1d61 Merge branch 'scodoc-master' into pe-BUT-v2 2024-01-26 15:25:31 +01:00
79f07deac0 Clonage formation: conserve ue_code, même si vide 2024-01-26 14:57:50 +01:00
431dd20911 tags formation: élargit caractères autorisés 2024-01-26 12:12:26 +01:00
4985182b9a Fix: recherche tag numériques 2024-01-26 11:38:38 +01:00
4681294cb8 evaluation_listenotes: remet colonne groupes en excel 2024-01-26 10:28:10 +01:00
f6051f930f PE: generation du zip 2024-01-26 10:18:46 +01:00
cf415763b3 Merge branch 'scodoc-master' into pe-BUT-v2
# Conflicts:
#	app/pe/pe_etudiant.py
2024-01-26 09:53:32 +01:00
769f6c0ea0 Restreint les analyses aux semestres d'une formation APC (cf. bug lyonnais) 2024-01-26 09:42:41 +01:00
33f2afb04b Fix: pe_etudiant.py 2024-01-26 09:29:36 +01:00
02bccb58aa Améliore le calcul de l'année de diplôme d'un étudiant 2024-01-26 07:13:09 +01:00
4f7da8bfa4 Tag modules formation: vérification longueur et syntaxe 2024-01-25 22:40:10 +01:00
a439c4c985 Fix typo (bug introduit par le précédent commit) + avertissement travaux en cours 2024-01-25 22:07:24 +01:00
d991eb007c PE: qq modifs de forme (type hints, comments) 2024-01-25 21:54:22 +01:00
4f10d017be Merge branch 'pe-BUT-v2' of https://scodoc.org/git/cleo/ScoDoc-PE into cleo-pe-BUT-v2 2024-01-25 21:35:44 +01:00
be7bb588cf Supprime les dépendances à pe_avislatex 2024-01-25 21:27:24 +01:00
83c6ec44c8 Merge remote-tracking branch 'scodoc/master' into pe-BUT-v2
# Conflicts:
#	app/pe/pe_jurype.py
2024-01-25 21:22:33 +01:00
3a3d47ebe4 Merge pe-BUT-v2 de Cléo 2024-01-25 21:06:59 +01:00
54be507e35 Modif affichage poids evals (non nuls) 2024-01-25 20:54:12 +01:00
efd735542e Coquille 2024-01-25 20:04:42 +01:00
776b0fb228 Mise à 0 de pe_comp.PE_DEBUG 2024-01-25 19:54:39 +01:00
cd8d73b41f Version 2 fonctionnelle 2024-01-25 19:42:22 +01:00
decc28b896 Presque la version 2? 2024-01-25 17:17:01 +01:00
a4b25eb47b Assiduité: signal_assiduites_group: impose de choisir activement un module quand l'option 'forcer' est vraie. 2024-01-24 23:00:22 +01:00
790ba910ee Lien vers page documentation codes jury BUT 2024-01-24 22:54:28 +01:00
82713752c2 Mise en place des interclassements 2024-01-24 19:37:45 +01:00
fc35974951 BUT: Ne génère plus de code annuel lors de jurys de semestre impairs 2024-01-24 19:00:12 +01:00
283daae4d9 Etat intermédiaire n°4 2024-01-24 15:37:50 +01:00
ece689eb10 API: moduleimpl-notes 2024-01-23 22:55:00 +01:00
242771c619 Fix: view_apo_csv 2024-01-23 21:42:11 +01:00
aa45680ed8 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-01-23 20:09:57 +01:00
086b8ee191 Assiduite: un peu de nettoyage, corrections 2024-01-23 20:09:13 +01:00
8477dc96ca Etat intermédiaire n°3 2024-01-23 19:08:54 +01:00
e3cde87a0f Adaptation diverses pour la gestion des aggrégats (dont les redoublements de semestre) 2024-01-23 18:44:44 +01:00
8b3efe9dad Améliore détection étudiants 2024-01-23 09:54:30 +01:00
Iziram
dfbe0dc3ed Assiduites : fix bug modif justif (signalé par Sebastien) 2024-01-23 09:14:38 +01:00
02976c9996 Etat temporaire n°2 2024-01-23 09:05:52 +01:00
2a239ab92f Assiduites: 3 bugs 2024-01-22 17:01:01 +01:00
9989f419cb Fix: 2 typos 2024-01-22 16:30:18 +01:00
74b8b90a65 Fix: retreive_formsemestre_from_request / sans semestre 2024-01-22 15:50:01 +01:00
2ad77428a5 Fix: désactive etat_abs_date si évaluation sans date de fin 2024-01-22 13:38:04 +01:00
505f5e5f1c Fix yet another bug on evaluations dates 2024-01-22 13:35:59 +01:00
a7848f0a4e typo 2024-01-22 13:18:54 +01:00
2660801dd5 API: script exemple: exemple-api-list-modules.p 2024-01-22 13:15:24 +01:00
e415a5255e Fix bug: evaluations sans dates 2024-01-22 09:57:41 +01:00
dae04658b7 Fix: bug assiduites stats 2024-01-21 23:15:44 +01:00
f842fa0b4f typo + TODO #850 2024-01-21 22:30:10 +01:00
45c685d725 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-01-21 22:23:39 +01:00
ef63e27aed Page assiduité: utilisation de templates partout. Fix #816 2024-01-21 22:21:10 +01:00
Iziram
a04278f301 Assiduites : fix initialisation datepicker signal_assiduite_group 2024-01-21 22:11:07 +01:00
a12505e8df Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-01-21 22:03:41 +01:00
Iziram
32a4ada483 Assiduites : fix problème datepicket signal_assiduite_group 2024-01-21 22:02:18 +01:00
555e8af818 Accès au détail d'un justificatif avec AbsJustifView: closes #824 2024-01-21 22:02:18 +01:00
f09b2028e2 Adaptation a minima de la table 'poursuites' pour le BUT. Closes #849. 2024-01-21 22:02:18 +01:00
7d2d5a3ea9 typos 2024-01-21 22:02:18 +01:00
a65c1d3c4a RGPD: durée conservation logs par défaut (1 an). Closes #647 2024-01-21 22:02:18 +01:00
b8eb8bb77f Deux fichiers oubliés, pour #648 2024-01-21 22:02:18 +01:00
4917034b6d RGPD: config. coordonnées DPO. Closes #648 2024-01-21 22:02:17 +01:00
81915b1522 RGPD: ViewEtudData. Implements #842 2024-01-21 22:02:17 +01:00
c6910fc76e RGPD: protection optionnelle des données perso étudiantes (ViewEtudData) sur fiche_etud 2024-01-21 22:02:17 +01:00
90c2516d01 Etat temporaire 2024-01-21 18:55:21 +01:00
aee4f14b81 Accès au détail d'un justificatif avec AbsJustifView: closes #824 2024-01-21 18:07:56 +01:00
340aa749b2 Calcul des moyennes par tag d'un settag (avec les tenseurs) 2024-01-21 18:05:00 +01:00
7a0b560d54 Celui qui voulait des tenseurs 2024-01-21 13:14:04 +01:00
9e925aa500 Détection des cursus des étudiants dans les aggrégats (quelles combinaisons de semestre pour un '3S'?) 2024-01-21 11:42:46 +01:00
ff63a32bbe Adaptation a minima de la table 'poursuites' pour le BUT. Closes #849. 2024-01-20 21:34:28 +01:00
ab116ee9e7 typos 2024-01-20 20:19:50 +01:00
b91609950c Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-01-20 19:42:26 +01:00
f2f229df4a RGPD: durée conservation logs par défaut (1 an). Closes #647 2024-01-20 19:36:20 +01:00
6908b0b8d2 Deux fichiers oubliés, pour #648 2024-01-20 19:30:42 +01:00
706b21ede7 RGPD: config. coordonnées DPO. Closes #648 2024-01-20 19:29:32 +01:00
238fbe887c RGPD: ViewEtudData. Implements #842 2024-01-20 17:37:24 +01:00
3e55391f7e Recode SemestreTag 2024-01-20 16:34:38 +01:00
9c1c316f14 RGPD: protection optionnelle des données perso étudiantes (ViewEtudData) sur fiche_etud 2024-01-20 14:49:36 +01:00
c9336dd01c Refonte de la recherche des étudiants à prendre en compte dans le JuryPE 2024-01-20 09:31:02 +01:00
Iziram
87e98b5478 Assiduites : WIP todos 2 2024-01-19 17:06:01 +01:00
Iziram
7659bcb488 Assiduites : WIP todos 2024-01-18 17:05:43 +01:00
Iziram
44de81857a Assiduites : fix typo type test_api_justif 2024-01-18 15:09:39 +01:00
Iziram
78d97d2c2d Assiduites : fix couleur etat_abs_date fixes #830 2024-01-18 09:36:38 +01:00
Iziram
4b304c559b Assiduites : fix user_id justi + fix scodoc_dept dans cache 2024-01-18 09:04:25 +01:00
21eeff90aa WIP: corrections pour passer tests unitaires. api/test_api_justificatifs.py ne passe pas (user_id) 2024-01-18 00:27:17 +01:00
6d3f276cc0 Filigranne bulletins BUT: fix #844 2024-01-17 23:52:14 +01:00
e2ca673239 Fix typo 2024-01-17 23:51:45 +01:00
f55f3fe82f Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-01-17 22:01:57 +01:00
9104a8986e EDT: raw event hidden in html. WIP: user edt. 2024-01-17 21:58:45 +01:00
Iziram
3e1f563ecd Assiduites : améliorations
- - duplication const
- + bouton justifier `ajout_assiduite_etud`
- + auto actualiser `signal_assiduite_group`
- - enum au lieu de str
- + mettre css minitimeline.css
- + resize timeline  fix
2024-01-16 16:17:06 +01:00
df20372abb Reformattage code avec Black 2024-01-16 15:51:22 +01:00
0cafc0b184 Ajoute timepicker partout. Utilise pour evaluation_edit. Fix #829 2024-01-16 13:56:35 +01:00
2c42a1547c Améliore moduleimpl_inscriptions_edit. Closes #843 2024-01-16 13:56:35 +01:00
7ce57d28cb Ajoute timepicker partout. Utilise pour evaluation_edit. Fix #829 2024-01-16 12:36:20 +01:00
e3fc13f215 Améliore moduleimpl_inscriptions_edit. Closes #843 2024-01-16 11:11:00 +01:00
Iziram
3a3f94b7cf Assiduites : fin intégration pagination + cache tableau 2024-01-16 09:30:12 +01:00
Iziram
023e3a4c04 Assiduites : pagination + tri + options tableaux 2024-01-16 09:30:02 +01:00
76bedfb303 Fix: bug synchro apo si 1 seul étudiant 2024-01-16 09:23:19 +01:00
0634dbd0aa Cache: delete_pattern 2024-01-16 09:23:19 +01:00
01d0f7d651 Erreur si le jury demandé correspond à un DUT ou tout formation non APC 2024-01-16 09:21:02 +01:00
6d16927db9 Désactive les moyennes par tag, autres que le tag but 2024-01-16 08:54:20 +01:00
2f81ce8ac2 Améliore rendu visuel min/moy/max dans excel : None/None/None -> -/-/- 2024-01-16 06:35:27 +01:00
898270d2f0 Réactive la détection des étudiants à prendre en compte dans un jury BUT + Désactive les avis LaTeX 2024-01-16 06:19:49 +01:00
e28bfa34be Entête des méthodes & fonctions 2024-01-16 05:36:27 +01:00
86e8803c87 Création branche pe-DUT-to-BUT + changement dut->but dans excel exporté 2024-01-15 19:45:38 +01:00
4babffd022 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-01-15 18:58:32 +01:00
6461d14883 Fix: bug synchro apo si 1 seul étudiant 2024-01-15 18:57:52 +01:00
524df5cbc8 Cache: delete_pattern 2024-01-15 17:49:28 +01:00
Iziram
4d19d385f1 Assiduites: harmonisation nom routes fonctions fichiers fixes #815 2024-01-12 11:42:17 +01:00
Iziram
c639778b78 Assiduites : VisuAssiduiteGr logique page fixes #804 2024-01-12 11:36:00 +01:00
Iziram
7d441b1c4d Assiduites : choix date quand date courante hors semestre fixes #837 2024-01-12 10:52:40 +01:00
Iziram
0fa35708f9 Assiduites : bug moduleimpl / autre fixes #827 2024-01-12 10:00:53 +01:00
Iziram
bcb01089ca Assiduites : fix bug api count etat mal orthographié 2024-01-12 09:08:46 +01:00
Iziram
a05801b78a Assiduites : calendrier python fixes #812 2024-01-11 17:19:56 +01:00
a90fd6dcd0 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-01-09 19:56:54 +01:00
ac9b5722cf Option de config globale pour imposer saisie du mail institutionnel dans le formulaire 2024-01-09 19:56:05 +01:00
e61ec5e04e EDT: avertissement si dates hors semestre 2024-01-09 18:48:44 +01:00
Iziram
5bb4e4e0eb Assiduites : fix bug comptage absences + invalide cache justification 2024-01-09 14:38:02 +01:00
208 changed files with 9228 additions and 10632 deletions

View File

@ -3,9 +3,11 @@
from flask_json import as_json
from flask import Blueprint
from flask import request, g
from flask_login import current_user
from app import db
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import AccessDenied, ScoException
from app.scodoc.sco_permissions import Permission
api_bp = Blueprint("api", __name__)
api_web_bp = Blueprint("apiweb", __name__)
@ -48,13 +50,21 @@ def requested_format(default_format="json", allowed_formats=None):
@as_json
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
def get_model_api_object(
model_cls: db.Model,
model_id: int,
join_cls: db.Model = None,
restrict: bool | None = None,
):
"""
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemestre) -> join_cls
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
L'agument restrict est passé to_dict, est signale que l'on veut une version restreinte
(sans données personnelles, ou sans informations sur le justificatif d'absence)
"""
query = model_cls.query.filter_by(id=model_id)
if g.scodoc_dept and join_cls is not None:
@ -66,8 +76,9 @@ def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model
404,
message=f"{model_cls.__name__} inexistant(e)",
)
return unique.to_dict(format_api=True)
if restrict is None:
return unique.to_dict(format_api=True)
return unique.to_dict(format_api=True, restrict=restrict)
from app.api import tokens

View File

@ -337,7 +337,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))

View File

@ -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,

View File

@ -10,7 +10,7 @@
from datetime import datetime
from operator import attrgetter
from flask import g, request
from flask import g, request, Response
from flask_json import as_json
from flask_login import current_user
from flask_login import login_required
@ -18,7 +18,7 @@ from sqlalchemy import desc, func, or_
from sqlalchemy.dialects.postgresql import VARCHAR
import app
from app import db
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.api import tools
from app.but import bulletin_but_court
@ -26,6 +26,7 @@ from app.decorators import scodoc, permission_required
from app.models import (
Admission,
Departement,
EtudAnnotation,
FormSemestreInscription,
FormSemestre,
Identite,
@ -54,6 +55,32 @@ import app.scodoc.sco_utils as scu
#
def _get_etud_by_code(
code_type: str, code: str, dept: Departement
) -> tuple[bool, Response | Identite]:
"""Get etud, using etudid, NIP or INE
Returns True, etud if ok, or False, error response.
"""
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return False, json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code)
else:
return False, json_error(404, "invalid code_type")
if dept:
query = query.filter_by(dept_id=dept.id)
etud = query.first()
if etud is None:
return False, json_error(404, message="etudiant inexistant")
return True, etud
@bp.route("/etudiants/courants", defaults={"long": False})
@bp.route("/etudiants/courants/long", defaults={"long": True})
@api_web_bp.route("/etudiants/courants", defaults={"long": False})
@ -104,7 +131,10 @@ def etudiants_courants(long=False):
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
if long:
data = [etud.to_dict_api() for etud in etuds]
restrict = not current_user.has_permission(Permission.ViewEtudData)
data = [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in etuds
]
else:
data = [etud.to_dict_short() for etud in etuds]
return data
@ -138,8 +168,8 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
404,
message="étudiant inconnu",
)
return etud.to_dict_api()
restrict = not current_user.has_permission(Permission.ViewEtudData)
return etud.to_dict_api(restrict=restrict, with_annotations=True)
@bp.route("/etudiant/etudid/<int:etudid>/photo")
@ -251,7 +281,10 @@ def etudiants(etudid: int = None, nip: str = None, ine: str = None):
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
return [etud.to_dict_api() for etud in query]
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict, with_annotations=True) for etud in query
]
@bp.route("/etudiants/name/<string:start>")
@ -278,7 +311,11 @@ def etudiants_by_name(start: str = "", min_len=3, limit=32):
)
etuds = query.order_by(Identite.nom, Identite.prenom).limit(limit)
# Note: on raffine le tri pour les caractères spéciaux et nom usuel ici:
return [etud.to_dict_api() for etud in sorted(etuds, key=attrgetter("sort_key"))]
restrict = not current_user.has_permission(Permission.ViewEtudData)
return [
etud.to_dict_api(restrict=restrict)
for etud in sorted(etuds, key=attrgetter("sort_key"))
]
@bp.route("/etudiant/etudid/<int:etudid>/formsemestres")
@ -379,28 +416,15 @@ def bulletin(
pdf = True
if version not in scu.BULLETINS_VERSIONS_BUT:
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()
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre inexistant")
app.set_sco_dept(dept.acronym)
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code, dept_id=dept.id)
else:
return json_error(404, "invalid code_type")
etud = query.first()
if etud is None:
return json_error(404, message="etudiant inexistant")
ok, etud = _get_etud_by_code(code_type, code, dept)
if not ok:
return etud # json error
if version == "butcourt":
if pdf:
@ -543,7 +567,8 @@ def etudiant_create(force=False):
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
r = etud.to_dict_api()
r = etud.to_dict_api(restrict=False) # pas de restriction, on vient de le créer
return r
@ -551,26 +576,15 @@ def etudiant_create(force=False):
@api_web_bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_edit(
code_type: str = "etudid",
code: str = None,
):
"""Edition des données étudiant (identité, admission, adresses)"""
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code)
else:
return json_error(404, "invalid code_type")
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first()
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
#
args = request.get_json(force=True) # may raise 400 Bad Request
etud.from_dict(args)
@ -590,5 +604,70 @@ def etudiant_edit(
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
r = etud.to_dict_api()
restrict = not current_user.has_permission(Permission.ViewEtudData)
r = etud.to_dict_api(restrict=restrict)
return r
@bp.route("/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"])
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"]
)
@scodoc
@permission_required(Permission.EtudInscrit) # il faut en plus ViewEtudData
@as_json
def etudiant_annotation(
code_type: str = "etudid",
code: str = None,
):
"""Ajout d'une annotation sur un étudiant"""
if not current_user.has_permission(Permission.ViewEtudData):
return json_error(403, "non autorisé (manque ViewEtudData)")
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
#
args = request.get_json(force=True) # may raise 400 Bad Request
comment = args.get("comment", None)
if not isinstance(comment, str):
return json_error(404, "invalid comment (expected string)")
if len(comment) > scu.MAX_TEXT_LEN:
return json_error(404, "invalid comment (too large)")
annotation = EtudAnnotation(comment=comment, author=current_user.user_name)
etud.annotations.append(annotation)
db.session.add(etud)
db.session.commit()
log(f"etudiant_annotation/{etud.id}/{annotation.id}")
return annotation.to_dict()
@bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@as_json
@permission_required(Permission.EtudInscrit)
def etudiant_annotation_delete(
code_type: str = "etudid", code: str = None, annotation_id: int = None
):
"""
Suppression d'une annotation
"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
annotation = EtudAnnotation.query.filter_by(
etudid=etud.id, id=annotation_id
).first()
if annotation is None:
return json_error(404, "annotation not found")
log(f"etudiant_annotation_delete/{etud.id}/{annotation.id}")
db.session.delete(annotation)
db.session.commit()
return "ok"

View File

@ -67,7 +67,7 @@ def get_evaluation(evaluation_id: int):
@scodoc
@permission_required(Permission.ScoView)
@as_json
def evaluations(moduleimpl_id: int):
def moduleimpl_evaluations(moduleimpl_id: int):
"""
Retourne la liste des évaluations d'un moduleimpl
@ -75,14 +75,8 @@ def evaluations(moduleimpl_id: int):
Exemple de résultat : voir /evaluation
"""
query = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id)
if g.scodoc_dept:
query = (
query.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
return [e.to_dict_api() for e in query]
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return [evaluation.to_dict_api() for evaluation in modimpl.evaluations]
@bp.route("/evaluation/<int:evaluation_id>/notes")

View File

@ -11,7 +11,7 @@ from operator import attrgetter, itemgetter
from flask import g, make_response, request
from flask_json import as_json
from flask_login import login_required
from flask_login import current_user, login_required
import app
from app import db
@ -360,7 +360,8 @@ def formsemestre_etudiants(
inscriptions = formsemestre.inscriptions
if long:
etuds = [ins.etud.to_dict_api() for ins in inscriptions]
restrict = not current_user.has_permission(Permission.ViewEtudData)
etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions]
else:
etuds = [ins.etud.to_dict_short() for ins in inscriptions]
# Ajout des groupes de chaque étudiants
@ -425,7 +426,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:

View File

@ -66,7 +66,7 @@ def _news_delete_jury_etud(etud: Identite):
"génère news sur effacement décision"
# n'utilise pas g.scodoc_dept, pas toujours dispo en mode API
url = url_for(
"scolar.ficheEtud", scodoc_dept=etud.departement.acronym, etudid=etud.id
"scolar.fiche_etud", scodoc_dept=etud.departement.acronym, etudid=etud.id
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,

View File

@ -15,7 +15,7 @@ from werkzeug.exceptions import NotFound
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app import db, set_sco_dept
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object, tools
@ -53,14 +53,19 @@ def justificatif(justif_id: int = None):
"date_fin": "2022-10-31T10:00+01:00",
"etat": "valide",
"fichier": "archive_id",
"raison": "une raison",
"raison": "une raison", // VIDE si pas le droit
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
"""
return get_model_api_object(Justificatif, justif_id, Identite)
return get_model_api_object(
Justificatif,
justif_id,
Identite,
restrict=not current_user.has_permission(Permission.AbsJustifView),
)
# etudid
@ -133,8 +138,9 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
# Mise en forme des données puis retour en JSON
data_set: list[dict] = []
restrict = not current_user.has_permission(Permission.AbsJustifView)
for just in justificatifs_query.all():
data = just.to_dict(format_api=True)
data = just.to_dict(format_api=True, restrict=restrict)
data_set.append(data)
return data_set
@ -151,7 +157,10 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
@as_json
@permission_required(Permission.ScoView)
def justificatifs_dept(dept_id: int = None, with_query: bool = False):
"""XXX TODO missing doc"""
"""
Renvoie tous les justificatifs d'un département
(en ajoutant un champ "formsemestre" si possible)
"""
# Récupération du département et des étudiants du département
dept: Departement = Departement.query.get(dept_id)
@ -169,14 +178,15 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False):
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Mise en forme des données et retour JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
data_set: list[dict] = []
for just in justificatifs_query:
data_set.append(_set_sems(just))
data_set.append(_set_sems(just, restrict=restrict))
return data_set
def _set_sems(justi: Justificatif) -> dict:
def _set_sems(justi: Justificatif, restrict: bool) -> dict:
"""
_set_sems Ajoute le formsemestre associé au justificatif s'il existe
@ -189,7 +199,7 @@ def _set_sems(justi: Justificatif) -> dict:
dict: La représentation de l'assiduité en dictionnaire
"""
# Conversion du justificatif en dictionnaire
data = justi.to_dict(format_api=True)
data = justi.to_dict(format_api=True, restrict=restrict)
# Récupération du formsemestre de l'assiduité
formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict())
@ -243,9 +253,10 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Retour des justificatifs en JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
data_set: list[dict] = []
for justi in justificatifs_query.all():
data = justi.to_dict(format_api=True)
data = justi.to_dict(format_api=True, restrict=restrict)
data_set.append(data)
return data_set
@ -294,6 +305,7 @@ def justif_create(etudid: int = None, nip=None, ine=None):
404,
message="étudiant inconnu",
)
set_sco_dept(etud.departement.acronym)
# Récupération des justificatifs à créer
create_list: list[object] = request.get_json(force=True)

View File

@ -8,16 +8,14 @@
ScoDoc 9 API : accès aux moduleimpl
"""
from flask import g
from flask_json import as_json
from flask_login import login_required
import app
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.models import (
FormSemestre,
ModuleImpl,
)
from app.models import ModuleImpl
from app.scodoc import sco_liste_notes
from app.scodoc.sco_permissions import Permission
@ -62,10 +60,7 @@ def moduleimpl(moduleimpl_id: int):
}
}
"""
query = ModuleImpl.query.filter_by(id=moduleimpl_id)
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
modimpl: ModuleImpl = query.first_or_404()
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return modimpl.to_dict(convert_objects=True)
@ -87,8 +82,36 @@ def moduleimpl_inscriptions(moduleimpl_id: int):
...
]
"""
query = ModuleImpl.query.filter_by(id=moduleimpl_id)
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
modimpl: ModuleImpl = query.first_or_404()
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
return [i.to_dict() for i in modimpl.inscriptions]
@bp.route("/moduleimpl/<int:moduleimpl_id>/notes")
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/notes")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def moduleimpl_notes(moduleimpl_id: int):
"""Liste des notes dans ce moduleimpl
Exemple de résultat :
[
{
"etudid": 17776, // code de l'étudiant
"nom": "DUPONT",
"prenom": "Luz",
"38411": 16.0, // Note dans l'évaluation d'id 38411
"38410": 15.0,
"moymod": 15.5, // Moyenne INDICATIVE module
"moy_ue_2875": 15.5, // Moyenne vers l'UE 2875
"moy_ue_2876": 15.5, // Moyenne vers l'UE 2876
"moy_ue_2877": 15.5 // Moyenne vers l'UE 2877
},
...
]
"""
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
app.set_sco_dept(modimpl.formsemestre.departement.acronym)
table, _ = sco_liste_notes.do_evaluation_listenotes(
moduleimpl_id=modimpl.id, fmt="json"
)
return table

View File

@ -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)

View File

@ -7,7 +7,6 @@
"""
ScoDoc 9 API : accès aux utilisateurs
"""
import datetime
from flask import g, request
from flask_json import as_json
@ -15,13 +14,14 @@ from flask_login import current_user, login_required
from app import db, log
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
from app.auth.models import User, Role, UserRole
from app.auth.models import is_valid_password
from app.decorators import scodoc, permission_required
from app.models import Departement
from app.models import Departement, ScoDocSiteConfig
from app.scodoc import sco_edt_cal
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
from app.scodoc import sco_utils as scu
@ -441,3 +441,63 @@ def role_delete(role_name: str):
db.session.delete(role)
db.session.commit()
return {"OK": True}
# @bp.route("/user/<int:uid>/edt")
# @api_web_bp.route("/user/<int:uid>/edt")
# @login_required
# @scodoc
# @permission_required(Permission.ScoView)
# @as_json
# def user_edt(uid: int):
# """L'emploi du temps de l'utilisateur.
# Si ok, une liste d'évènements. Sinon, une chaine indiquant un message d'erreur.
# show_modules_titles affiche le titre complet du module (défaut), sinon juste le code.
# Il faut la permission ScoView + (UsersView ou bien être connecté comme l'utilisateur demandé)
# """
# if g.scodoc_dept is None: # route API non départementale
# if not current_user.has_permission(Permission.UsersView):
# return scu.json_error(403, "accès non autorisé")
# user: User = db.session.get(User, uid)
# if user is None:
# return json_error(404, "user not found")
# # Check permission
# if current_user.id != user.id:
# if g.scodoc_dept:
# allowed_depts = current_user.get_depts_with_permission(Permission.UsersView)
# if (None not in allowed_depts) and (user.dept not in allowed_depts):
# return json_error(404, "user not found")
# show_modules_titles = scu.to_bool(request.args.get("show_modules_titles", False))
# # Cherche ics
# if not user.edt_id:
# return json_error(404, "user not configured")
# ics_filename = sco_edt_cal.get_ics_user_edt_filename(user.edt_id)
# if not ics_filename:
# return json_error(404, "no calendar for this user")
# _, calendar = sco_edt_cal.load_calendar(ics_filename)
# # TODO:
# # - Construire mapping edt2modimpl: edt_id -> modimpl
# # pour cela, considérer tous les formsemestres de la période de l'edt
# # (soit on considère l'année scolaire du 1er event, ou celle courante,
# # soit on cherche min, max des dates des events)
# # - Modifier décodage des groupes dans convert_ics pour avoi run mapping
# # de groupe par semestre (retrouvé grâce au modimpl associé à l'event)
# raise NotImplementedError() # TODO XXX WIP
# events_scodoc, _ = sco_edt_cal.convert_ics(
# calendar,
# edt2group=edt2group,
# default_group=default_group,
# edt2modimpl=edt2modimpl,
# )
# edt_dict = sco_edt_cal.translate_calendar(
# events_scodoc, group_ids, show_modules_titles=show_modules_titles
# )
# return edt_dict

View File

@ -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):

View File

@ -349,19 +349,12 @@ class BulletinBUT:
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,14 +363,20 @@ 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
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)
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
etud_groups = sco_groups.get_etud_formsemestre_groups(
etud, formsemestre, only_to_show=True
@ -410,7 +409,7 @@ class BulletinBUT:
semestre_infos.update(
sco_bulletins_json.dict_decision_jury(etud, formsemestre)
)
if etat_inscription == scu.INSCRIT:
if d["etat_inscription"] == scu.INSCRIT:
# moyenne des moyennes générales du semestre
semestre_infos["notes"] = {
"value": fmt_note(res.etud_moy_gen[etud.id]),
@ -499,10 +498,8 @@ class BulletinBUT:
d["etud"]["etat_civil"] = etud.etat_civil
d.update(self.res.sem)
etud_etat = self.res.get_etud_etat(etud.id)
d["filigranne"] = sco_bulletins_pdf.get_filigranne(
etud_etat,
self.prefs,
decision_sem=d["semestre"].get("decision"),
d["filigranne"] = sco_bulletins_pdf.get_filigranne_apc(
etud_etat, self.prefs, etud.id, res=self.res
)
if etud_etat == scu.DEMISSION:
d["demission"] = "(Démission)"

View File

@ -104,8 +104,10 @@ def _build_bulletin_but_infos(
bulletins_sem = BulletinBUT(formsemestre)
if fmt == "pdf":
bul: dict = bulletins_sem.bulletin_etud_complet(etud)
filigranne = bul["filigranne"]
else: # la même chose avec un peu moins d'infos
bul: dict = bulletins_sem.bulletin_etud(etud, force_publishing=True)
filigranne = ""
decision_ues = (
{x["acronyme"]: x for x in bul["semestre"]["decision_ue"]}
if "semestre" in bul and "decision_ue" in bul["semestre"]
@ -131,6 +133,7 @@ def _build_bulletin_but_infos(
"decision_ues": decision_ues,
"ects_total": ects_total,
"etud": etud,
"filigranne": filigranne,
"formsemestre": formsemestre,
"logo": logo,
"prefs": bulletins_sem.prefs,

View File

@ -48,6 +48,7 @@ def make_bulletin_but_court_pdf(
ects_total: float = 0.0,
etud: Identite = None,
formsemestre: FormSemestre = None,
filigranne=""
logo: Logo = None,
prefs: SemPreferences = None,
title: str = "",
@ -86,6 +87,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
decision_ues: dict = None,
ects_total: float = 0.0,
etud: Identite = None,
filigranne="",
formsemestre: FormSemestre = None,
logo: Logo = None,
prefs: SemPreferences = None,
@ -95,7 +97,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
] = None,
ues_acronyms: list[str] = None,
):
super().__init__(bul, authuser=current_user)
super().__init__(bul, authuser=current_user, filigranne=filigranne)
self.bul = bul
self.cursus = cursus
self.decision_ues = decision_ues
@ -192,7 +194,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:
@ -404,6 +406,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
def boite_identite(self) -> list:
"Les informations sur l'identité et l'inscription de l'étudiant"
parcour = self.formsemestre.etuds_inscriptions[self.etud.id].parcour
return [
Paragraph(
SU(f"""{self.etud.nomprenom}"""),
@ -414,7 +418,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,

View File

@ -119,9 +119,13 @@ 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 not niveau.competence.id in self.validation_par_competence_et_annee:
if (
niveau is None
or not niveau.competence.id in self.validation_par_competence_et_annee
):
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id
@ -443,8 +447,24 @@ def formsemestre_warning_apc_setup(
}">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(
f"""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 = {

View File

@ -380,14 +380,24 @@ class DecisionsProposeesAnnee(DecisionsProposees):
sco_codes.ADJ,
] + self.codes
explanation += f" et {self.nb_rcues_under_8} < 8"
else:
self.codes = [
sco_codes.RED,
sco_codes.NAR,
sco_codes.PAS1NCI,
sco_codes.ADJ,
sco_codes.PASD, # voir #488 (discutable, conventions locales)
] + self.codes
else: # autres cas: non admis, non passage, non dem, pas la moitié des rcue:
if formsemestre.semestre_id % 2 and self.formsemestre_pair is None:
# Si jury sur un seul semestre impair, ne propose pas redoublement
# et efface décision éventuellement existante
codes = [None]
else:
codes = []
self.codes = (
codes
+ [
sco_codes.RED,
sco_codes.NAR,
sco_codes.PAS1NCI,
sco_codes.ADJ,
sco_codes.PASD, # voir #488 (discutable, conventions locales)
]
+ self.codes
)
explanation += f""" et {self.nb_rcues_under_8
} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
@ -407,7 +417,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
+ '</div><div class="warning">'.join(messages)
+ "</div>"
)
self.codes = [self.codes[0]] + sorted(self.codes[1:])
self.codes = [self.codes[0]] + sorted((c or "") for c in self.codes[1:])
def passage_de_droit_en_but3(self) -> tuple[bool, str]:
"""Vérifie si les conditions supplémentaires de passage BUT2 vers BUT3 sont satisfaites"""
@ -514,19 +524,21 @@ class DecisionsProposeesAnnee(DecisionsProposees):
"""Les deux formsemestres auquel est inscrit l'étudiant (ni DEM ni DEF)
du niveau auquel appartient formsemestre.
-> S_impair, S_pair
-> S_impair, S_pair (de la même année scolaire)
Si l'origine est impair, S_impair est l'origine et S_pair est None
Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur
suivi par cet étudiant (ou None).
Note: si l'option "block_moyennes" est activée, ne prend pas en compte le semestre.
"""
if not formsemestre.formation.is_apc(): # garde fou
return None, None
if formsemestre.semestre_id % 2:
idx_autre = formsemestre.semestre_id + 1
idx_autre = formsemestre.semestre_id + 1 # impair, autre = suivant
else:
idx_autre = formsemestre.semestre_id - 1
idx_autre = formsemestre.semestre_id - 1 # pair: autre = précédent
# Cherche l'autre semestre de la même année scolaire:
autre_formsemestre = None
@ -539,6 +551,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
@ -610,6 +624,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def next_semestre_ids(self, code: str) -> set[int]:
"""Les indices des semestres dans lequels l'étudiant est autorisé
à poursuivre après le semestre courant.
code: code jury sur année BUT
"""
# La poursuite d'études dans un semestre pair d'une même année
# est de droit pour tout étudiant.
@ -653,6 +668,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
Si les code_rcue et le code_annee ne sont pas fournis,
et qu'il n'y en a pas déjà, enregistre ceux par défaut.
Si le code_annee est None, efface le code déjà enregistré.
"""
log("jury_but.DecisionsProposeesAnnee.record_form")
code_annee = self.codes[0] # si pas dans le form, valeur par defaut
@ -697,6 +714,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def record(self, code: str, mark_recorded: bool = True) -> bool:
"""Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
Si l'étudiant est DEM ou DEF, ne fait rien.
Si le code est None, efface le code déjà enregistré.
Si mark_recorded est vrai, positionne self.recorded
"""
if self.inscription_etat != scu.INSCRIT:
@ -746,7 +764,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
return True
def record_autorisation_inscription(self, code: str):
"""Autorisation d'inscription dans semestre suivant"""
"""Autorisation d'inscription dans semestre suivant.
code: code jury sur année BUT
"""
if self.autorisations_recorded:
return
if self.inscription_etat != scu.INSCRIT:

View File

@ -154,7 +154,7 @@ def pvjury_table_but(
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_target": url_for(
"scolar.ficheEtud",
"scolar.fiche_etud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
),

View File

@ -97,7 +97,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():
@ -256,7 +256,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}
@ -447,7 +447,7 @@ def jury_but_semestriel(
<div class="nom_etud">{etud.nomprenom}</div>
</div>
<div class="bull_photo"><a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
</div>
</div>

View File

@ -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):

View File

@ -56,6 +56,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):
@ -168,25 +169,34 @@ 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)]
# Nombre de notes (non vides, incluant ATT etc) des inscrits:
nb_notes = eval_notes_inscr.notna().sum()
# 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 "imé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)

View File

@ -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)

View File

@ -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.

View File

@ -234,7 +234,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
raise ScoValueError(
f"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée {ue.acronyme}
impossible à déterminer pour l'étudiant <a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}" class="discretelink">{etud.nom_disp()}</a></p>
<p>Il faut <a href="{
url_for("notes.formsemestre_edit_uecoefs", scodoc_dept=g.scodoc_dept,

View File

@ -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,82 @@ 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" {
"evalcomplete" : bool,
"last_modif" : datetime.datetime | None, # saisie de note la plus récente
"nb_notes" : int, # nb notes d'étudiants inscrits
},
"evaluatiuon_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 or 0.0,
"description": evaluation.description or "",
"evaluation_id": evaluation.id,
"jour": evaluation.date_debut or datetime.datetime(1900, 1, 1),
"etat": {
"evalcomplete": etat.is_complete,
"nb_notes": etat.nb_notes,
"last_modif": last_modif,
},
"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:

View File

@ -423,30 +423,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"""

View File

@ -32,6 +32,7 @@ Formulaire ajout d'un justificatif sur un étudiant
from flask_wtf import FlaskForm
from flask_wtf.file import MultipleFileField
from wtforms import (
BooleanField,
SelectField,
StringField,
SubmitField,
@ -101,7 +102,7 @@ class AjoutAssiOrJustForm(FlaskForm):
)
entry_date = StringField(
"Date de dépot ou saisie",
"Date de dépôt ou saisie",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
@ -109,6 +110,16 @@ 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})
@ -136,6 +147,7 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"Module",
choices={}, # will be populated dynamically
)
est_just = BooleanField("Justifiée")
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
@ -161,3 +173,30 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField(label="Ajouter des fichiers")
class ChoixDateForm(FlaskForm):
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)
self.ok = True
self.error_messages: list[str] = [] # used to report our errors
def set_error(self, err_msg, field=None):
"Set error message both in form and field"
self.ok = False
self.error_messages.append(err_msg)
if field:
field.errors.append(err_msg)
date = StringField(
"Date",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "date",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -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(

View File

@ -34,52 +34,11 @@ import re
from flask_wtf import FlaskForm
from wtforms import DecimalField, SubmitField, ValidationError
from wtforms.fields.simple import StringField
from wtforms.validators import Optional
from wtforms.validators import Optional, Length
from wtforms.widgets import TimeInput
class TimeField(StringField):
"""HTML5 time input.
tiré de : https://gist.github.com/tachyondecay/6016d32f65a996d0d94f
"""
widget = TimeInput()
def __init__(self, label=None, validators=None, fmt="%H:%M:%S", **kwargs):
super(TimeField, self).__init__(label, validators, **kwargs)
self.fmt = fmt
self.data = None
def _value(self):
if self.raw_data:
return " ".join(self.raw_data)
if self.data and isinstance(self.data, str):
self.data = datetime.time(*map(int, self.data.split(":")))
return self.data and self.data.strftime(self.fmt) or ""
def process_formdata(self, valuelist):
if valuelist:
time_str = " ".join(valuelist)
try:
components = time_str.split(":")
hour = 0
minutes = 0
seconds = 0
if len(components) in range(2, 4):
hour = int(components[0])
minutes = int(components[1])
if len(components) == 3:
seconds = int(components[2])
else:
raise ValueError
self.data = datetime.time(hour, minutes, seconds)
except ValueError as exc:
self.data = None
raise ValueError(self.gettext("Not a valid time string")) from exc
def check_tick_time(form, field):
"""Le tick_time doit être entre 0 et 60 minutes"""
if field.data < 1 or field.data > 59:
@ -118,14 +77,36 @@ def check_ics_regexp(form, field):
class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduité"
assi_morning_time = TimeField(
"Début de la journée"
) # TODO utiliser TextField + timepicker voir AjoutAssiOrJustForm
assi_lunch_time = TimeField(
"Heure de midi (date pivot entre matin et après-midi)"
) # TODO
assi_afternoon_time = TimeField("Fin de la journée") # TODO
assi_morning_time = StringField(
"Début de la journée",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_morning_time",
},
)
assi_lunch_time = StringField(
"Heure de midi (date pivot entre matin et après-midi)",
default="",
validators=[Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_lunch_time",
},
)
assi_afternoon_time = StringField(
"Fin de la journée",
validators=[Length(max=5)],
default="",
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_afternoon_time",
},
)
assi_tick_time = DecimalField(
"Granularité de la timeline (temps en minutes)",

View File

@ -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.""",
)

View File

@ -77,6 +77,9 @@ class ScoDocConfigurationForm(FlaskForm):
Attention: si ce champ peut aussi être défini dans chaque département.""",
validators=[Optional(), Email()],
)
user_require_email_institutionnel = BooleanField(
"imposer la saisie du mail institutionnel dans le formulaire de création utilisateur"
)
disable_bul_pdf = BooleanField(
"interdire les exports des bulletins en PDF (déconseillé)"
)
@ -99,6 +102,7 @@ def configuration():
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
"disable_bul_pdf": ScoDocSiteConfig.is_bul_pdf_disabled(),
"user_require_email_institutionnel": ScoDocSiteConfig.is_user_require_email_institutionnel_enabled(),
}
)
if request.method == "POST" and (
@ -151,6 +155,18 @@ def configuration():
"Exports PDF "
+ ("désactivés" if form_scodoc.data["disable_bul_pdf"] else "réactivés")
)
if ScoDocSiteConfig.set(
"user_require_email_institutionnel",
"on" if form_scodoc.data["user_require_email_institutionnel"] else "",
):
flash(
(
"impose"
if form_scodoc.data["user_require_email_institutionnel"]
else "n'impose pas"
)
+ " la saisie du mail institutionnel des utilisateurs"
)
return redirect(url_for("scodoc.index"))
return render_template(

View File

@ -0,0 +1,49 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire configuration RGPD
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField
from wtforms.fields.simple import TextAreaField
class ConfigRGPDForm(FlaskForm):
"Formulaire paramétrage RGPD"
rgpd_coordonnees_dpo = TextAreaField(
label="Optionnel: coordonnées du DPO",
description="""Le délégué à la protection des données (DPO) est chargé de mettre en œuvre
la conformité au règlement européen sur la protection des données (RGPD) au sein de lorganisme.
Indiquer ici les coordonnées (format libre) qui seront affichées aux utilisateurs de ScoDoc.
""",
render_kw={"rows": 5, "cols": 72},
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -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))

View File

@ -2,8 +2,10 @@
"""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 (
@ -87,8 +89,10 @@ class Assiduite(ScoDocModel):
lazy="select",
)
def to_dict(self, format_api=True) -> dict:
"""Retourne la représentation json de l'assiduité"""
def to_dict(self, format_api=True, restrict: bool | None = None) -> dict:
"""Retourne la représentation json de l'assiduité
restrict n'est pas utilisé ici.
"""
etat = self.etat
user: User | None = None
if format_api:
@ -249,50 +253,57 @@ class Assiduite(ScoDocModel):
sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut)
return nouv_assiduite
def set_moduleimpl(self, moduleimpl_id: int | str) -> bool:
"""TODO"""
# je ne comprend pas cette fonction WIP
# moduleimpl_id peut être == "autre", ce qui plante
# ci-dessous un fix temporaire en attendant explication de @iziram
if moduleimpl_id is None:
raise ScoValueError("invalid moduleimpl_id")
try:
moduleimpl_id_int = int(moduleimpl_id)
except ValueError as exc:
raise ScoValueError("invalid moduleimpl_id") from exc
# /fix
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id_int)
if moduleimpl is not None:
# Vérification de l'inscription de l'étudiant
if moduleimpl.est_inscrit(self.etudiant):
self.moduleimpl_id = moduleimpl.id
else:
raise ScoValueError("L'étudiant n'est pas inscrit au module")
elif isinstance(moduleimpl_id, str):
def set_moduleimpl(self, moduleimpl_id: int | str):
"""Mise à jour du moduleimpl_id
Les valeurs du champ "moduleimpl_id" possibles sont :
- <int> (un id classique)
- <str> ("autre" ou "<id>")
- "" (pas de moduleimpl_id)
Si la valeur est "autre" il faut:
- mettre à None assiduité.moduleimpl_id
- mettre à jour assiduite.external_data["module"] = "autre"
En fonction de la configuration du semestre (option force_module) la valeur "" peut-être
considérée comme invalide.
- Il faudra donc vérifier que ce n'est pas le cas avant de mettre à jour l'assiduité
"""
moduleimpl: ModuleImpl = None
if moduleimpl_id == "autre":
# Configuration de external_data pour Module Autre
# Si self.external_data None alors on créé un dictionnaire {"module": "autre"}
# Sinon on met à jour external_data["module"] à "autre"
if self.external_data is None:
self.external_data = {"module": moduleimpl_id}
self.external_data = {"module": "autre"}
else:
self.external_data["module"] = moduleimpl_id
self.external_data["module"] = "autre"
# Dans tous les cas une fois fait, assiduite.moduleimpl_id doit être None
self.moduleimpl_id = None
# Ici pas de vérification du force module car on l'a mis dans "external_data"
return
if moduleimpl_id != "":
try:
moduleimpl_id = int(moduleimpl_id)
except ValueError as exc:
raise ScoValueError("Module non reconnu") from exc
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
# ici moduleimpl est None si non spécifié
# Vérification ModuleImpl not None (raise ScoValueError)
if moduleimpl is None:
self._check_force_module()
# Ici uniquement si on est autorisé à ne pas avoir de module
self.moduleimpl_id = None
return
# Vérification Inscription ModuleImpl (raise ScoValueError)
if moduleimpl.est_inscrit(self.etudiant):
self.moduleimpl_id = moduleimpl.id
else:
# Vérification si module forcé
formsemestre: FormSemestre = get_formsemestre_from_data(
{
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
}
)
force: bool
if formsemestre:
force = is_assiduites_module_forced(formsemestre_id=formsemestre.id)
else:
force = is_assiduites_module_forced(dept_id=self.etudiant.dept_id)
if force:
raise ScoValueError("Module non renseigné")
return True
raise ScoValueError("L'étudiant n'est pas inscrit au module")
def supprime(self):
"Supprime l'assiduité. Log et commit."
@ -322,7 +333,7 @@ class Assiduite(ScoDocModel):
return get_formsemestre_from_data(self.to_dict())
def get_module(self, traduire: bool = False) -> int | str:
"TODO"
"TODO documenter"
if self.moduleimpl_id is not None:
if traduire:
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
@ -338,6 +349,41 @@ class Assiduite(ScoDocModel):
return "Non spécifié" if traduire else None
def get_saisie(self) -> str:
"""
retourne le texte "saisie le <date> par <User>"
"""
date: str = self.entry_date.strftime("%d/%m/%Y à %H:%M")
utilisateur: str = ""
if self.user != None:
self.user: User
utilisateur = f"par {self.user.get_prenomnom()}"
return f"saisie le {date} {utilisateur}"
def _check_force_module(self):
"""Vérification si module forcé:
Si le module est requis, raise ScoValueError
sinon ne fait rien.
"""
# cherche le formsemestre affecté pour utiliser ses préférences
formsemestre: FormSemestre = get_formsemestre_from_data(
{
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
}
)
formsemestre_id = formsemestre.id if formsemestre else None
# si pas de formsemestre, utilisera les prefs globales du département
dept_id = self.etudiant.dept_id
force = is_assiduites_module_forced(
formsemestre_id=formsemestre_id, dept_id=dept_id
)
if force:
raise ScoValueError("Module non renseigné")
class Justificatif(ScoDocModel):
"""
@ -372,7 +418,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,
@ -391,6 +437,14 @@ class Justificatif(ScoDocModel):
etudiant = db.relationship(
"Identite", back_populates="justificatifs", lazy="joined"
)
# En revanche, user est rarement accédé:
user = db.relationship(
"User",
backref=db.backref(
"justificatifs", lazy="select", order_by="Justificatif.entry_date"
),
lazy="select",
)
external_data = db.Column(db.JSON, nullable=True)
@ -402,20 +456,16 @@ class Justificatif(ScoDocModel):
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
return query.first_or_404()
def to_dict(self, format_api: bool = False) -> dict:
"""transformation de l'objet en dictionnaire sérialisable"""
def to_dict(self, format_api: bool = False, restrict: bool = False) -> dict:
"""L'objet en dictionnaire sérialisable.
Si restrict, ne donne par la raison et les fichiers et external_data
"""
etat = self.etat
username = self.user_id
user: User = self.user if self.user_id is not None else None
if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name
if self.user_id is not None:
user: User = db.session.get(User, self.user_id)
if user is None:
username = "Non renseigné"
else:
username = user.get_prenomnom()
data = {
"justif_id": self.justif_id,
@ -424,11 +474,13 @@ class Justificatif(ScoDocModel):
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"raison": self.raison,
"fichier": self.fichier,
"raison": None if restrict else self.raison,
"fichier": None if restrict else self.fichier,
"entry_date": self.entry_date,
"user_id": username,
"external_data": self.external_data,
"user_id": None if user is None else user.id, # l'uid
"user_name": None if user is None else user.user_name, # le login
"user_nom_complet": None if user is None else user.get_nomcomplet(),
"external_data": None if restrict else self.external_data,
}
return data
@ -575,6 +627,12 @@ def compute_assiduites_justified(
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(

View File

@ -9,6 +9,7 @@ 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
class ApcValidationRCUE(db.Model):
@ -76,10 +77,12 @@ class ApcValidationRCUE(db.Model):
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"
@ -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

View File

@ -95,6 +95,7 @@ class ScoDocSiteConfig(db.Model):
"month_debut_annee_scolaire": int,
"month_debut_periode2": int,
"disable_bul_pdf": bool,
"user_require_email_institutionnel": bool,
# CAS
"cas_enable": bool,
"cas_server": str,
@ -243,6 +244,14 @@ class ScoDocSiteConfig(db.Model):
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
return cfg is not None and cfg.value
@classmethod
def is_user_require_email_institutionnel_enabled(cls) -> bool:
"""True si impose saisie email_institutionnel"""
cfg = ScoDocSiteConfig.query.filter_by(
name="user_require_email_institutionnel"
).first()
return cfg is not None and cfg.value
@classmethod
def is_bul_pdf_disabled(cls) -> bool:
"""True si on interdit les exports PDF des bulltins"""
@ -250,36 +259,14 @@ class ScoDocSiteConfig(db.Model):
return cfg is not None and cfg.value
@classmethod
def enable_entreprises(cls, enabled=True) -> bool:
def enable_entreprises(cls, enabled: bool = True) -> bool:
"""Active (ou déactive) le module entreprises. True si changement."""
if enabled != ScoDocSiteConfig.is_entreprises_enabled():
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
if cfg is None:
cfg = ScoDocSiteConfig(
name="enable_entreprises", value="on" if enabled else ""
)
else:
cfg.value = "on" if enabled else ""
db.session.add(cfg)
db.session.commit()
return True
return False
return cls.set("enable_entreprises", "on" if enabled else "")
@classmethod
def disable_bul_pdf(cls, enabled=True) -> bool:
"""Interedit (ou autorise) les exports PDF. True si changement."""
if enabled != ScoDocSiteConfig.is_bul_pdf_disabled():
cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
if cfg is None:
cfg = ScoDocSiteConfig(
name="disable_bul_pdf", value="on" if enabled else ""
)
else:
cfg.value = "on" if enabled else ""
db.session.add(cfg)
db.session.commit()
return True
return False
"""Interdit (ou autorise) les exports PDF. True si changement."""
return cls.set("disable_bul_pdf", "on" if enabled else "")
@classmethod
def get(cls, name: str, default: str = "") -> str:
@ -298,9 +285,10 @@ class ScoDocSiteConfig(db.Model):
if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=value_str)
else:
cfg.value = str(value or "")
cfg.value = value_str
current_app.logger.info(
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}{
'...' if len(cfg.value)>32 else ''}'"""
)
db.session.add(cfg)
db.session.commit()
@ -309,7 +297,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
@ -323,7 +311,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])

View File

@ -19,7 +19,7 @@ from app.models.departements import Departement
from app.models.scolar_event import ScolarEvent
from app.scodoc import notesdb as ndb
from app.scodoc.sco_bac import Baccalaureat
from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
from app.scodoc.sco_exceptions import ScoGenError, ScoInvalidParamError, ScoValueError
import app.scodoc.sco_utils as scu
@ -101,7 +101,12 @@ class Identite(models.ScoDocModel):
adresses = db.relationship(
"Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic"
)
annotations = db.relationship(
"EtudAnnotation",
backref="etudiant",
cascade="all, delete-orphan",
lazy="dynamic",
)
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
#
dispense_ues = db.relationship(
@ -119,6 +124,9 @@ class Identite(models.ScoDocModel):
"Justificatif", back_populates="etudiant", lazy="dynamic", cascade="all, delete"
)
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {"boursier"}
def __repr__(self):
return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
@ -176,7 +184,7 @@ class Identite(models.ScoDocModel):
def url_fiche(self) -> str:
"url de la fiche étudiant"
return url_for(
"scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id
"scolar.fiche_etud", scodoc_dept=self.departement.acronym, etudid=self.id
)
@classmethod
@ -230,6 +238,15 @@ class Identite(models.ScoDocModel):
log(f"Identite.create {etud}")
return etud
def from_dict(self, args, **kwargs) -> bool:
"""Check arguments, then modify.
Add to session but don't commit.
True if modification.
"""
check_etud_duplicate_code(args, "code_nip")
check_etud_duplicate_code(args, "code_ine")
return super().from_dict(args, **kwargs)
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded."""
@ -418,7 +435,7 @@ class Identite(models.ScoDocModel):
return args_dict
def to_dict_short(self) -> dict:
"""Les champs essentiels"""
"""Les champs essentiels (aucune donnée perso protégée)"""
return {
"id": self.id,
"civilite": self.civilite,
@ -433,9 +450,11 @@ class Identite(models.ScoDocModel):
"prenom_etat_civil": self.prenom_etat_civil,
}
def to_dict_scodoc7(self) -> dict:
def to_dict_scodoc7(self, restrict=False, with_inscriptions=False) -> dict:
"""Représentation dictionnaire,
compatible ScoDoc7 mais sans infos admission
compatible ScoDoc7 mais sans infos admission.
Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
Si with_inscriptions, inclut les champs "inscription"
"""
e_dict = self.__dict__.copy() # dict(self.__dict__)
e_dict.pop("_sa_instance_state", None)
@ -446,7 +465,9 @@ class Identite(models.ScoDocModel):
e_dict["nomprenom"] = self.nomprenom
adresse = self.adresses.first()
if adresse:
e_dict.update(adresse.to_dict())
e_dict.update(adresse.to_dict(restrict=restrict))
if with_inscriptions:
e_dict.update(self.inscription_descr())
return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty
def to_dict_bul(self, include_urls=True):
@ -461,9 +482,9 @@ 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("%d/%m/%Y") if self.date_naissance else ""
),
"dept_acronym": self.departement.acronym,
"dept_id": self.dept_id,
"dept_naissance": self.dept_naissance or "",
@ -481,7 +502,7 @@ class Identite(models.ScoDocModel):
if include_urls and has_request_context():
# test request context so we can use this func in tests under the flask shell
d["fiche_url"] = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id
)
d["photo_url"] = sco_photos.get_etud_photo_url(self.id)
adresse = self.adresses.first()
@ -490,16 +511,33 @@ class Identite(models.ScoDocModel):
d["id"] = self.id # a été écrasé par l'id de adresse
return d
def to_dict_api(self) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission."""
def to_dict_api(self, restrict=False, with_annotations=False) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission.
Si restrict, supprime les infos "personnelles" (boursier)
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
admission = self.admission
e["admission"] = admission.to_dict() if admission is not None else None
e["adresses"] = [adr.to_dict() for adr in self.adresses]
e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses]
e["dept_acronym"] = self.departement.acronym
e.pop("departement", None)
e["sort_key"] = self.sort_key
if with_annotations:
e["annotations"] = (
[
annot.to_dict()
for annot in EtudAnnotation.query.filter_by(
etudid=self.id
).order_by(desc(EtudAnnotation.date))
]
if not restrict
else []
)
if restrict:
# Met à None les attributs protégés:
for attr in self.protected_attrs:
e[attr] = None
return e
def inscriptions(self) -> list["FormSemestreInscription"]:
@ -555,7 +593,9 @@ class Identite(models.ScoDocModel):
return r[0] if r else None
def inscription_descr(self) -> dict:
"""Description de l'état d'inscription"""
"""Description de l'état d'inscription
avec champs compatibles templates ScoDoc7
"""
inscription_courante = self.inscription_courante()
if inscription_courante:
titre_sem = inscription_courante.formsemestre.titre_mois()
@ -566,7 +606,7 @@ class Identite(models.ScoDocModel):
else:
inscr_txt = "Inscrit en"
return {
result = {
"etat_in_cursem": inscription_courante.etat,
"inscription_courante": inscription_courante,
"inscription": titre_sem,
@ -589,15 +629,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:
@ -718,6 +763,58 @@ class Identite(models.ScoDocModel):
)
def check_etud_duplicate_code(args, code_name, edit=True):
"""Vérifie que le code n'est pas dupliqué.
Raises ScoGenError si problème.
"""
etudid = args.get("etudid", None)
if not args.get(code_name, None):
return
etuds = Identite.query.filter_by(
**{code_name: str(args[code_name]), "dept_id": g.scodoc_dept_id}
).all()
duplicate = False
if edit:
duplicate = (len(etuds) > 1) or ((len(etuds) == 1) and etuds[0].id != etudid)
else:
duplicate = len(etuds) > 0
if duplicate:
listh = [] # liste des doubles
for etud in etuds:
listh.append(f"Autre étudiant: {etud.html_link_fiche()}")
if etudid:
submit_label = "retour à la fiche étudiant"
dest_endpoint = "scolar.fiche_etud"
parameters = {"etudid": etudid}
else:
if "tf_submitted" in args:
del args["tf_submitted"]
submit_label = "Continuer"
dest_endpoint = "scolar.etudident_create_form"
parameters = args
else:
submit_label = "Annuler"
dest_endpoint = "notes.index_html"
parameters = {}
err_page = f"""<h3><h3>Code étudiant ({code_name}) dupliqué !</h3>
<p class="help">Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir
ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur.
</p>
<ul><li>
{ '</li><li>'.join(listh) }
</li></ul>
<p>
<a href="{ url_for(dest_endpoint, scodoc_dept=g.scodoc_dept, **parameters) }
">{submit_label}</a>
</p>
"""
log(f"*** error: code {code_name} duplique: {args[code_name]}")
raise ScoGenError(err_page)
def make_etud_args(
etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True
) -> dict:
@ -825,12 +922,25 @@ class Adresse(models.ScoDocModel):
)
description = db.Column(db.Text)
def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,"""
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {
"emailperso",
"domicile",
"codepostaldomicile",
"villedomicile",
"telephone",
"telephonemobile",
"fax",
}
def to_dict(self, convert_nulls_to_str=False, restrict=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if convert_nulls_to_str:
return {k: e[k] or "" for k in e}
e = {k: v or "" for k, v in e.items()}
if restrict:
e = {k: v for (k, v) in e.items() if k not in self.protected_attrs}
return e
@ -885,12 +995,16 @@ class Admission(models.ScoDocModel):
# classement (1..Ngr) par le jury dans le groupe APB
apb_classement_gr = db.Column(db.Integer)
# Tous les champs sont "protégés" par ViewEtudData (RGPD)
# sauf:
not_protected_attrs = {"bac", "specialite", "anne_bac"}
def get_bac(self) -> Baccalaureat:
"Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères."
return Baccalaureat(self.bac, specialite=self.specialite)
def to_dict(self, no_nulls=False):
"""Représentation dictionnaire,"""
def to_dict(self, no_nulls=False, restrict=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
if no_nulls:
@ -905,6 +1019,8 @@ class Admission(models.ScoDocModel):
d[key] = 0
elif isinstance(col_type, sqlalchemy.Boolean):
d[key] = False
if restrict:
d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs}
return d
@classmethod
@ -972,10 +1088,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

View File

@ -5,7 +5,7 @@
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
@ -184,7 +184,7 @@ class Evaluation(db.Model):
# ScoDoc7 output_formators
e_dict["evaluation_id"] = self.id
e_dict["date_debut"] = self.date_debut.isoformat() if self.date_debut else None
e_dict["date_fin"] = self.date_debut.isoformat() if self.date_fin else None
e_dict["date_fin"] = self.date_fin.isoformat() if self.date_fin else None
e_dict["numero"] = self.numero or 0
e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
@ -241,6 +241,25 @@ class Evaluation(db.Model):
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:
"""Return max numero among evaluations in this
@ -265,7 +284,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
@ -428,8 +449,8 @@ class Evaluation(db.Model):
def get_ue_poids_str(self) -> str:
"""string describing poids, for excel cells and pdfs
Note: si les poids ne sont pas initialisés (poids par défaut),
ils ne sont pas affichés.
Note: les poids nuls ou non initialisés (poids par défaut),
ne sont pas affichés.
"""
# restreint aux UE du semestre dans lequel est cette évaluation
# au cas où le module ait changé de semestre et qu'il reste des poids
@ -440,7 +461,7 @@ class Evaluation(db.Model):
for p in sorted(
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme)
)
if evaluation_semestre_idx == p.ue.semestre_idx
if evaluation_semestre_idx == p.ue.semestre_idx and (p.poids or 0) > 0
]
)
@ -584,20 +605,10 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
if date_debut and date_fin:
duration = data["date_fin"] - data["date_debut"]
if duration.total_seconds() < 0 or duration > MAX_EVALUATION_DURATION:
raise ScoValueError("Heures de l'évaluation incohérentes !")
# # --- heures
# heure_debut = data.get("heure_debut", None)
# if heure_debut and not isinstance(heure_debut, datetime.time):
# if date_format == "dmy":
# data["heure_debut"] = heure_to_time(heure_debut)
# else: # ISO
# data["heure_debut"] = datetime.time.fromisoformat(heure_debut)
# heure_fin = data.get("heure_fin", None)
# if heure_fin and not isinstance(heure_fin, datetime.time):
# if date_format == "dmy":
# data["heure_fin"] = heure_to_time(heure_fin)
# else: # ISO
# data["heure_fin"] = datetime.time.fromisoformat(heure_fin)
raise ScoValueError(
"Heures de l'évaluation incohérentes !",
dest_url="javascript:history.back();",
)
def heure_to_time(heure: str) -> datetime.time:

View File

@ -68,7 +68,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")
@ -187,7 +187,7 @@ 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"""
if not isinstance(formsemestre_id, int):
try:
formsemestre_id = int(formsemestre_id)
@ -673,7 +673,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,

View File

@ -2,12 +2,14 @@
"""ScoDoc models: moduleimpls
"""
import pandas as pd
from flask import abort, g
from flask_login import current_user
from flask_sqlalchemy.query import Query
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
@ -16,7 +18,7 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
class ModuleImpl(db.Model):
class ModuleImpl(ScoDocModel):
"""Mise en oeuvre d'un module pour une annee/semestre"""
__tablename__ = "notes_moduleimpl"
@ -35,7 +37,10 @@ class ModuleImpl(db.Model):
index=True,
nullable=False,
)
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
responsable_id = db.Column(
"responsable_id", db.Integer, db.ForeignKey("user.id", ondelete="SET NULL")
)
responsable = db.relationship("User", back_populates="modimpls")
# formule de calcul moyenne:
computation_expr = db.Column(db.Text())
@ -51,8 +56,8 @@ class ModuleImpl(db.Model):
secondary="notes_modules_enseignants",
lazy="dynamic",
backref="moduleimpl",
viewonly=True,
)
"enseignants du module (sans le responsable)"
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} module={repr(self.module)}>"
@ -82,6 +87,23 @@ class ModuleImpl(db.Model):
df_cache.EvaluationsPoidsCache.set(self.id, evaluations_poids)
return evaluations_poids
@classmethod
def get_modimpl(cls, moduleimpl_id: int | str, dept_id: int = None) -> "ModuleImpl":
"""ModuleImpl ou 404, cherche uniquement dans le département spécifié ou le courant."""
from app.models.formsemestre import FormSemestre
if not isinstance(moduleimpl_id, int):
try:
moduleimpl_id = int(moduleimpl_id)
except (TypeError, ValueError):
abort(404, "moduleimpl_id invalide")
if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
query = cls.query.filter_by(id=moduleimpl_id)
if dept_id is not None:
query = query.join(FormSemestre).filter_by(dept_id=dept_id)
return query.first_or_404()
def invalidate_evaluations_poids(self):
"""Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id)
@ -169,7 +191,7 @@ class ModuleImpl(db.Model):
return allow_ens and user.id in (ens.id for ens in self.enseignants)
return True
def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
def can_change_responsable(self, user: User, raise_exc=False) -> bool:
"""Check if user can modify module resp.
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
= Admin, et dir des etud. (si option l'y autorise)
@ -190,6 +212,27 @@ class ModuleImpl(db.Model):
raise AccessDenied(f"Modification impossible pour {user}")
return False
def can_change_ens(self, user: User | None = None, raise_exc=True) -> bool:
"""check if user can modify ens list (raise exception if not)"
if user is None, current user.
"""
user = current_user if user is None else user
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
# -- check access
# admin, resp. module ou resp. semestre
if (
user.id != self.responsable_id
and not user.has_permission(Permission.EditFormSemestre)
and user.id not in (u.id for u in self.formsemestre.responsables)
):
if raise_exc:
raise AccessDenied(f"Modification impossible pour {user}")
return False
return True
def est_inscrit(self, etud: Identite) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl (même si DEM ou DEF au semestre).

View File

@ -1,6 +1,6 @@
"""ScoDoc 9 models : Modules
"""
from flask import current_app
from flask import current_app, g
from app import db
from app.models import APO_CODE_STR_LEN
@ -310,6 +310,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 +380,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(

View File

@ -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

44
app/pe/pe_affichage.py Normal file
View File

@ -0,0 +1,44 @@
##############################################################################
# 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
PE_DEBUG = False
# On stocke les logs PE dans g.scodoc_pe_log
# pour ne pas modifier les nombreux appels à pe_print.
def pe_start_log() -> list[str]:
"Initialize log"
g.scodoc_pe_log = []
return g.scodoc_pe_log
def pe_print(*a):
"Log (or print in PE_DEBUG mode) and store in g"
lines = getattr(g, "scodoc_pe_log")
if lines is None:
lines = pe_start_log()
msg = " ".join(a)
lines.append(msg)
if PE_DEBUG:
print(msg)
else:
log(msg)
def pe_get_log() -> str:
"Renvoie une chaîne avec tous les messages loggués"
return "\n".join(getattr(g, "scodoc_pe_log", []))
# Affichage dans le tableur pe en cas d'absence de notes
SANS_NOTE = "-"
NOM_STAT_GROUPE = "statistiques du groupe"
NOM_STAT_PROMO = "statistiques de la promo"

View File

@ -1,517 +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)
##############################################################################
import os
import codecs
import re
from app.pe import pe_tagtable
from app.pe import pe_jurype
from app.pe import pe_tools
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app import log
from app.scodoc.gen_tables import GenTable, SeqGenTable
from app.scodoc import sco_preferences
from app.scodoc import sco_etud
DEBUG = False # Pour debug et repérage des prints à changer en Log
DONNEE_MANQUANTE = (
"" # Caractère de remplacement des données manquantes dans un avis PE
)
# ----------------------------------------------------------------------------------------
def get_code_latex_from_modele(fichier):
"""Lit le code latex à partir d'un modèle. Renvoie une chaine unicode.
Le fichier doit contenir le chemin relatif
vers le modele : attention pas de vérification du format d'encodage
Le fichier doit donc etre enregistré avec le même codage que ScoDoc (utf-8)
"""
fid_latex = codecs.open(fichier, "r", encoding=scu.SCO_ENCODING)
un_avis_latex = fid_latex.read()
fid_latex.close()
return un_avis_latex
# ----------------------------------------------------------------------------------------
def get_code_latex_from_scodoc_preference(formsemestre_id, champ="pe_avis_latex_tmpl"):
"""
Extrait le template (ou le tag d'annotation au regard du champ fourni) des préférences LaTeX
et s'assure qu'il est renvoyé au format unicode
"""
template_latex = sco_preferences.get_preference(champ, formsemestre_id)
return template_latex or ""
# ----------------------------------------------------------------------------------------
def get_tags_latex(code_latex):
"""Recherche tous les tags présents dans un code latex (ce code étant obtenu
à la lecture d'un modèle d'avis pe).
Ces tags sont répérés par les balises **, débutant et finissant le tag
et sont renvoyés sous la forme d'une liste.
result: liste de chaines unicode
"""
if code_latex:
# changé par EV: était r"([\*]{2}[a-zA-Z0-9:éèàâêëïôöù]+[\*]{2})"
res = re.findall(r"([\*]{2}[^\t\n\r\f\v\*]+[\*]{2})", code_latex)
return [tag[2:-2] for tag in res]
else:
return []
def comp_latex_parcourstimeline(etudiant, promo, taille=17):
"""Interprète un tag dans un avis latex **parcourstimeline**
et génère le code latex permettant de retracer le parcours d'un étudiant
sous la forme d'une frise temporelle.
Nota: modeles/parcourstimeline.tex doit avoir été inclu dans le préambule
result: chaine unicode (EV:)
"""
codelatexDebut = (
""""
\\begin{parcourstimeline}{**debut**}{**fin**}{**nbreSemestres**}{%d}
"""
% taille
)
modeleEvent = """
\\parcoursevent{**nosem**}{**nomsem**}{**descr**}
"""
codelatexFin = """
\\end{parcourstimeline}
"""
reslatex = codelatexDebut
reslatex = reslatex.replace("**debut**", etudiant["entree"])
reslatex = reslatex.replace("**fin**", str(etudiant["promo"]))
reslatex = reslatex.replace("**nbreSemestres**", str(etudiant["nbSemestres"]))
# Tri du parcours par ordre croissant : de la forme descr, nom sem date-date
parcours = etudiant["parcours"][::-1] # EV: XXX je ne comprend pas ce commentaire ?
for no_sem in range(etudiant["nbSemestres"]):
descr = modeleEvent
nom_semestre_dans_parcours = parcours[no_sem]["nom_semestre_dans_parcours"]
descr = descr.replace("**nosem**", str(no_sem + 1))
if no_sem % 2 == 0:
descr = descr.replace("**nomsem**", nom_semestre_dans_parcours)
descr = descr.replace("**descr**", "")
else:
descr = descr.replace("**nomsem**", "")
descr = descr.replace("**descr**", nom_semestre_dans_parcours)
reslatex += descr
reslatex += codelatexFin
return reslatex
# ----------------------------------------------------------------------------------------
def interprete_tag_latex(tag):
"""Découpe les tags latex de la forme S1:groupe:dut:min et renvoie si possible
le résultat sous la forme d'un quadruplet.
"""
infotag = tag.split(":")
if len(infotag) == 4:
return (
infotag[0].upper(),
infotag[1].lower(),
infotag[2].lower(),
infotag[3].lower(),
)
else:
return (None, None, None, None)
# ----------------------------------------------------------------------------------------
def get_code_latex_avis_etudiant(
donnees_etudiant, un_avis_latex, annotationPE, footer_latex, prefs
):
"""
Renvoie le code latex permettant de générer l'avis d'un étudiant en utilisant ses
donnees_etudiant contenu dans le dictionnaire de synthèse du jury PE et en suivant un
fichier modele donné
result: chaine unicode
"""
if not donnees_etudiant or not un_avis_latex: # Cas d'un template vide
return annotationPE if annotationPE else ""
# Le template latex (corps + footer)
code = un_avis_latex + "\n\n" + footer_latex
# Recherche des tags dans le fichier
tags_latex = get_tags_latex(code)
if DEBUG:
log("Les tags" + str(tags_latex))
# Interprète et remplace chaque tags latex par les données numériques de l'étudiant (y compris les
# tags "macros" tels que parcourstimeline
for tag_latex in tags_latex:
# les tags numériques
valeur = DONNEE_MANQUANTE
if ":" in tag_latex:
(aggregat, groupe, tag_scodoc, champ) = interprete_tag_latex(tag_latex)
valeur = str_from_syntheseJury(
donnees_etudiant, aggregat, groupe, tag_scodoc, champ
)
# La macro parcourstimeline
elif tag_latex == "parcourstimeline":
valeur = comp_latex_parcourstimeline(
donnees_etudiant, donnees_etudiant["promo"]
)
# Le tag annotationPE
elif tag_latex == "annotation":
valeur = annotationPE
# Le tag bilanParTag
elif tag_latex == "bilanParTag":
valeur = get_bilanParTag(donnees_etudiant)
# Les tags "simples": par ex. nom, prenom, civilite, ...
else:
if tag_latex in donnees_etudiant:
valeur = donnees_etudiant[tag_latex]
elif tag_latex in prefs: # les champs **NomResponsablePE**, ...
valeur = pe_tools.escape_for_latex(prefs[tag_latex])
# Vérification des pb d'encodage (debug)
# assert isinstance(tag_latex, unicode)
# assert isinstance(valeur, unicode)
# Substitution
code = code.replace("**" + tag_latex + "**", valeur)
return code
# ----------------------------------------------------------------------------------------
def get_annotation_PE(etudid, tag_annotation_pe):
"""Renvoie l'annotation PE dans la liste de ces annotations ;
Cette annotation est reconnue par la présence d'un tag **PE**
(cf. .get_preferences -> pe_tag_annotation_avis_latex).
Result: chaine unicode
"""
if tag_annotation_pe:
cnx = ndb.GetDBConnexion()
annotations = sco_etud.etud_annotations_list(
cnx, args={"etudid": etudid}
) # Les annotations de l'étudiant
annotationsPE = []
exp = re.compile(r"^" + tag_annotation_pe)
for a in annotations:
commentaire = scu.unescape_html(a["comment"])
if exp.match(commentaire): # tag en début de commentaire ?
a["comment_u"] = commentaire # unicode, HTML non quoté
annotationsPE.append(
a
) # sauvegarde l'annotation si elle contient le tag
if annotationsPE: # Si des annotations existent, prend la plus récente
annotationPE = sorted(annotationsPE, key=lambda a: a["date"], reverse=True)[
0
]["comment_u"]
annotationPE = exp.sub(
"", annotationPE
) # Suppression du tag d'annotation PE
annotationPE = annotationPE.replace("\r", "") # Suppression des \r
annotationPE = annotationPE.replace(
"<br>", "\n\n"
) # Interprète les retours chariots html
return annotationPE
return "" # pas d'annotations
# ----------------------------------------------------------------------------------------
def str_from_syntheseJury(donnees_etudiant, aggregat, groupe, tag_scodoc, champ):
"""Extrait du dictionnaire de synthèse du juryPE pour un étudiant donnée,
une valeur indiquée par un champ ;
si champ est une liste, renvoie la liste des valeurs extraites.
Result: chaine unicode ou liste de chaines unicode
"""
if isinstance(champ, list):
return [
str_from_syntheseJury(donnees_etudiant, aggregat, groupe, tag_scodoc, chp)
for chp in champ
]
else: # champ = str à priori
valeur = DONNEE_MANQUANTE
if (
(aggregat in donnees_etudiant)
and (groupe in donnees_etudiant[aggregat])
and (tag_scodoc in donnees_etudiant[aggregat][groupe])
):
donnees_numeriques = donnees_etudiant[aggregat][groupe][tag_scodoc]
if champ == "rang":
valeur = "%s/%d" % (
donnees_numeriques[
pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index("rang")
],
donnees_numeriques[
pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index(
"nbinscrits"
)
],
)
elif champ in pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS:
indice_champ = pe_tagtable.TableTag.FORMAT_DONNEES_ETUDIANTS.index(
champ
)
if (
len(donnees_numeriques) > indice_champ
and donnees_numeriques[indice_champ] != None
):
if isinstance(
donnees_numeriques[indice_champ], float
): # valeur numérique avec formattage unicode
valeur = "%2.2f" % donnees_numeriques[indice_champ]
else:
valeur = "%s" % donnees_numeriques[indice_champ]
return valeur
# ----------------------------------------------------------------------------------------
def get_bilanParTag(donnees_etudiant, groupe="groupe"):
"""Renvoie le code latex d'un tableau récapitulant, pour tous les tags trouvés dans
les données étudiants, ses résultats.
result: chaine unicode
"""
entete = [
(
agg,
pe_jurype.JuryPE.PARCOURS[agg]["affichage_court"],
pe_jurype.JuryPE.PARCOURS[agg]["ordre"],
)
for agg in pe_jurype.JuryPE.PARCOURS
]
entete = sorted(entete, key=lambda t: t[2])
lignes = []
valeurs = {"note": [], "rang": []}
for indice_aggregat, (aggregat, intitule, _) in enumerate(entete):
# print("> " + aggregat)
# listeTags = jury.get_allTagForAggregat(aggregat) # les tags de l'aggrégat
listeTags = [
tag for tag in donnees_etudiant[aggregat][groupe].keys() if tag != "dut"
] #
for tag in listeTags:
if tag not in lignes:
lignes.append(tag)
valeurs["note"].append(
[""] * len(entete)
) # Ajout d'une ligne de données
valeurs["rang"].append(
[""] * len(entete)
) # Ajout d'une ligne de données
indice_tag = lignes.index(tag) # l'indice de ligne du tag
# print(" --- " + tag + "(" + str(indice_tag) + "," + str(indice_aggregat) + ")")
[note, rang] = str_from_syntheseJury(
donnees_etudiant, aggregat, groupe, tag, ["note", "rang"]
)
valeurs["note"][indice_tag][indice_aggregat] = "" + note + ""
valeurs["rang"][indice_tag][indice_aggregat] = (
("\\textit{" + rang + "}") if note else ""
) # rang masqué si pas de notes
code_latex = "\\begin{tabular}{|c|" + "|c" * (len(entete)) + "|}\n"
code_latex += "\\hline \n"
code_latex += (
" & "
+ " & ".join(["\\textbf{" + intitule + "}" for (agg, intitule, _) in entete])
+ " \\\\ \n"
)
code_latex += "\\hline"
code_latex += "\\hline \n"
for i, ligne_val in enumerate(valeurs["note"]):
titre = lignes[i] # règle le pb d'encodage
code_latex += "\\textbf{" + titre + "} & " + " & ".join(ligne_val) + "\\\\ \n"
code_latex += (
" & "
+ " & ".join(
["{\\scriptsize " + clsmt + "}" for clsmt in valeurs["rang"][i]]
)
+ "\\\\ \n"
)
code_latex += "\\hline \n"
code_latex += "\\end{tabular}"
return code_latex
# ----------------------------------------------------------------------------------------
def get_avis_poursuite_par_etudiant(
jury, etudid, template_latex, tag_annotation_pe, footer_latex, prefs
):
"""Renvoie un nom de fichier et le contenu de l'avis latex d'un étudiant dont l'etudid est fourni.
result: [ chaine unicode, chaine unicode ]
"""
if pe_tools.PE_DEBUG:
pe_tools.pe_print(jury.syntheseJury[etudid]["nom"] + " " + str(etudid))
civilite_str = jury.syntheseJury[etudid]["civilite_str"]
nom = jury.syntheseJury[etudid]["nom"].replace(" ", "-")
prenom = jury.syntheseJury[etudid]["prenom"].replace(" ", "-")
nom_fichier = scu.sanitize_filename(
"avis_poursuite_%s_%s_%s" % (nom, prenom, etudid)
)
if pe_tools.PE_DEBUG:
pe_tools.pe_print("fichier latex =" + nom_fichier, type(nom_fichier))
# Entete (commentaire)
contenu_latex = (
"%% ---- Etudiant: " + civilite_str + " " + nom + " " + prenom + "\n"
)
# les annnotations
annotationPE = get_annotation_PE(etudid, tag_annotation_pe=tag_annotation_pe)
if pe_tools.PE_DEBUG:
pe_tools.pe_print(annotationPE, type(annotationPE))
# le LaTeX
avis = get_code_latex_avis_etudiant(
jury.syntheseJury[etudid], template_latex, annotationPE, footer_latex, prefs
)
# if pe_tools.PE_DEBUG: pe_tools.pe_print(avis, type(avis))
contenu_latex += avis + "\n"
return [nom_fichier, contenu_latex]
def get_templates_from_distrib(template="avis"):
"""Récupère le template (soit un_avis.tex soit le footer.tex) à partir des fichiers mémorisés dans la distrib des avis pe (distrib local
ou par défaut et le renvoie"""
if template == "avis":
pe_local_tmpl = pe_tools.PE_LOCAL_AVIS_LATEX_TMPL
pe_default_tmpl = pe_tools.PE_DEFAULT_AVIS_LATEX_TMPL
elif template == "footer":
pe_local_tmpl = pe_tools.PE_LOCAL_FOOTER_TMPL
pe_default_tmpl = pe_tools.PE_DEFAULT_FOOTER_TMPL
if template in ["avis", "footer"]:
# pas de preference pour le template: utilise fichier du serveur
if os.path.exists(pe_local_tmpl):
template_latex = get_code_latex_from_modele(pe_local_tmpl)
else:
if os.path.exists(pe_default_tmpl):
template_latex = get_code_latex_from_modele(pe_default_tmpl)
else:
template_latex = "" # fallback: avis vides
return template_latex
# ----------------------------------------------------------------------------------------
def table_syntheseAnnotationPE(syntheseJury, tag_annotation_pe):
"""Génère un fichier excel synthétisant les annotations PE telles qu'inscrites dans les fiches de chaque étudiant"""
sT = SeqGenTable() # le fichier excel à générer
# Les etudids des étudiants à afficher, triés par ordre alphabétiques de nom+prénom
donnees_tries = sorted(
[
(etudid, syntheseJury[etudid]["nom"] + " " + syntheseJury[etudid]["prenom"])
for etudid in syntheseJury.keys()
],
key=lambda c: c[1],
)
etudids = [e[0] for e in donnees_tries]
if not etudids: # Si pas d'étudiants
T = GenTable(
columns_ids=["pas d'étudiants"],
rows=[],
titles={"pas d'étudiants": "pas d'étudiants"},
html_sortable=True,
xls_sheet_name="dut",
)
sT.add_genTable("Annotation PE", T)
return sT
# Si des étudiants
maxParcours = max(
[syntheseJury[etudid]["nbSemestres"] for etudid in etudids]
) # le nombre de semestre le + grand
infos = ["civilite", "nom", "prenom", "age", "nbSemestres"]
entete = ["etudid"]
entete.extend(infos)
entete.extend(["P%d" % i for i in range(1, maxParcours + 1)]) # ajout du parcours
entete.append("Annotation PE")
columns_ids = entete # les id et les titres de colonnes sont ici identiques
titles = {i: i for i in columns_ids}
rows = []
for (
etudid
) in etudids: # parcours des étudiants par ordre alphabétique des nom+prénom
e = syntheseJury[etudid]
# Les info générales:
row = {
"etudid": etudid,
"civilite": e["civilite"],
"nom": e["nom"],
"prenom": e["prenom"],
"age": e["age"],
"nbSemestres": e["nbSemestres"],
}
# Les parcours: P1, P2, ...
n = 1
for p in e["parcours"]:
row["P%d" % n] = p["titreannee"]
n += 1
# L'annotation PE
annotationPE = get_annotation_PE(etudid, tag_annotation_pe=tag_annotation_pe)
row["Annotation PE"] = annotationPE if annotationPE else ""
rows.append(row)
T = GenTable(
columns_ids=columns_ids,
rows=rows,
titles=titles,
html_sortable=True,
xls_sheet_name="Annotation PE",
)
sT.add_genTable("Annotation PE", T)
return sT

286
app/pe/pe_comp.py Normal file
View File

@ -0,0 +1,286 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Thu Sep 8 09:36:33 2016
@author: barasc
"""
import os
import datetime
import re
import unicodedata
from flask import g
import app.scodoc.sco_utils as scu
from app.models import FormSemestre
from app.pe.pe_rcs import TYPES_RCS
from app.scodoc import sco_formsemestre
from app.scodoc.sco_logos import find_logo
# Generated LaTeX files are encoded as:
PE_LATEX_ENCODING = "utf-8"
# /opt/scodoc/tools/doc_poursuites_etudes
REP_DEFAULT_AVIS = os.path.join(scu.SCO_TOOLS_DIR, "doc_poursuites_etudes/")
REP_LOCAL_AVIS = os.path.join(scu.SCODOC_CFG_DIR, "doc_poursuites_etudes/")
PE_DEFAULT_AVIS_LATEX_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_avis.tex"
PE_LOCAL_AVIS_LATEX_TMPL = REP_LOCAL_AVIS + "local/modeles/un_avis.tex"
PE_DEFAULT_FOOTER_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_footer.tex"
PE_LOCAL_FOOTER_TMPL = REP_LOCAL_AVIS + "local/modeles/un_footer.tex"
# ----------------------------------------------------------------------------------------
"""
Descriptif d'un parcours classique BUT
TODO:: A améliorer si BUT en moins de 6 semestres
"""
NBRE_SEMESTRES_DIPLOMANT = 6
AGGREGAT_DIPLOMANT = (
"6S" # aggrégat correspondant à la totalité des notes pour le diplôme
)
TOUS_LES_SEMESTRES = TYPES_RCS[AGGREGAT_DIPLOMANT]["aggregat"]
# ----------------------------------------------------------------------------------------
def calcul_age(born: datetime.date) -> int:
"""Calcule l'age connaissant la date de naissance ``born``. (L'age est calculé
à partir de l'horloge système).
Args:
born: La date de naissance
Return:
L'age (au regard de la date actuelle)
"""
if not born or not isinstance(born, datetime.date):
return None
today = datetime.date.today()
return today.year - born.year - ((today.month, today.day) < (born.month, born.day))
# Nota: scu.suppress_accents fait la même chose mais renvoie un str et non un bytes
def remove_accents(input_unicode_str: str) -> bytes:
"""Supprime les accents d'une chaine unicode"""
nfkd_form = unicodedata.normalize("NFKD", input_unicode_str)
only_ascii = nfkd_form.encode("ASCII", "ignore")
return only_ascii
def escape_for_latex(s):
"""Protège les caractères pour inclusion dans du source LaTeX"""
if not s:
return ""
conv = {
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\^{}",
"\\": r"\textbackslash{}",
"<": r"\textless ",
">": r"\textgreater ",
}
exp = re.compile(
"|".join(
re.escape(key)
for key in sorted(list(conv.keys()), key=lambda item: -len(item))
)
)
return exp.sub(lambda match: conv[match.group()], s)
# ----------------------------------------------------------------------------------------
def list_directory_filenames(path: str) -> list[str]:
"""List of regular filenames (paths) in a directory (recursive)
Excludes files and directories begining with .
"""
paths = []
for root, dirs, files in os.walk(path, topdown=True):
dirs[:] = [d for d in dirs if d[0] != "."]
paths += [os.path.join(root, fn) for fn in files if fn[0] != "."]
return paths
def add_local_file_to_zip(zipfile, ziproot, pathname, path_in_zip):
"""Read pathname server file and add content to zip under path_in_zip"""
rooted_path_in_zip = os.path.join(ziproot, path_in_zip)
zipfile.write(filename=pathname, arcname=rooted_path_in_zip)
# data = open(pathname).read()
# zipfile.writestr(rooted_path_in_zip, data)
def add_refs_to_register(register, directory):
"""Ajoute les fichiers trouvés dans directory au registre (dictionaire) sous la forme
filename => pathname
"""
length = len(directory)
for pathname in list_directory_filenames(directory):
filename = pathname[length + 1 :]
register[filename] = pathname
def add_pe_stuff_to_zip(zipfile, ziproot):
"""Add auxiliary files to (already opened) zip
Put all local files found under config/doc_poursuites_etudes/local
and config/doc_poursuites_etudes/distrib
If a file is present in both subtrees, take the one in local.
Also copy logos
"""
register = {}
# first add standard (distrib references)
distrib_dir = os.path.join(REP_DEFAULT_AVIS, "distrib")
add_refs_to_register(register=register, directory=distrib_dir)
# then add local references (some oh them may overwrite distrib refs)
local_dir = os.path.join(REP_LOCAL_AVIS, "local")
add_refs_to_register(register=register, directory=local_dir)
# at this point register contains all refs (filename, pathname) to be saved
for filename, pathname in register.items():
add_local_file_to_zip(zipfile, ziproot, pathname, "avis/" + filename)
# Logos: (add to logos/ directory in zip)
logos_names = ["header", "footer"]
for name in logos_names:
logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id)
if logo is not None:
add_local_file_to_zip(
zipfile, ziproot, logo.filepath, "avis/logos/" + logo.filename
)
# ----------------------------------------------------------------------------------------
def get_annee_diplome_semestre(
sem_base: FormSemestre | dict, nbre_sem_formation: int = 6
) -> int:
"""Pour un semestre ``sem_base`` donné (supposé être un semestre d'une formation BUT
à 6 semestres) et connaissant le numéro du semestre, ses dates de début et de fin du
semestre, prédit l'année à laquelle sera remis le diplôme BUT des étudiants qui y
sont scolarisés (en supposant qu'il n'y ait pas de redoublement à venir).
**Remarque sur le calcul** : Les semestres de 1ère partie d'année (S1, S3, S5 ou S4,
S6 pour des semestres décalés)
s'étalent sur deux années civiles ; contrairement au semestre de seconde partie
d'année universitaire.
Par exemple :
* S5 débutant en 2025 finissant en 2026 : diplome en 2026
* S3 debutant en 2025 et finissant en 2026 : diplome en 2027
La fonction est adaptée au cas des semestres décalés.
Par exemple :
* S5 décalé débutant en 2025 et finissant en 2025 : diplome en 2026
* S3 décalé débutant en 2025 et finissant en 2025 : diplome en 2027
Args:
sem_base: Le semestre à partir duquel est prédit l'année de diplomation, soit :
* un ``FormSemestre`` (Scodoc9)
* un dict (format compatible avec Scodoc7)
nbre_sem_formation: Le nombre de semestre prévu dans la formation (par défaut 6 pour un BUT)
"""
if isinstance(sem_base, FormSemestre):
sem_id = sem_base.semestre_id
annee_fin = sem_base.date_fin.year
annee_debut = sem_base.date_debut.year
else: # sem_base est un dictionnaire (Scodoc 7)
sem_id = sem_base["semestre_id"]
annee_fin = int(sem_base["annee_fin"])
annee_debut = int(sem_base["annee_debut"])
if (
1 <= sem_id <= nbre_sem_formation
): # Si le semestre est un semestre BUT => problème si formation BUT en 1 an ??
nb_sem_restants = (
nbre_sem_formation - sem_id
) # nombre de semestres restant avant diplome
nb_annees_restantes = (
nb_sem_restants // 2
) # nombre d'annees restant avant diplome
# Flag permettant d'activer ou désactiver un increment
# à prendre en compte en cas de semestre décalé
# avec 1 - delta = 0 si semestre de 1ere partie d'année / 1 sinon
delta = annee_fin - annee_debut
decalage = nb_sem_restants % 2 # 0 si S4, 1 si S3, 0 si S2, 1 si S1
increment = decalage * (1 - delta)
return annee_fin + nb_annees_restantes + increment
def get_cosemestres_diplomants(annee_diplome: int) -> dict[int, FormSemestre]:
"""Ensemble des cosemestres donnant lieu à diplomation à l'``annee_diplome``.
**Définition** : Un co-semestre est un semestre :
* dont l'année de diplômation prédite (sans redoublement) est la même
* dont la formation est la même (optionnel)
* qui a des étudiants inscrits
Args:
annee_diplome: L'année de diplomation
Returns:
Un dictionnaire {fid: FormSemestre(fid)} contenant les cosemestres
"""
tous_les_sems = (
sco_formsemestre.do_formsemestre_list()
) # tous les semestres memorisés dans scodoc
cosemestres_fids = {
sem["id"]
for sem in tous_les_sems
if get_annee_diplome_semestre(sem) == annee_diplome
}
cosemestres = {}
for fid in cosemestres_fids:
cosem = FormSemestre.get_formsemestre(fid)
if len(cosem.etuds_inscriptions) > 0:
cosemestres[fid] = cosem
return cosemestres

618
app/pe/pe_etudiant.py Normal file
View File

@ -0,0 +1,618 @@
# -*- 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 17/01/2024
@author: barasc
"""
import pandas as pd
from app.models import FormSemestre, Identite, Formation
from app.pe import pe_comp, pe_affichage
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
from app.comp.res_sem import load_formsemestre_results
class EtudiantsJuryPE:
"""Classe centralisant la gestion des étudiants à prendre en compte dans un jury de PE"""
def __init__(self, annee_diplome: int):
"""
Args:
annee_diplome: L'année de diplomation
"""
self.annee_diplome = annee_diplome
"""L'année du diplôme"""
self.identites: dict[int, Identite] = {} # ex. ETUDINFO_DICT
"Les identités des étudiants traités pour le jury"
self.cursus: dict[int, dict] = {}
"Les cursus (semestres suivis, abandons) des étudiants"
self.trajectoires = {}
"""Les trajectoires/chemins de semestres suivis par les étudiants
pour atteindre un aggrégat donné
(par ex: 3S=S1+S2+S3 à prendre en compte avec d'éventuels redoublements)"""
self.etudiants_diplomes = {}
"""Les identités des étudiants à considérer au jury (ceux qui seront effectivement
diplômés)"""
self.diplomes_ids = {}
"""Les etudids des étudiants diplômés"""
self.etudiants_ids = {}
"""Les etudids des étudiants dont il faut calculer les moyennes/classements
(même si d'éventuels abandons).
Il s'agit des étudiants inscrits dans les co-semestres (ceux du jury mais aussi
d'autres ayant été réorientés ou ayant abandonnés)"""
self.cosemestres: dict[int, FormSemestre] = None
"Les cosemestres donnant lieu à même année de diplome"
self.abandons = {}
"""Les étudiants qui ne seront pas diplômés à ce jury (redoublants/réorientés)"""
self.abandons_ids = {}
"""Les etudids des étudiants redoublants/réorientés"""
def find_etudiants(self):
"""Liste des étudiants à prendre en compte dans le jury PE, en les recherchant
de manière automatique par rapport à leur année de diplomation ``annee_diplome``.
Les données obtenues sont stockées dans les attributs de EtudiantsJuryPE.
*Remarque* : ex: JuryPE.get_etudiants_in_jury()
"""
cosemestres = pe_comp.get_cosemestres_diplomants(self.annee_diplome)
self.cosemestres = cosemestres
pe_affichage.pe_print(
f"1) Recherche des coSemestres -> {len(cosemestres)} trouvés"
)
pe_affichage.pe_print("2) Liste des étudiants dans les différents co-semestres")
self.etudiants_ids = get_etudiants_dans_semestres(cosemestres)
pe_affichage.pe_print(
f" => {len(self.etudiants_ids)} étudiants trouvés dans les cosemestres"
)
# 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")
for etudid in self.etudiants_ids:
self.identites[etudid] = Identite.get_etud(etudid)
# Analyse son cursus
self.analyse_etat_etudiant(etudid, cosemestres)
# Analyse son parcours pour atteindre chaque semestre de la formation
self.structure_cursus_etudiant(etudid)
# Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris
self.etudiants_diplomes = self.get_etudiants_diplomes()
self.diplomes_ids = set(self.etudiants_diplomes.keys())
self.etudiants_ids = set(self.identites.keys())
# Les abandons (pour debug)
self.abandons = self.get_etudiants_redoublants_ou_reorientes()
# Les identités des étudiants ayant redoublés ou ayant abandonnés
self.abandons_ids = set(self.abandons)
# Les identifiants des étudiants ayant redoublés ou ayant abandonnés
# Synthèse
pe_affichage.pe_print(
f" => {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}"
)
nbre_abandons = len(self.etudiants_ids) - len(self.etudiants_diplomes)
assert nbre_abandons == len(self.abandons_ids)
pe_affichage.pe_print(
f" => {nbre_abandons} étudiants non considérés (redoublement, réorientation, abandon"
)
# pe_affichage.pe_print(
# " => quelques étudiants futurs diplômés : "
# + ", ".join([str(etudid) for etudid in list(self.etudiants_diplomes)[:10]])
# )
# pe_affichage.pe_print(
# " => semestres dont il faut calculer les moyennes : "
# + ", ".join([str(fid) for fid in list(self.formsemestres_jury_ids)])
# )
def get_etudiants_diplomes(self) -> dict[int, Identite]:
"""Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
qui vont être à traiter au jury PE pour
l'année de diplômation donnée et n'ayant ni été réorienté, ni abandonné.
Returns:
Un dictionnaire `{etudid: Identite(etudid)}`
"""
etudids = [
etudid
for etudid, cursus_etud in self.cursus.items()
if cursus_etud["diplome"] == self.annee_diplome
and cursus_etud["abandon"] is False
]
etudiants = {etudid: self.identites[etudid] for etudid in etudids}
return etudiants
def get_etudiants_redoublants_ou_reorientes(self) -> dict[int, Identite]:
"""Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
dont les notes seront prises en compte (pour les classements) mais qui n'apparaitront
pas dans le jury car diplômé une autre année (redoublants) ou réorienté ou démissionnaire.
Returns:
Un dictionnaire `{etudid: Identite(etudid)}`
"""
etudids = [
etudid
for etudid, cursus_etud in self.cursus.items()
if cursus_etud["diplome"] != self.annee_diplome
or cursus_etud["abandon"] is True
]
etudiants = {etudid: self.identites[etudid] for etudid in etudids}
return etudiants
def analyse_etat_etudiant(self, etudid: int, cosemestres: dict[int, FormSemestre]):
"""Analyse le cursus d'un étudiant pouvant être :
* l'un de ceux sur lesquels le jury va statuer (année de diplômation du jury considéré)
* un étudiant qui ne sera pas considéré dans le jury mais qui a participé dans sa scolarité
à un (ou plusieurs) semestres communs aux étudiants du jury (et impactera les classements)
L'analyse consiste :
* à insérer une entrée dans ``self.cursus`` pour mémoriser son identité,
avec son nom, prénom, etc...
* à analyser son parcours, pour déterminer s'il n'a (ou non) abandonné l'IUT en cours de
route (cf. clé abandon)
Args:
etudid: L'etudid d'un étudiant, à ajouter à ceux traiter par le jury
cosemestres: Dictionnaire {fid: Formsemestre(fid)} donnant accès aux cosemestres
de même année de diplomation
"""
identite = Identite.get_etud(etudid)
# Le cursus global de l'étudiant (restreint aux semestres APC)
formsemestres = identite.get_formsemestres()
semestres_etudiant = {
formsemestre.formsemestre_id: formsemestre
for formsemestre in formsemestres
if formsemestre.formation.is_apc()
}
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": get_annee_diplome(
identite
), # Le date prévisionnelle de son diplôme
"formsemestres": semestres_etudiant, # les semestres de l'étudiant
"nb_semestres": len(
semestres_etudiant
), # le nombre de semestres de l'étudiant
"abandon": False, # va être traité en dessous
}
# 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
def structure_cursus_etudiant(self, etudid: int):
"""Structure les informations sur les semestres suivis par un
étudiant, pour identifier les semestres qui seront pris en compte lors de ses calculs
de moyennes PE.
Cette structuration s'appuie sur les numéros de semestre: pour chaque Si, stocke :
le dernier semestre (en date) de numéro i qu'il a suivi (1 ou 0 si pas encore suivi).
Ce semestre influera les interclassement par semestre dans la promo.
"""
semestres_significatifs = self.get_semestres_significatifs(etudid)
# Tri des semestres par numéro de semestre
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
# les semestres de n°i de l'étudiant:
semestres_i = {
fid: sem_sig
for fid, sem_sig in semestres_significatifs.items()
if sem_sig.semestre_id == i
}
self.cursus[etudid][f"S{i}"] = semestres_i
def get_formsemestres_terminaux_aggregat(
self, aggregat: str
) -> dict[int, FormSemestre]:
"""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
Returns:
Un dictionnaire ``{fid: FormSemestre(fid)}``
"""
formsemestres_terminaux = {}
for trajectoire_aggr in self.trajectoires.values():
trajectoire = trajectoire_aggr[aggregat]
if trajectoire:
# Le semestre terminal de l'étudiant de l'aggrégat
fid = trajectoire.formsemestre_final.formsemestre_id
formsemestres_terminaux[fid] = trajectoire.formsemestre_final
return formsemestres_terminaux
def nbre_etapes_max_diplomes(self, etudids: list[int]) -> int:
"""Partant d'un ensemble d'étudiants,
nombre de semestres (étapes) maximum suivis par les étudiants du jury.
Args:
etudids: Liste d'étudid d'étudiants
"""
nbres_semestres = []
for etudid in etudids:
nbres_semestres.append(self.cursus[etudid]["nb_semestres"])
if not nbres_semestres:
return 0
return max(nbres_semestres)
def df_administratif(self, etudids: list[int]) -> pd.DataFrame:
"""Synthétise toutes les données administratives d'un groupe
d'étudiants fournis par les etudid dans un dataFrame
Args:
etudids: La liste des étudiants à prendre en compte
"""
etudids = list(etudids)
# Récupération des données des étudiants
administratif = {}
nbre_semestres_max = self.nbre_etapes_max_diplomes(etudids)
for etudid in etudids:
etudiant = self.identites[etudid]
cursus = self.cursus[etudid]
formsemestres = cursus["formsemestres"]
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),
"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_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
Returns:
Un ensemble d``etudid``
"""
etudiants_ids = set()
for sem in semestres.values(): # pour chacun des semestres de la liste
etudiants_du_sem = {ins.etudid for ins in sem.inscriptions}
pe_affichage.pe_print(f" --> {sem} : {len(etudiants_du_sem)} etudiants")
etudiants_ids = (
etudiants_ids | etudiants_du_sem
) # incluant la suppression des doublons
return etudiants_ids
def get_annee_diplome(etud: Identite) -> int | None:
"""L'année de diplôme prévue d'un étudiant en fonction de ses semestres
d'inscription (pour un BUT).
Args:
identite: L'identité d'un étudiant
Returns:
L'année prévue de sa diplômation, ou None si aucun semestre
"""
formsemestres_apc = get_semestres_apc(etud)
if formsemestres_apc:
dates_possibles_diplome = []
# Années de diplômation prédites en fonction des semestres
# (d'une formation APC) d'un étudiant
for sem_base in formsemestres_apc:
annee = pe_comp.get_annee_diplome_semestre(sem_base)
if annee:
dates_possibles_diplome.append(annee)
if dates_possibles_diplome:
return max(dates_possibles_diplome)
return None
def get_semestres_apc(identite: Identite) -> list:
"""Liste des semestres d'un étudiant qui corresponde à une formation APC.
Args:
identite: L'identité d'un étudiant
Returns:
Liste de ``FormSemestre`` correspondant à une formation APC
"""
semestres = identite.get_formsemestres()
semestres_apc = []
for sem in semestres:
if sem.formation.is_apc():
semestres_apc.append(sem)
return semestres_apc
def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool:
"""Détermine si un étudiant a arrêté sa formation. 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 ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc),
l'étudiant doit appartenir à une instance des S5 qui conduisent à la diplomation dans
l'année visée. S'il n'est que dans un S4, il a sans doute arrêté. A moins qu'il ne soit
parti à l'étranger et là, pas de notes.
TODO:: Cas de l'étranger, à coder/tester
**Attention** : Cela suppose que toutes les instances d'un semestre donné
(par ex: toutes les instances de S6 accueillant un étudiant soient créées ; sinon les
étudiants non inscrits dans un S6 seront considérés comme ayant abandonnés)
TODO:: Peut-être à mettre en regard avec les propositions d'inscriptions d'étudiants dans un nouveau semestre
Pour chaque étudiant, recherche son dernier semestre en date (validé ou non) et
regarde s'il n'existe pas parmi les semestres existants dans Scodoc un semestre :
* dont les dates sont postérieures (en terme de date de début)
* de n° au moins égal à celui de son dernier semestre valide (S5 -> S5 ou S5 -> S6)
dans lequel il aurait pu s'inscrire mais ne l'a pas fait.
Args:
etud: L'identité d'un étudiant
cosemestres: Les semestres donnant lieu à diplômation (sans redoublement) en date du jury
Returns:
Est-il réorienté, démissionnaire ou a-t-il arrêté de son propre chef sa formation ?
TODO:: A reprendre pour le cas des étudiants à l'étranger
TODO:: A reprendre si BUT avec semestres décalés
"""
# 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
# Son dernier semestre APC en date
dernier_formsemestre = get_dernier_semestre_en_date(semestres_apc)
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:
return True
return False
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
def etapes_du_cursus(
semestres: dict[int, FormSemestre], nbre_etapes_max: int
) -> list[str]:
"""Partant d'un dictionnaire de semestres (qui retrace
la scolarité d'un étudiant), liste les noms des
semestres (en version abbrégée)
qu'un étudiant a suivi au cours de sa scolarité à l'IUT.
Les noms des semestres sont renvoyés dans un dictionnaire
``{"etape i": nom_semestre_a_etape_i}``
avec i variant jusqu'à nbre_semestres_max. (S'il n'y a pas de semestre à l'étape i,
le nom affiché est vide.
La fonction suppose la liste des semestres triées par ordre
décroissant de date.
Args:
semestres: une liste de ``FormSemestre``
nbre_etapes_max: le nombre d'étapes max prise en compte
Returns:
Une liste de nom de semestre (dans le même ordre que les ``semestres``)
See also:
app.pe.pe_affichage.nom_semestre_etape
"""
assert len(semestres) <= nbre_etapes_max
noms = [nom_semestre_etape(sem, avec_fid=False) for (fid, sem) in semestres.items()]
noms = noms[::-1] # trie par ordre croissant
dico = {f"Etape {i+1}": "" for i in range(nbre_etapes_max)}
for i, nom in enumerate(noms): # Charge les noms de semestres
dico[f"Etape {i+1}"] = nom
return dico
def nom_semestre_etape(semestre: FormSemestre, avec_fid=False) -> str:
"""Nom d'un semestre à afficher dans le descriptif des étapes de la scolarité
d'un étudiant.
Par ex: Pour un S2, affiche ``"Semestre 2 FI S014-2015 (129)"`` avec :
* 2 le numéro du semestre,
* FI la modalité,
* 2014-2015 les dates
Args:
semestre: Un ``FormSemestre``
avec_fid: Ajoute le n° du semestre à la description
Returns:
La chaine de caractères décrivant succintement le semestre
"""
formation: Formation = semestre.formation
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
description = [
parcours.SESSION_NAME.capitalize(),
str(semestre.semestre_id),
semestre.modalite, # eg FI ou FC
f"{semestre.date_debut.year}-{semestre.date_fin.year}",
]
if avec_fid:
description.append(f"({semestre.formsemestre_id})")
return " ".join(description)

160
app/pe/pe_interclasstag.py Normal file
View File

@ -0,0 +1,160 @@
##############################################################################
#
# 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.pe.pe_tabletags import TableTag, MoyenneTag
from app.pe.pe_etudiant import EtudiantsJuryPE
from app.pe.pe_rcs import RCS, RCSsJuryPE
from app.pe.pe_rcstag import RCSTag
class RCSInterclasseTag(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')
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
formsemestre)
* calculant le classement sur les étudiants diplômes
"""
def __init__(
self,
nom_rcs: str,
etudiants: EtudiantsJuryPE,
rcss_jury_pe: RCSsJuryPE,
rcss_tags: dict[tuple, RCSTag],
):
TableTag.__init__(self)
self.nom_rcs = nom_rcs
"""Le nom du RCS interclassé"""
self.nom = self.get_repr()
"""Les étudiants diplômés et leurs rcss""" # TODO
self.diplomes_ids = etudiants.etudiants_diplomes
self.etudiants_diplomes = {etudid for etudid in self.diplomes_ids}
# pour les exports sous forme de dataFrame
self.etudiants = {
etudid: etudiants.identites[etudid].etat_civil
for etudid in self.diplomes_ids
}
# Les trajectoires (et leur version tagguées), en ne gardant que
# celles associées à l'aggrégat
self.rcss: dict[int, RCS] = {}
"""Ensemble des trajectoires associées à l'aggrégat"""
for trajectoire_id in rcss_jury_pe.rcss:
trajectoire = rcss_jury_pe.rcss[trajectoire_id]
if trajectoire_id[0] == nom_rcs:
self.rcss[trajectoire_id] = trajectoire
self.trajectoires_taggues: dict[int, RCS] = {}
"""Ensemble des trajectoires tagguées associées à l'aggrégat"""
for trajectoire_id in self.rcss:
self.trajectoires_taggues[trajectoire_id] = rcss_tags[trajectoire_id]
# Les trajectoires suivies par les étudiants du jury, en ne gardant que
# celles associées aux diplomés
self.suivi: dict[int, RCS] = {}
"""Association entre chaque étudiant et la trajectoire tagguée à prendre en
compte pour l'aggrégat"""
for etudid in self.diplomes_ids:
self.suivi[etudid] = rcss_jury_pe.suivi[etudid][nom_rcs]
self.tags_sorted = self.do_taglist()
"""Liste des tags (triés par ordre alphabétique)"""
# Construit la matrice de notes
self.notes = self.compute_notes_matrice()
"""Matrice des notes de l'aggrégat"""
# Synthétise les moyennes/classements par tag
self.moyennes_tags: dict[str, MoyenneTag] = {}
for tag in self.tags_sorted:
moy_gen_tag = self.notes[tag]
self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag)
# Est significatif ? (aka a-t-il des tags et des notes)
self.significatif = len(self.tags_sorted) > 0
def get_repr(self) -> str:
"""Une représentation textuelle"""
return f"Aggrégat {self.nom_rcs}"
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

734
app/pe/pe_jury.py Normal file
View File

@ -0,0 +1,734 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
# ----------------------------------------------------------
# Ensemble des fonctions et des classes
# permettant les calculs preliminaires (hors affichage)
# a l'edition d'un jury de poursuites d'etudes
# ----------------------------------------------------------
import io
import os
import time
from zipfile import ZipFile
import numpy as np
import pandas as pd
from app.pe.pe_affichage import NOM_STAT_PROMO, SANS_NOTE, NOM_STAT_GROUPE
import app.pe.pe_affichage as pe_affichage
from app.pe.pe_etudiant import * # TODO A éviter -> pe_etudiant.
from app.pe.pe_rcs import * # TODO A éviter
from app.pe.pe_rcstag import RCSTag
from app.pe.pe_semtag import SemestreTag
from app.pe.pe_interclasstag import RCSInterclasseTag
class JuryPE(object):
"""
Classe mémorisant toutes les informations nécessaires pour établir un jury de PE, sur la base
d'une année de diplôme. De ce semestre est déduit :
1. l'année d'obtention du DUT,
2. tous les étudiants susceptibles à ce stade (au regard de leur parcours) d'être diplomés.
Args:
diplome : l'année d'obtention du diplome BUT et du jury de PE (généralement février XXXX)
"""
def __init__(self, diplome):
pe_affichage.pe_start_log()
self.diplome = diplome
"L'année du diplome"
self.nom_export_zip = f"Jury_PE_{self.diplome}"
"Nom du zip où ranger les fichiers générés"
pe_affichage.pe_print(
f"Données de poursuite d'étude générées le {time.strftime('%d/%m/%Y à %H:%M')}\n"
)
# Chargement des étudiants à prendre en compte dans le jury
pe_affichage.pe_print(
f"""*** Recherche et chargement des étudiants diplômés en {
self.diplome}"""
)
self.etudiants = EtudiantsJuryPE(self.diplome) # Les infos sur les étudiants
self.etudiants.find_etudiants()
self.diplomes_ids = self.etudiants.diplomes_ids
self.zipdata = io.BytesIO()
with ZipFile(self.zipdata, "w") as zipfile:
if not self.diplomes_ids:
pe_affichage.pe_print("*** Aucun étudiant diplômé")
else:
self._gen_xls_diplomes(zipfile)
self._gen_xls_semestre_taggues(zipfile)
self._gen_xls_rcss_tags(zipfile)
self._gen_xls_interclassements_rcss(zipfile)
self._gen_xls_synthese_jury_par_tag(zipfile)
self._gen_xls_synthese_par_etudiant(zipfile)
# et le log
self._add_log_to_zip(zipfile)
# Fin !!!! Tada :)
def _gen_xls_diplomes(self, zipfile: ZipFile):
"Intègre le bilan des semestres taggués au zip"
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
if self.diplomes_ids:
onglet = "diplômés"
df_diplome = self.etudiants.df_administratif(self.diplomes_ids)
df_diplome.to_excel(writer, onglet, index=True, header=True)
if self.etudiants.abandons_ids:
onglet = "redoublants-réorientés"
df_abandon = self.etudiants.df_administratif(
self.etudiants.abandons_ids
)
df_abandon.to_excel(writer, onglet, index=True, header=True)
output.seek(0)
self.add_file_to_zip(
zipfile,
f"etudiants_{self.diplome}.xlsx",
output.read(),
path="details",
)
def _gen_xls_semestre_taggues(self, zipfile: ZipFile):
"Génère les semestres taggués (avec le calcul des moyennes) pour le jury PE"
pe_affichage.pe_print("*** Génère les semestres taggués")
self.sems_tags = compute_semestres_tag(self.etudiants)
# Intègre le bilan des semestres taggués au zip final
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
for formsemestretag in self.sems_tags.values():
onglet = formsemestretag.nom
df = formsemestretag.df_moyennes_et_classements()
# écriture dans l'onglet
df.to_excel(writer, onglet, index=True, header=True)
output.seek(0)
self.add_file_to_zip(
zipfile,
f"semestres_taggues_{self.diplome}.xlsx",
output.read(),
path="details",
)
def _gen_xls_rcss_tags(self, zipfile: ZipFile):
"""Génère les RCS (combinaisons de semestres suivis
par un étudiant)
"""
pe_affichage.pe_print(
"*** Génère les trajectoires (différentes combinaisons de semestres) des étudiants"
)
self.rcss = RCSsJuryPE(self.diplome)
self.rcss.cree_rcss(self.etudiants)
# Génère les moyennes par tags des trajectoires
pe_affichage.pe_print("*** Calcule les moyennes par tag des RCS possibles")
self.rcss_tags = compute_trajectoires_tag(
self.rcss, self.etudiants, self.sems_tags
)
# Intègre le bilan des trajectoires tagguées au zip final
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
for rcs_tag in self.rcss_tags.values():
onglet = rcs_tag.get_repr()
df = rcs_tag.df_moyennes_et_classements()
# écriture dans l'onglet
df.to_excel(writer, onglet, index=True, header=True)
output.seek(0)
self.add_file_to_zip(
zipfile,
f"RCS_taggues_{self.diplome}.xlsx",
output.read(),
path="details",
)
def _gen_xls_interclassements_rcss(self, zipfile: ZipFile):
"""Intègre le bilan des RCS (interclassé par promo) au zip"""
# Génère les interclassements (par promo et) par (nom d') aggrégat
pe_affichage.pe_print("*** Génère les interclassements par aggrégat")
self.interclassements_taggues = compute_interclassements(
self.etudiants, self.rcss, self.rcss_tags
)
# Intègre le bilan des aggrégats (interclassé par promo) au zip final
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
for interclass_tag in self.interclassements_taggues.values():
if interclass_tag.significatif: # Avec des notes
onglet = interclass_tag.get_repr()
df = interclass_tag.df_moyennes_et_classements()
# écriture dans l'onglet
df.to_excel(writer, onglet, index=True, header=True)
output.seek(0)
self.add_file_to_zip(
zipfile,
f"interclassements_taggues_{self.diplome}.xlsx",
output.read(),
path="details",
)
def _gen_xls_synthese_jury_par_tag(self, zipfile: ZipFile):
"""Synthèse des éléments du jury PE tag par tag"""
# Synthèse des éléments du jury PE
self.synthese = self.synthetise_jury_par_tags()
# Export des données => mode 1 seule feuille -> supprimé
pe_affichage.pe_print("*** Export du jury de synthese par tags")
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
for onglet, df in self.synthese.items():
# écriture dans l'onglet:
df.to_excel(writer, onglet, index=True, header=True)
output.seek(0)
self.add_file_to_zip(
zipfile, f"synthese_jury_{self.diplome}_par_tag.xlsx", output.read()
)
def _gen_xls_synthese_par_etudiant(self, zipfile: ZipFile):
"""Synthèse des éléments du jury PE, étudiant par étudiant"""
# Synthèse des éléments du jury PE
synthese = self.synthetise_jury_par_etudiants()
# Export des données => mode 1 seule feuille -> supprimé
pe_affichage.pe_print("*** Export du jury de synthese par étudiants")
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
for onglet, df in synthese.items():
# écriture dans l'onglet:
df.to_excel(writer, onglet, index=True, header=True)
output.seek(0)
self.add_file_to_zip(
zipfile, f"synthese_jury_{self.diplome}_par_etudiant.xlsx", output.read()
)
def _add_log_to_zip(self, zipfile):
"""Add a text file with the log messages"""
log_data = pe_affichage.pe_get_log()
self.add_file_to_zip(zipfile, "pe_log.txt", log_data)
def add_file_to_zip(self, zipfile: ZipFile, filename: str, data, path=""):
"""Add a file to given zip
All files under NOM_EXPORT_ZIP/
path may specify a subdirectory
Args:
zipfile: ZipFile
filename: Le nom du fichier à intégrer au zip
data: Les données du fichier
path: Un dossier dans l'arborescence du zip
"""
path_in_zip = os.path.join(path, filename) # self.nom_export_zip,
zipfile.writestr(path_in_zip, data)
def get_zipped_data(self) -> io.BytesIO | None:
"""returns file-like data with a zip of all generated (CSV) files.
Warning: reset stream to the begining.
"""
self.zipdata.seek(0)
return self.zipdata
def do_tags_list(self, interclassements: dict[str, RCSInterclasseTag]):
"""La liste des tags extraites des interclassements"""
tags = []
for aggregat in interclassements:
interclass = interclassements[aggregat]
if interclass.tags_sorted:
tags.extend(interclass.tags_sorted)
tags = sorted(set(tags))
return tags
# **************************************************************************************************************** #
# Méthodes pour la synthèse du juryPE
# *****************************************************************************************************************
def synthetise_jury_par_tags(self) -> dict[pd.DataFrame]:
"""Synthétise tous les résultats du jury PE dans des dataframes,
dont les onglets sont les tags"""
pe_affichage.pe_print("*** Synthèse finale des moyennes par tag***")
synthese = {}
pe_affichage.pe_print(" -> Synthèse des données administratives")
synthese["administratif"] = self.etudiants.df_administratif(self.diplomes_ids)
tags = self.do_tags_list(self.interclassements_taggues)
for tag in tags:
pe_affichage.pe_print(f" -> Synthèse du tag {tag}")
synthese[tag] = self.df_tag(tag)
return synthese
def df_tag(self, tag):
"""Génère le DataFrame synthétisant les moyennes/classements (groupe,
interclassement promo) pour tous les aggrégats prévus,
tels que fourni dans l'excel final.
Args:
tag: Un des tags (a minima `but`)
Returns:
"""
etudids = list(self.diplomes_ids)
# Les données des étudiants
donnees_etudiants = {}
for etudid in etudids:
etudiant = self.etudiants.identites[etudid]
donnees_etudiants[etudid] = {
("Identité", "", "Civilite"): etudiant.civilite_str,
("Identité", "", "Nom"): etudiant.nom,
("Identité", "", "Prenom"): etudiant.prenom,
}
df_synthese = pd.DataFrame.from_dict(donnees_etudiants, orient="index")
# Ajout des aggrégats
for aggregat in TOUS_LES_RCS:
descr = TYPES_RCS[aggregat]["descr"]
# Les trajectoires (tagguées) suivies par les étudiants pour l'aggrégat et le tag
# considéré
trajectoires_tagguees = []
for etudid in etudids:
trajectoire = self.rcss.suivi[etudid][aggregat]
if trajectoire:
tid = trajectoire.rcs_id
trajectoire_tagguee = self.rcss_tags[tid]
if (
tag in trajectoire_tagguee.moyennes_tags
and trajectoire_tagguee not in trajectoires_tagguees
):
trajectoires_tagguees.append(trajectoire_tagguee)
# Combien de notes vont être injectées ?
nbre_notes_injectees = 0
for traj in trajectoires_tagguees:
moy_traj = traj.moyennes_tags[tag]
inscrits_traj = moy_traj.inscrits_ids
etudids_communs = set(etudids) & set(inscrits_traj)
nbre_notes_injectees += len(etudids_communs)
# Si l'aggrégat est significatif (aka il y a des notes)
if nbre_notes_injectees > 0:
# Ajout des données classements & statistiques
nom_stat_promo = f"{NOM_STAT_PROMO} {self.diplome}"
donnees = pd.DataFrame(
index=etudids,
columns=[
[descr] * (1 + 4 * 2),
[""] + [NOM_STAT_GROUPE] * 4 + [nom_stat_promo] * 4,
["note"] + ["class.", "min", "moy", "max"] * 2,
],
)
for traj in trajectoires_tagguees:
# Les données des trajectoires_tagguees
moy_traj = traj.moyennes_tags[tag]
# Les étudiants communs entre tableur de synthèse et trajectoires
inscrits_traj = moy_traj.inscrits_ids
etudids_communs = list(set(etudids) & set(inscrits_traj))
# Les notes
champ = (descr, "", "note")
notes_traj = moy_traj.get_notes()
donnees.loc[etudids_communs, champ] = notes_traj.loc[
etudids_communs
]
# Les rangs
champ = (descr, NOM_STAT_GROUPE, "class.")
rangs = moy_traj.get_rangs_inscrits()
donnees.loc[etudids_communs, champ] = rangs.loc[etudids_communs]
# Les mins
champ = (descr, NOM_STAT_GROUPE, "min")
mins = moy_traj.get_min()
donnees.loc[etudids_communs, champ] = mins.loc[etudids_communs]
# Les max
champ = (descr, NOM_STAT_GROUPE, "max")
maxs = moy_traj.get_max()
donnees.loc[etudids_communs, champ] = maxs.loc[etudids_communs]
# Les moys
champ = (descr, NOM_STAT_GROUPE, "moy")
moys = moy_traj.get_moy()
donnees.loc[etudids_communs, champ] = moys.loc[etudids_communs]
# Ajoute les données d'interclassement
interclass = self.interclassements_taggues[aggregat]
moy_interclass = interclass.moyennes_tags[tag]
# Les étudiants communs entre tableur de synthèse et l'interclassement
inscrits_interclass = moy_interclass.inscrits_ids
etudids_communs = list(set(etudids) & set(inscrits_interclass))
# Les classements d'interclassement
champ = (descr, nom_stat_promo, "class.")
rangs = moy_interclass.get_rangs_inscrits()
donnees.loc[etudids_communs, champ] = rangs.loc[etudids_communs]
# Les mins
champ = (descr, nom_stat_promo, "min")
mins = moy_interclass.get_min()
donnees.loc[etudids_communs, champ] = mins.loc[etudids_communs]
# Les max
champ = (descr, nom_stat_promo, "max")
maxs = moy_interclass.get_max()
donnees.loc[etudids_communs, champ] = maxs.loc[etudids_communs]
# Les moys
champ = (descr, nom_stat_promo, "moy")
moys = moy_interclass.get_moy()
donnees.loc[etudids_communs, champ] = moys.loc[etudids_communs]
df_synthese = df_synthese.join(donnees)
# Fin de l'aggrégat
# Tri par nom/prénom
df_synthese.sort_values(
by=[("Identité", "", "Nom"), ("Identité", "", "Prenom")], inplace=True
)
return df_synthese
def synthetise_jury_par_etudiants(self) -> dict[pd.DataFrame]:
"""Synthétise tous les résultats du jury PE dans des dataframes,
dont les onglets sont les étudiants"""
pe_affichage.pe_print("*** Synthèse finale des moyennes par étudiants***")
synthese = {}
pe_affichage.pe_print(" -> Synthèse des données administratives")
synthese["administratif"] = self.etudiants.df_administratif(self.diplomes_ids)
etudids = list(self.diplomes_ids)
for etudid in etudids:
etudiant = self.etudiants.identites[etudid]
nom = etudiant.nom
prenom = etudiant.prenom[0] # initial du prénom
onglet = f"{nom} {prenom}. ({etudid})"
if len(onglet) > 32: # limite sur la taille des onglets
fin_onglet = f"{prenom}. ({etudid})"
onglet = f"{nom[:32-len(fin_onglet)-2]}." + fin_onglet
pe_affichage.pe_print(f" -> Synthèse de l'étudiant {etudid}")
synthese[onglet] = self.df_synthese_etudiant(etudid)
return synthese
def df_synthese_etudiant(self, etudid: int) -> pd.DataFrame:
"""Créé un DataFrame pour un étudiant donné par son etudid, retraçant
toutes ses moyennes aux différents tag et aggrégats"""
tags = self.do_tags_list(self.interclassements_taggues)
donnees = {}
for tag in tags:
# Une ligne pour le tag
donnees[tag] = {("", "", "tag"): tag}
for aggregat in TOUS_LES_RCS:
# Le dictionnaire par défaut des moyennes
donnees[tag] |= get_defaut_dict_synthese_aggregat(
aggregat, self.diplome
)
# La trajectoire de l'étudiant sur l'aggrégat
trajectoire = self.rcss.suivi[etudid][aggregat]
if trajectoire:
trajectoire_tagguee = self.rcss_tags[trajectoire.rcs_id]
if tag in trajectoire_tagguee.moyennes_tags:
# L'interclassement
interclass = self.interclassements_taggues[aggregat]
# Injection des données dans un dictionnaire
donnees[tag] |= get_dict_synthese_aggregat(
aggregat,
trajectoire_tagguee,
interclass,
etudid,
tag,
self.diplome,
)
# Fin de l'aggrégat
# Construction du dataFrame
df = pd.DataFrame.from_dict(donnees, orient="index")
# Tri par nom/prénom
df.sort_values(by=[("", "", "tag")], inplace=True)
return df
def get_formsemestres_etudiants(etudiants: EtudiantsJuryPE) -> dict:
"""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.
Args:
etudiants: Les étudiants du jury PE
Returns:
Un dictionnaire de la forme `{fid: FormSemestre(fid)}`
"""
semestres = {}
for etudid in etudiants.etudiants_ids:
for cle in etudiants.cursus[etudid]:
if cle.startswith("S"):
semestres = semestres | etudiants.cursus[etudid][cle]
return semestres
def compute_semestres_tag(etudiants: EtudiantsJuryPE) -> dict:
"""Créé les semestres taggués, de type 'S1', 'S2', ..., pour un groupe d'étudiants donnés.
Chaque semestre taggué est rattaché à l'un des FormSemestre faisant partie du cursus scolaire
des étudiants (cf. attribut etudiants.cursus).
En crééant le semestre taggué, sont calculées les moyennes/classements par tag associé.
.
Args:
etudiants: Un groupe d'étudiants participant au jury
Returns:
Un dictionnaire {fid: SemestreTag(fid)}
"""
# Création des semestres taggués, de type 'S1', 'S2', ...
pe_affichage.pe_print("*** Création des semestres taggués")
formsemestres = get_formsemestres_etudiants(etudiants)
semestres_tags = {}
for frmsem_id, formsemestre in formsemestres.items():
# Crée le semestre_tag et exécute les calculs de moyennes
formsemestretag = SemestreTag(frmsem_id)
pe_affichage.pe_print(
f" --> Semestre taggué {formsemestretag.nom} sur la base de {formsemestre}"
)
# Stocke le semestre taggué
semestres_tags[frmsem_id] = formsemestretag
return semestres_tags
def compute_trajectoires_tag(
trajectoires: RCSsJuryPE,
etudiants: EtudiantsJuryPE,
semestres_taggues: dict[int, SemestreTag],
):
"""Créée les trajectoires tagguées (combinaison aggrégeant plusieurs semestres au sens
d'un aggrégat (par ex: '3S')),
en calculant les moyennes et les classements par tag pour chacune.
Pour rappel : Chaque trajectoire est identifiée un nom d'aggrégat et par un formsemestre terminal.
Par exemple :
* combinaisons '3S' : S1+S2+S3 en prenant en compte tous les S3 qu'ont fréquenté les
étudiants du jury PE. Ces S3 marquent les formsemestre terminal de chaque combinaison.
* combinaisons 'S2' : 1 seul S2 pour des étudiants n'ayant pas redoublé, 2 pour des redoublants (dont les
notes seront moyennées sur leur 2 semestres S2). Ces combinaisons ont pour formsemestre le dernier S2 en
date (le S2 redoublé par les redoublants est forcément antérieur)
Args:
etudiants: Les données des étudiants
semestres_tag: Les semestres tag (pour lesquels des moyennes par tag ont été calculés)
Return:
Un dictionnaire de la forme ``{nom_aggregat: {fid_terminal: SetTag(fid_terminal)} }``
"""
trajectoires_tagguees = {}
for trajectoire_id, trajectoire in trajectoires.rcss.items():
nom = trajectoire.get_repr()
pe_affichage.pe_print(f" --> Aggrégat {nom}")
# Trajectoire_tagguee associée
trajectoire_tagguee = RCSTag(trajectoire, semestres_taggues)
# Mémorise le résultat
trajectoires_tagguees[trajectoire_id] = trajectoire_tagguee
return trajectoires_tagguees
def compute_interclassements(
etudiants: EtudiantsJuryPE,
trajectoires_jury_pe: RCSsJuryPE,
trajectoires_tagguees: dict[tuple, RCS],
):
"""Interclasse les étudiants, (nom d') aggrégat par aggrégat,
pour fournir un classement sur la promo. Le classement est établi au regard du nombre
d'étudiants ayant participé au même aggrégat.
"""
aggregats_interclasses_taggues = {}
for nom_aggregat in TOUS_LES_RCS:
pe_affichage.pe_print(f" --> Interclassement {nom_aggregat}")
interclass = RCSInterclasseTag(
nom_aggregat, etudiants, trajectoires_jury_pe, trajectoires_tagguees
)
aggregats_interclasses_taggues[nom_aggregat] = interclass
return aggregats_interclasses_taggues
def get_defaut_dict_synthese_aggregat(nom_rcs: str, diplome: int) -> dict:
"""Renvoie le dictionnaire de synthèse (à intégrer dans
un tableur excel) pour décrire les résultats d'un aggrégat
Args:
nom_rcs : Le nom du RCS visé
diplôme : l'année du diplôme
"""
# L'affichage de l'aggrégat dans le tableur excel
descr = get_descr_rcs(nom_rcs)
nom_stat_promo = f"{NOM_STAT_PROMO} {diplome}"
donnees = {
(descr, "", "note"): SANS_NOTE,
# Les stat du groupe
(descr, NOM_STAT_GROUPE, "class."): SANS_NOTE,
(descr, NOM_STAT_GROUPE, "min"): SANS_NOTE,
(descr, NOM_STAT_GROUPE, "moy"): SANS_NOTE,
(descr, NOM_STAT_GROUPE, "max"): SANS_NOTE,
# Les stats de l'interclassement dans la promo
(descr, nom_stat_promo, "class."): SANS_NOTE,
(
descr,
nom_stat_promo,
"min",
): SANS_NOTE,
(
descr,
nom_stat_promo,
"moy",
): SANS_NOTE,
(
descr,
nom_stat_promo,
"max",
): SANS_NOTE,
}
return donnees
def get_dict_synthese_aggregat(
aggregat: str,
trajectoire_tagguee: RCSTag,
interclassement_taggue: RCSInterclasseTag,
etudid: int,
tag: str,
diplome: int,
):
"""Renvoie le dictionnaire (à intégrer au tableur excel de synthese)
traduisant les résultats (moy/class) d'un étudiant à une trajectoire tagguée associée
à l'aggrégat donné et pour un tag donné"""
donnees = {}
# L'affichage de l'aggrégat dans le tableur excel
descr = get_descr_rcs(aggregat)
# La note de l'étudiant (chargement à venir)
note = np.nan
# Les données de la trajectoire tagguée pour le tag considéré
moy_tag = trajectoire_tagguee.moyennes_tags[tag]
# Les données de l'étudiant
note = moy_tag.get_note_for_df(etudid)
classement = moy_tag.get_class_for_df(etudid)
nmin = moy_tag.get_min_for_df()
nmax = moy_tag.get_max_for_df()
nmoy = moy_tag.get_moy_for_df()
# Statistiques sur le groupe
if not pd.isna(note) and note != np.nan:
# Les moyennes de cette trajectoire
donnees |= {
(descr, "", "note"): note,
(descr, NOM_STAT_GROUPE, "class."): classement,
(descr, NOM_STAT_GROUPE, "min"): nmin,
(descr, NOM_STAT_GROUPE, "moy"): nmoy,
(descr, NOM_STAT_GROUPE, "max"): nmax,
}
# L'interclassement
moy_tag = interclassement_taggue.moyennes_tags[tag]
classement = moy_tag.get_class_for_df(etudid)
nmin = moy_tag.get_min_for_df()
nmax = moy_tag.get_max_for_df()
nmoy = moy_tag.get_moy_for_df()
if not pd.isna(note) and note != np.nan:
nom_stat_promo = f"{NOM_STAT_PROMO} {diplome}"
donnees |= {
(descr, nom_stat_promo, "class."): classement,
(descr, nom_stat_promo, "min"): nmin,
(descr, nom_stat_promo, "moy"): nmoy,
(descr, nom_stat_promo, "max"): nmax,
}
return donnees

File diff suppressed because it is too large Load Diff

269
app/pe/pe_rcs.py Normal file
View File

@ -0,0 +1,269 @@
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on 01-2024
@author: barasc
"""
import app.pe.pe_comp as pe_comp
from app.models import FormSemestre
from app.pe.pe_etudiant import EtudiantsJuryPE, get_dernier_semestre_en_date
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 semestre 1 au semestre 3 (S1+S2+S3)",
},
"4S": {
"aggregat": ["S1", "S2", "S3", "S4"],
"descr": "Moyenne du semestre 1 au semestre 4 (S1+S2+S3+S4)",
},
"S5": {
"aggregat": ["S5"],
"descr": "Semestre 5 (S5)",
},
"S6": {
"aggregat": ["S6"],
"descr": "Semestre 6 (S6)",
},
"3A": {
"aggregat": ["S5", "S6"],
"descr": "3ème année (S5+S6)",
},
"5S": {
"aggregat": ["S1", "S2", "S3", "S4", "S5"],
"descr": "Moyenne du semestre 1 au semestre 5 (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")]
class RCS:
"""Modélise un ensemble de semestres d'étudiants
associé à un type de regroupement cohérent de semestres
donné (par ex: 'S2', '3S', '2A').
Si le RCS est un semestre de type Si, stocke le (ou les)
formsemestres de numéro i qu'ont suivi 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 le RCS 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, ...
Args:
nom_rcs: Un nom du RCS (par ex: '5S')
semestre_final: Le semestre final du RCS
"""
def __init__(self, nom_rcs: str, semestre_final: FormSemestre):
self.nom = nom_rcs
"""Nom du RCS"""
self.formsemestre_final = semestre_final
"""FormSemestre terminal du RCS"""
self.rcs_id = (nom_rcs, semestre_final.formsemestre_id)
"""Identifiant du RCS sous forme (nom_rcs, id du semestre_terminal)"""
self.semestres_aggreges = {}
"""Semestres regroupés dans le RCS"""
def add_semestres_a_aggreger(self, semestres: dict[int:FormSemestre]):
"""Ajout de semestres aux semestres à regrouper
Args:
semestres: Dictionnaire ``{fid: FormSemestre(fid)}`` à ajouter
"""
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"""
noms = []
for fid in self.semestres_aggreges:
semestre = self.semestres_aggreges[fid]
noms.append(f"S{semestre.semestre_id}({fid})")
noms = sorted(noms)
title = f"""{self.nom} ({
self.formsemestre_final.formsemestre_id}) {self.formsemestre_final.date_fin.year}"""
if verbose and noms:
title += " - " + "+".join(noms)
return title
class RCSsJuryPE:
"""Classe centralisant toutes 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):
self.annee_diplome = annee_diplome
"""Année de diplômation"""
self.rcss: dict[tuple:RCS] = {}
"""Ensemble des RCS recensés : {(nom_RCS, fid_terminal): RCS}"""
self.suivi: dict[int:str] = {}
"""Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS,
son RCS : {etudid: {nom_RCS: RCS}}"""
def cree_rcss(self, etudiants: EtudiantsJuryPE):
"""Créé tous les RCS, 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
"""
for nom_rcs in pe_comp.TOUS_LES_SEMESTRES + TOUS_LES_RCS_AVEC_PLUSIEURS_SEM:
# 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 = TYPES_RCS[nom_rcs]["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_comp.TOUS_LES_SEMESTRES
+ TOUS_LES_RCS_AVEC_PLUSIEURS_SEM
}
# 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_rcs, formsemestre_final.formsemestre_id)
if trajectoire_id not in self.rcss:
trajectoire = RCS(nom_rcs, formsemestre_final)
self.rcss[trajectoire_id] = trajectoire
else:
trajectoire = self.rcss[trajectoire_id]
# La liste des semestres de l'étudiant à prendre en compte
# pour cette trajectoire
semestres_a_aggreger = get_rcs_etudiant(
etudiants.cursus[etudid], formsemestre_final, nom_rcs
)
# 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_rcs] = trajectoire
def get_rcs_etudiant(
semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str
) -> dict[int, FormSemestre]:
"""Ensemble des semestres parcourus par un étudiant, connaissant
les semestres de son cursus,
dans le cadre du RCS visé et ayant pour semestre terminal `formsemestre_final`.
Si le RCS est de type "Si", limite les semestres à ceux de numéro i.
Par ex: si formsemestre_terminal est un S3 et nom_agrregat "S3", ne prend en compte que les
semestres 3.
Si le RCS est de type "iA" ou "iS" (incluant plusieurs numéros de semestres), prend en
compte les dit numéros de semestres.
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.
Args:
cursus: Dictionnaire {fid: FormSemestre(fid)} 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 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_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"]

217
app/pe/pe_rcstag.py Normal file
View File

@ -0,0 +1,217 @@
# -*- 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.res_sem import load_formsemestre_results
from app.pe.pe_semtag import SemestreTag
import pandas as pd
import numpy as np
from app.pe.pe_rcs import RCS
from app.pe.pe_tabletags import TableTag, MoyenneTag
class RCSTag(TableTag):
def __init__(
self, rcs: RCS, semestres_taggues: dict[int, SemestreTag]
):
"""Calcule les moyennes par tag d'une combinaison de semestres
(RCS), 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.
Args:
rcs: Un RCS (identifié par un nom et l'id de son semestre terminal)
semestres_taggues: Les données sur les semestres taggués
"""
TableTag.__init__(self)
self.rcs_id = rcs.rcs_id
"""Identifiant du RCS taggué (identique au RCS sur lequel il s'appuie)"""
self.rcs = rcs
"""RCS associé au RCS taggué"""
self.nom = self.get_repr()
"""Représentation textuelle du RCS taggué"""
self.formsemestre_terminal = rcs.formsemestre_final
"""Le formsemestre terminal"""
# Les résultats du formsemestre terminal
nt = load_formsemestre_results(self.formsemestre_terminal)
self.semestres_aggreges = rcs.semestres_aggreges
"""Les semestres aggrégés"""
self.semestres_tags_aggreges = {}
"""Les semestres tags associés aux semestres aggrégés"""
for frmsem_id in self.semestres_aggreges:
try:
self.semestres_tags_aggreges[frmsem_id] = semestres_taggues[frmsem_id]
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}
self.tags_sorted = self.do_taglist()
"""Tags extraits de tous les semestres"""
self.notes_cube = self.compute_notes_cube()
"""Cube de notes"""
etudids = list(self.etudiants.keys())
self.notes = compute_tag_moy(self.notes_cube, etudids, self.tags_sorted)
"""Calcul les moyennes par tag sous forme d'un dataframe"""
self.moyennes_tags: dict[str, MoyenneTag] = {}
"""Synthétise les moyennes/classements par tag (qu'ils soient personnalisé ou de compétences)"""
for tag in self.tags_sorted:
moy_gen_tag = self.notes[tag]
self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag)
def __eq__(self, other):
"""Egalité de 2 RCS taggués sur la base de leur identifiant"""
return self.rcs_id == other.rcs_id
def get_repr(self, verbose=False) -> str:
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
est basée)"""
return self.rcs.get_repr(verbose=verbose)
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
]
# 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 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
)
etud_moy_tag_df.fillna(np.nan)
return etud_moy_tag_df

View File

@ -1,511 +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
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
from app.models.moduleimpls import ModuleImpl
from app.pe import pe_tagtable
from app.scodoc import codes_cursus
from app.scodoc import sco_tag_module
from app.scodoc import sco_utils as scu
class SemestreTag(pe_tagtable.TableTag):
"""Un SemestreTag représente un tableau de notes (basé sur notesTable)
modélisant les résultats des étudiants sous forme de moyennes par tag.
Attributs récupérés via des NotesTables :
- nt: le tableau de notes du semestre considéré
- nt.inscrlist: étudiants inscrits à ce semestre, par ordre alphabétique (avec demissions)
- nt.identdict: { etudid : ident }
- liste des moduleimpl { ... 'module_id', ...}
Attributs supplémentaires :
- inscrlist/identdict: étudiants inscrits hors démissionnaires ou défaillants
- _tagdict : Dictionnaire résumant les tags et les modules du semestre auxquels ils sont liés
Attributs hérités de TableTag :
- nom :
- resultats: {tag: { etudid: (note_moy, somme_coff), ...} , ...}
- rang
- statistiques
Redéfinition :
- get_etudids() : les etudids des étudiants non défaillants ni démissionnaires
"""
DEBUG = True
# -----------------------------------------------------------------------------
# Fonctions d'initialisation
# -----------------------------------------------------------------------------
def __init__(self, notetable, sem): # Initialisation sur la base d'une notetable
"""Instantiation d'un objet SemestreTag à partir d'un tableau de note
et des informations sur le semestre pour le dater
"""
pe_tagtable.TableTag.__init__(
self,
nom="S%d %s %s-%s"
% (
sem["semestre_id"],
"ENEPS"
if "ENEPS" in sem["titre"]
else "UFA"
if "UFA" in sem["titre"]
else "FI",
sem["annee_debut"],
sem["annee_fin"],
),
)
# Les attributs spécifiques
self.nt = notetable
# Les attributs hérités : la liste des étudiants
self.inscrlist = [
etud
for etud in self.nt.inscrlist
if self.nt.get_etud_etat(etud["etudid"]) == scu.INSCRIT
]
self.identdict = {
etudid: ident
for (etudid, ident) in self.nt.identdict.items()
if etudid in self.get_etudids()
} # Liste des étudiants non démissionnaires et non défaillants
# Les modules pris en compte dans le calcul des moyennes par tag => ceux des UE standards
self.modimpls = [
modimpl
for modimpl in self.nt.formsemestre.modimpls_sorted
if modimpl.module.ue.type == codes_cursus.UE_STANDARD
] # la liste des modules (objet modimpl)
self.somme_coeffs = sum(
[
modimpl.module.coefficient
for modimpl in self.modimpls
if modimpl.module.coefficient is not None
]
)
# -----------------------------------------------------------------------------
def comp_data_semtag(self):
"""Calcule tous les données numériques associées au semtag"""
# Attributs relatifs aux tag pour les modules pris en compte
self.tagdict = (
self.do_tagdict()
) # Dictionnaire résumant les tags et les données (normalisées) des modules du semestre auxquels ils sont liés
# Calcul des moyennes de chaque étudiant puis ajoute la moyenne au sens "DUT"
for tag in self.tagdict:
self.add_moyennesTag(tag, self.comp_MoyennesTag(tag, force=True))
self.add_moyennesTag("dut", self.get_moyennes_DUT())
self.taglist = sorted(
list(self.tagdict.keys()) + ["dut"]
) # actualise la liste des tags
# -----------------------------------------------------------------------------
def get_etudids(self):
"""Renvoie la liste des etud_id des étudiants inscrits au semestre"""
return [etud["etudid"] for etud in self.inscrlist]
# -----------------------------------------------------------------------------
def do_tagdict(self):
"""Parcourt les modimpl du semestre (instance des modules d'un programme) et synthétise leurs données sous la
forme d'un dictionnaire reliant les tags saisis dans le programme aux
données des modules qui les concernent, à savoir les modimpl_id, les module_id, le code du module, le coeff,
la 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' : ...},
modimpl_id2 : ....
},
tagname2 : ...
}
Renvoie le dictionnaire ainsi construit.
Rq: choix fait de repérer les modules par rapport à leur modimpl_id (valable uniquement pour un semestre), car
correspond à la majorité des calculs de moyennes pour les étudiants
(seuls ceux qui ont capitalisé des ue auront un régime de calcul différent).
"""
tagdict = {}
for modimpl in self.modimpls:
modimpl_id = modimpl.id
# liste des tags pour le modimpl concerné:
tags = sco_tag_module.module_tag_list(modimpl.module.id)
for (
tag
) in tags: # tag de la forme "mathématiques", "théorie", "pe:0", "maths:2"
[tagname, ponderation] = sco_tag_module.split_tagname_coeff(
tag
) # extrait un tagname et un éventuel coefficient de pondération (par defaut: 1)
# tagname = tagname
if tagname not in tagdict: # Ajout d'une clé pour le tag
tagdict[tagname] = {}
# Ajout du modimpl au tagname considéré
tagdict[tagname][modimpl_id] = {
"module_id": modimpl.module.id, # 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 tagdict
# -----------------------------------------------------------------------------
def comp_MoyennesTag(self, tag, force=False) -> list:
"""Calcule et renvoie les "moyennes" de tous les étudiants du SemTag
(non défaillants) à un tag donné, en prenant en compte
tous les modimpl_id concerné par le tag, leur coeff et leur pondération.
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), ...]
"""
lesMoyennes = []
for etudid in self.get_etudids():
(
notes,
coeffs_norm,
ponderations,
) = self.get_listesNotesEtCoeffsTagEtudiant(
tag, etudid
) # les notes associées au tag
coeffs = comp_coeff_pond(
coeffs_norm, ponderations
) # les coeff pondérés par les tags
(moyenne, somme_coeffs) = pe_tagtable.moyenne_ponderee_terme_a_terme(
notes, coeffs, force=force
)
lesMoyennes += [
(moyenne, somme_coeffs, etudid)
] # Un tuple (pour classement résumant les données)
return lesMoyennes
# -----------------------------------------------------------------------------
def get_moyennes_DUT(self):
"""Lit les moyennes DUT du semestre pour tous les étudiants
et les renvoie au même format que comp_MoyennesTag"""
return [
(self.nt.etud_moy_gen[etudid], 1.0, etudid) for etudid in self.get_etudids()
]
# -----------------------------------------------------------------------------
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 []
# -----------------------------------------------------------------------------
def get_listesNotesEtCoeffsTagEtudiant(self, tag, etudid):
"""Renvoie un triplet (notes, coeffs_norm, ponderations) où notes, coeff_norm et ponderation désignent trois listes
donnant -pour un tag donné- les note, coeff et ponderation de chaque modimpl à prendre en compte dans
le calcul de la moyenne du tag.
Les notes et coeff_norm sont extraits grâce à SemestreTag.get_noteEtCoeff_modimpl (donc dans semestre courant ou UE capitalisée).
Les pondérations sont celles déclarées avec le tag (cf. _tagdict)."""
notes = []
coeffs_norm = []
ponderations = []
for moduleimpl_id, modimpl in self.tagdict[
tag
].items(): # pour chaque module du semestre relatif au tag
(note, coeff_norm) = self.get_noteEtCoeff_modimpl(moduleimpl_id, etudid)
if note != None:
notes.append(note)
coeffs_norm.append(coeff_norm)
ponderations.append(modimpl["ponderation"])
return (notes, coeffs_norm, ponderations)
# -----------------------------------------------------------------------------
# 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 comp_coeff_pond(coeffs, ponderations):
"""
Applique une ponderation (indiquée dans la liste ponderations) à une liste de coefficients :
ex: coeff = [2, 3, 1, None], ponderation = [1, 2, 0, 1] => [2*1, 3*2, 1*0, None]
Les coeff peuvent éventuellement être None auquel cas None est conservé ;
Les pondérations sont des floattants
"""
if (
coeffs == None
or ponderations == None
or not isinstance(coeffs, list)
or not isinstance(ponderations, list)
or len(coeffs) != len(ponderations)
):
raise ValueError("Erreur de paramètres dans comp_coeff_pond")
return [
(None if coeffs[i] == None else coeffs[i] * ponderations[i])
for i in range(len(coeffs))
]
# -----------------------------------------------------------------------------
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"]

310
app/pe/pe_semtag.py Normal file
View File

@ -0,0 +1,310 @@
# -*- 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
"""
import pandas as pd
import app.pe.pe_etudiant
from app import db, ScoValueError
from app import comp
from app.comp.res_sem import load_formsemestre_results
from app.models import FormSemestre
from app.models.moduleimpls import ModuleImpl
import app.pe.pe_affichage as pe_affichage
from app.pe.pe_tabletags import TableTag, MoyenneTag
from app.scodoc import sco_tag_module
from app.scodoc.codes_cursus import UE_SPORT
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.
"""
def __init__(self, formsemestre_id: int):
"""
Args:
formsemestre_id: Identifiant du ``FormSemestre`` sur lequel il se base
"""
TableTag.__init__(self)
# Le semestre
self.formsemestre_id = formsemestre_id
self.formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# Le nom du semestre taggué
self.nom = self.get_repr()
# 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 :
## Saisis par l'utilisateur
tags_personnalises = get_synthese_tags_personnalises_semestre(
self.nt.formsemestre
)
noms_tags_perso = list(set(tags_personnalises.keys()))
## 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()))
noms_tags_auto = ["but"] + noms_tags_comp
self.tags = noms_tags_perso + noms_tags_auto
"""Tags du semestre taggué"""
## Vérifie l'unicité des tags
if len(set(self.tags)) != len(self.tags):
intersection = list(set(noms_tags_perso) & set(noms_tags_auto))
liste_intersection = "\n".join(
[f"<li><code>{tag}</code></li>" for tag in intersection]
)
s = "s" if len(intersection) > 0 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)
# Calcul des moyennes & les classements de chaque étudiant à chaque tag
self.moyennes_tags = {}
for tag in tags_personnalises:
# pe_affichage.pe_print(f" -> Traitement du tag {tag}")
moy_gen_tag = self.compute_moyenne_tag(tag, tags_personnalises)
self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag)
# Ajoute les moyennes générales de BUT pour le semestre considéré
moy_gen_but = self.nt.etud_moy_gen
self.moyennes_tags["but"] = MoyenneTag("but", moy_gen_but)
# Ajoute les moyennes par compétence
for ue_id, competence in dict_ues_competences.items():
if competence not in self.moyennes_tags:
moy_ue = self.nt.etud_moy_ue[ue_id]
self.moyennes_tags[competence] = MoyenneTag(competence, moy_ue)
self.tags_sorted = self.get_all_tags()
"""Tags (personnalisés+compétences) par ordre alphabétique"""
# Synthétise l'ensemble des moyennes dans un dataframe
self.notes = self.df_notes()
"""Dataframe synthétique des notes par tag"""
pe_affichage.pe_print(
f" => Traitement des tags {', '.join(self.tags_sorted)}"
)
def get_repr(self):
"""Nom affiché pour le semestre taggué"""
return app.pe.pe_etudiant.nom_semestre_etape(self.formsemestre, avec_fid=True)
def compute_moyenne_tag(self, tag: str, tags_infos: dict) -> pd.Series:
"""Calcule la moyenne des étudiants pour le tag indiqué,
pour ce SemestreTag, en ayant connaissance des informations sur
les tags (dictionnaire donnant les coeff de repondération)
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.
Returns:
La série des moyennes
"""
# 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 tags_infos[tag]:
modimpls_mask[i] = False
# Applique la pondération des coefficients
modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy()
for modimpl_id in tags_infos[tag]:
ponderation = tags_infos[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,
)
# 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 = comp.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_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
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_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
aucoeff et pondération fournie avec le tag (par défaut 1 si non indiquée)).
Args:
formsemestre: Le formsemestre à la base de la recherche des tags
Return:
Un dictionnaire de 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
def get_noms_competences_from_ues(formsemestre: FormSemestre) -> dict[int, str]:
"""Partant d'un formsemestre, extrait le nom des compétences associés
à (ou aux) parcours des étudiants du formsemestre.
Ignore les UEs non associées à un niveau de compétence.
Args:
formsemestre: Un FormSemestre
Returns:
Dictionnaire {ue_id: nom_competence} lisant tous les noms des compétences
en les raccrochant à leur ue
"""
# Les résultats du semestre
nt = load_formsemestre_results(formsemestre)
noms_competences = {}
for ue in nt.ues:
if ue.niveau_competence and ue.type != UE_SPORT:
# ?? inutilisé ordre = ue.niveau_competence.ordre
nom = ue.niveau_competence.competence.titre
noms_competences[ue.ue_id] = f"comp. {nom}"
return noms_competences

View File

@ -1,324 +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.pe.pe_tools import pe_print, PE_DEBUG
from app.pe import pe_tagtable
class SetTag(pe_tagtable.TableTag):
"""Agrège plusieurs semestres (ou settag) taggués (SemestreTag/Settag de 1 à 4) pour extraire des moyennes
et des classements par tag pour un groupe d'étudiants donnés.
par. exemple fusion d'un parcours ['S1', 'S2', 'S3'] donnant un nom_combinaison = '3S'
Le settag est identifié sur la base du dernier semestre (ici le 'S3') ;
les étudiants considérés sont donc ceux inscrits dans ce S3
à condition qu'ils disposent d'un parcours sur tous les semestres fusionnés valides (par. ex
un etudiant non inscrit dans un S1 mais dans un S2 et un S3 n'est pas pris en compte).
"""
# -------------------------------------------------------------------------------------------------------------------
def __init__(self, nom_combinaison, parcours):
pe_tagtable.TableTag.__init__(self, nom=nom_combinaison)
self.combinaison = nom_combinaison
self.parcours = parcours # Le groupe de semestres/parcours à aggréger
# -------------------------------------------------------------------------------------------
def set_Etudiants(
self, etudiants: list[dict], juryPEDict, etudInfoDict, nom_sem_final=None
):
"""Détermine la liste des étudiants à prendre en compte, en partant de
la liste en paramètre et en vérifiant qu'ils ont tous un parcours valide."""
if nom_sem_final:
self.nom += "_" + nom_sem_final
for etudid in etudiants:
parcours_incomplet = (
sum([juryPEDict[etudid][nom_sem] is None for nom_sem in self.parcours])
> 0
) # manque-t-il des formsemestre_id validant aka l'étudiant n'a pas été inscrit dans tous les semestres de l'aggrégat
if not parcours_incomplet:
self.inscrlist.append(etudInfoDict[etudid])
self.identdict[etudid] = etudInfoDict[etudid]
delta = len(etudiants) - len(self.inscrlist)
if delta > 0:
pe_print(self.nom + " -> " + str(delta) + " étudiants supprimés")
# Le sous-ensemble des parcours
self.parcoursDict = {etudid: juryPEDict[etudid] for etudid in self.identdict}
# -------------------------------------------------------------------------------------------
def get_Fids_in_settag(self):
"""Renvoie la liste des semestres (leur formsemestre_id) à prendre en compte
pour le calcul des moyennes, en considérant tous les étudiants inscrits et
tous les semestres de leur parcours"""
return list(
{
self.parcoursDict[etudid][nom_sem]
for etudid in self.identdict
for nom_sem in self.parcours
}
)
# ---------------------------------------------------------------------------------------------
def set_SemTagDict(self, SemTagDict):
"""Mémorise les semtag nécessaires au jury."""
self.SemTagDict = {fid: SemTagDict[fid] for fid in self.get_Fids_in_settag()}
if PE_DEBUG >= 1:
pe_print(" => %d semestres fusionnés" % len(self.SemTagDict))
# -------------------------------------------------------------------------------------------------------------------
def comp_data_settag(self):
"""Calcule tous les données numériques relatives au settag"""
# Attributs relatifs aux tag pour les modules pris en compte
self.taglist = self.do_taglist() # la liste des tags
self.do_tagdict() # le dico descriptif des tags
# if PE_DEBUG >= 1: pe_print(" => Tags = " + ", ".join( self.taglist ))
# Calcul des moyennes de chaque étudiant par tag
reussiteAjoutTag = {"OK": [], "KO": []}
for tag in self.taglist:
moyennes = self.comp_MoyennesSetTag(tag, force=False)
res = self.add_moyennesTag(tag, moyennes) # pas de notes => pas de moyenne
reussiteAjoutTag["OK" if res else "KO"].append(tag)
if len(reussiteAjoutTag["OK"]) > 0 and PE_DEBUG:
pe_print(
" => Fusion de %d tags : " % (len(reussiteAjoutTag["OK"]))
+ ", ".join(reussiteAjoutTag["OK"])
)
if len(reussiteAjoutTag["KO"]) > 0 and PE_DEBUG:
pe_print(
" => %d tags manquants : " % (len(reussiteAjoutTag["KO"]))
+ ", ".join(reussiteAjoutTag["KO"])
)
# -------------------------------------------------------------------------------------------------------------------
def get_etudids(self):
return list(self.identdict.keys())
# -------------------------------------------------------------------------------------------------------------------
def do_taglist(self):
"""Parcourt les tags des semestres taggués et les synthétise sous la forme
d'une liste en supprimant les doublons
"""
ensemble = []
for semtag in self.SemTagDict.values():
ensemble.extend(semtag.get_all_tags())
return sorted(list(set(ensemble)))
# -------------------------------------------------------------------------------------------------------------------
def do_tagdict(self):
"""Synthétise la liste des modules pris en compte dans le calcul d'un tag (pour analyse des résultats)"""
self.tagdict = {}
for semtag in self.SemTagDict.values():
for tag in semtag.get_all_tags():
if tag != "dut":
if tag not in self.tagdict:
self.tagdict[tag] = {}
for mod in semtag.tagdict[tag]:
self.tagdict[tag][mod] = semtag.tagdict[tag][mod]
# -------------------------------------------------------------------------------------------------------------------
def get_NotesEtCoeffsSetTagEtudiant(self, tag, etudid):
"""Récupère tous les notes et les coeffs d'un étudiant relatives à un tag dans ses semestres valides et les renvoie dans un tuple (notes, coeffs)
avec notes et coeffs deux listes"""
lesSemsDeLEtudiant = [
self.parcoursDict[etudid][nom_sem] for nom_sem in self.parcours
] # peuvent être None
notes = [
self.SemTagDict[fid].get_moy_from_resultats(tag, etudid)
for fid in lesSemsDeLEtudiant
if tag in self.SemTagDict[fid].taglist
] # eventuellement None
coeffs = [
self.SemTagDict[fid].get_coeff_from_resultats(tag, etudid)
for fid in lesSemsDeLEtudiant
if tag in self.SemTagDict[fid].taglist
]
return (notes, coeffs)
# -------------------------------------------------------------------------------------------------------------------
def comp_MoyennesSetTag(self, tag, force=False):
"""Calcule et renvoie les "moyennes" des étudiants à un tag donné, en prenant en compte tous les semestres taggués
de l'aggrégat, et leur coeff Par moyenne, s'entend une note moyenne, la somme des coefficients de pondération
appliqué dans cette moyenne.
Force ou non le calcul de la moyenne lorsque des notes sont manquantes.
Renvoie les informations sous la forme d'une liste [etudid: (moy, somme_coeff_normalisée, rang), ...}
"""
# if tag not in self.get_all_tags() : return None
# Calcule les moyennes
lesMoyennes = []
for (
etudid
) in (
self.get_etudids()
): # Pour tous les étudiants non défaillants du semestre inscrits dans des modules relatifs au tag
(notes, coeffs_norm) = self.get_NotesEtCoeffsSetTagEtudiant(
tag, etudid
) # lecture des notes associées au tag
(moyenne, somme_coeffs) = pe_tagtable.moyenne_ponderee_terme_a_terme(
notes, coeffs_norm, force=force
)
lesMoyennes += [
(moyenne, somme_coeffs, etudid)
] # Un tuple (pour classement résumant les données)
return lesMoyennes
class SetTagInterClasse(pe_tagtable.TableTag):
"""Récupère les moyennes de SetTag aggrégant un même parcours (par ex un ['S1', 'S2'] n'ayant pas fini au même S2
pour fournir un interclassement sur un groupe d'étudiant => seul compte alors la promo
nom_combinaison = 'S1' ou '1A'
"""
# -------------------------------------------------------------------------------------------------------------------
def __init__(self, nom_combinaison, diplome):
pe_tagtable.TableTag.__init__(self, nom=f"{nom_combinaison}_{diplome or ''}")
self.combinaison = nom_combinaison
self.parcoursDict = {}
# -------------------------------------------------------------------------------------------
def set_Etudiants(self, etudiants, juryPEDict, etudInfoDict, nom_sem_final=None):
"""Détermine la liste des étudiants à prendre en compte, en partant de
la liste fournie en paramètre et en vérifiant que l'étudiant dispose bien d'un parcours valide pour la combinaison demandée.
Renvoie le nombre d'étudiants effectivement inscrits."""
if nom_sem_final:
self.nom += "_" + nom_sem_final
for etudid in etudiants:
if juryPEDict[etudid][self.combinaison] != None:
self.inscrlist.append(etudInfoDict[etudid])
self.identdict[etudid] = etudInfoDict[etudid]
self.parcoursDict[etudid] = juryPEDict[etudid]
return len(self.inscrlist)
# -------------------------------------------------------------------------------------------
def get_Fids_in_settag(self):
"""Renvoie la liste des semestres (les formsemestre_id finissant la combinaison par ex. '3S' dont les fid des S3) à prendre en compte
pour les moyennes, en considérant tous les étudiants inscrits"""
return list(
{self.parcoursDict[etudid][self.combinaison] for etudid in self.identdict}
)
# ---------------------------------------------------------------------------------------------
def set_SetTagDict(self, SetTagDict):
"""Mémorise les settag nécessaires au jury."""
self.SetTagDict = {
fid: SetTagDict[fid] for fid in self.get_Fids_in_settag() if fid != None
}
if PE_DEBUG >= 1:
pe_print(" => %d semestres utilisés" % len(self.SetTagDict))
# -------------------------------------------------------------------------------------------------------------------
def comp_data_settag(self):
"""Calcule tous les données numériques relatives au settag"""
# Attributs relatifs aux tag pour les modules pris en compte
self.taglist = self.do_taglist()
# if PE_DEBUG >= 1: pe_print(" => Tags = " + ", ".join( self.taglist ))
# Calcul des moyennes de chaque étudiant par tag
reussiteAjoutTag = {"OK": [], "KO": []}
for tag in self.taglist:
moyennes = self.get_MoyennesSetTag(tag, force=False)
res = self.add_moyennesTag(tag, moyennes) # pas de notes => pas de moyenne
reussiteAjoutTag["OK" if res else "KO"].append(tag)
if len(reussiteAjoutTag["OK"]) > 0 and PE_DEBUG:
pe_print(
" => Interclassement de %d tags : " % (len(reussiteAjoutTag["OK"]))
+ ", ".join(reussiteAjoutTag["OK"])
)
if len(reussiteAjoutTag["KO"]) > 0 and PE_DEBUG:
pe_print(
" => %d tags manquants : " % (len(reussiteAjoutTag["KO"]))
+ ", ".join(reussiteAjoutTag["KO"])
)
# -------------------------------------------------------------------------------------------------------------------
def get_etudids(self):
return list(self.identdict.keys())
# -------------------------------------------------------------------------------------------------------------------
def do_taglist(self):
"""Parcourt les tags des semestres taggués et les synthétise sous la forme
d'une liste en supprimant les doublons
"""
ensemble = []
for settag in self.SetTagDict.values():
ensemble.extend(settag.get_all_tags())
return sorted(list(set(ensemble)))
# -------------------------------------------------------------------------------------------------------------------
def get_NotesEtCoeffsSetTagEtudiant(self, tag, etudid):
"""Récupère tous les notes et les coeffs d'un étudiant relatives à un tag dans ses semestres valides et les renvoie dans un tuple (notes, coeffs)
avec notes et coeffs deux listes"""
leSetTagDeLetudiant = self.parcoursDict[etudid][self.combinaison]
note = self.SetTagDict[leSetTagDeLetudiant].get_moy_from_resultats(tag, etudid)
coeff = self.SetTagDict[leSetTagDeLetudiant].get_coeff_from_resultats(
tag, etudid
)
return (note, coeff)
# -------------------------------------------------------------------------------------------------------------------
def get_MoyennesSetTag(self, tag, force=False):
"""Renvoie les "moyennes" des étudiants à un tag donné, en prenant en compte tous les settag de l'aggrégat,
et leur coeff Par moyenne, s'entend une note moyenne, la somme des coefficients de pondération
appliqué dans cette moyenne.
Force ou non le calcul de la moyenne lorsque des notes sont manquantes.
Renvoie les informations sous la forme d'une liste [etudid: (moy, somme_coeff_normalisée, rang), ...}
"""
# if tag not in self.get_all_tags() : return None
# Calcule les moyennes
lesMoyennes = []
for (
etudid
) in (
self.get_etudids()
): # Pour tous les étudiants non défaillants du semestre inscrits dans des modules relatifs au tag
(moyenne, somme_coeffs) = self.get_NotesEtCoeffsSetTagEtudiant(
tag, etudid
) # lecture des notes associées au tag
lesMoyennes += [
(moyenne, somme_coeffs, etudid)
] # Un tuple (pour classement résumant les données)
return lesMoyennes

263
app/pe/pe_tabletags.py Normal file
View File

@ -0,0 +1,263 @@
# -*- 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 import ScoValueError
from app.comp.moy_sem import comp_ranks_series
from app.pe import pe_affichage
from app.pe.pe_affichage import SANS_NOTE
from app.scodoc import sco_utils as scu
import pandas as pd
TAGS_RESERVES = ["but"]
class MoyenneTag:
def __init__(self, tag: str, notes: pd.Series):
"""Classe centralisant la synthèse des moyennes/classements d'une série
d'étudiants à un tag donné, en stockant un dictionnaire :
``
{
"notes": la Serie pandas des notes (float),
"classements": la Serie pandas des classements (float),
"min": la note minimum,
"max": la note maximum,
"moy": la moyenne,
"nb_inscrits": le nombre d'étudiants ayant une note,
}
``
Args:
tag: Un tag
note: Une série de notes (moyenne) sous forme d'un pd.Series()
"""
self.tag = tag
"""Le tag associé à la moyenne"""
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 moyenne est non nulle"""
self.df: pd.DataFrame = self.comp_moy_et_stat(notes)
"""Le dataframe retraçant les moyennes/classements/statistiques"""
self.synthese = self.to_dict()
"""La synthèse (dictionnaire) des notes/classements/statistiques"""
def __eq__(self, other):
"""Egalité de deux MoyenneTag lorsque leur tag sont identiques"""
return self.tag == other.tag
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 (souvent une moyenne par 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=[
"note",
"classement",
"rang",
"min",
"max",
"moy",
"nb_etuds",
"nb_inscrits",
],
)
# 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.loc[self.inscrits_ids, "nb_inscrits"] = len(self.inscrits_ids)
# Le classement des inscrits
notes_non_nulles = notes[self.inscrits_ids]
(class_str, class_int) = comp_ranks_series(notes_non_nulles)
df.loc[self.inscrits_ids, "classement"] = class_int
# Le rang (classement/nb_inscrit)
df["rang"] = df["rang"].astype(str)
df.loc[self.inscrits_ids, "rang"] = (
df.loc[self.inscrits_ids, "classement"].astype(int).astype(str)
+ "/"
+ df.loc[self.inscrits_ids, "nb_inscrits"].astype(int).astype(str)
)
# Les stat (des inscrits)
df.loc[self.inscrits_ids, "min"] = notes.min()
df.loc[self.inscrits_ids, "max"] = notes.max()
df.loc[self.inscrits_ids, "moy"] = notes.mean()
return df
def to_dict(self) -> dict:
"""Renvoie un dictionnaire de synthèse des moyennes/classements/statistiques"""
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 get_notes(self):
"""Série des notes, arrondies à 2 chiffres après la virgule"""
return self.df["note"].round(2)
def get_rangs_inscrits(self) -> pd.Series:
"""Série des rangs classement/nbre_inscrit"""
return self.df["rang"]
def get_min(self) -> pd.Series:
"""Série des min"""
return self.df["min"].round(2)
def get_max(self) -> pd.Series:
"""Série des max"""
return self.df["max"].round(2)
def get_moy(self) -> pd.Series:
"""Série des moy"""
return self.df["moy"].round(2)
def get_note_for_df(self, etudid: int):
"""Note d'un étudiant donné par son etudid"""
return round(self.df["note"].loc[etudid], 2)
def get_min_for_df(self) -> float:
"""Min renseigné pour affichage dans un df"""
return round(self.synthese["min"], 2)
def get_max_for_df(self) -> float:
"""Max renseigné pour affichage dans un df"""
return round(self.synthese["max"], 2)
def get_moy_for_df(self) -> float:
"""Moyenne renseignée pour affichage dans un df"""
return round(self.synthese["moy"], 2)
def get_class_for_df(self, etudid: int) -> str:
"""Classement ramené au nombre d'inscrits,
pour un étudiant donné par son etudid"""
classement = self.df["rang"].loc[etudid]
if not pd.isna(classement):
return classement
else:
return pe_affichage.SANS_NOTE
def is_significatif(self) -> bool:
"""Indique si la moyenne est significative (c'est-à-dire à des notes)"""
return self.synthese["nb_inscrits"] > 0
class TableTag(object):
def __init__(self):
"""Classe centralisant différentes méthodes communes aux
SemestreTag, TrajectoireTag, AggregatInterclassTag
"""
pass
# -----------------------------------------------------------------------------------------------------------
def get_all_tags(self):
"""Liste des tags de la table, triée par ordre alphabétique,
extraite des clés du dictionnaire ``moyennes_tags`` connues (tags en doublon
possible).
Returns:
Liste de tags triés par ordre alphabétique
"""
return sorted(list(self.moyennes_tags.keys()))
def df_moyennes_et_classements(self) -> pd.DataFrame:
"""Renvoie un dataframe listant toutes les moyennes,
et les classements des étudiants pour tous les tags.
Est utilisé pour afficher le détail d'un tableau taggué
(semestres, trajectoires ou aggrégat)
Returns:
Le dataframe des notes et des classements
"""
etudiants = self.etudiants
df = pd.DataFrame.from_dict(etudiants, orient="index", columns=["nom"])
tags_tries = self.get_all_tags()
for tag in tags_tries:
moy_tag = self.moyennes_tags[tag]
df = df.join(moy_tag.synthese["notes"].rename(f"Moy {tag}"))
df = df.join(moy_tag.synthese["classements"].rename(f"Class {tag}"))
return df
def df_notes(self) -> pd.DataFrame | None:
"""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_tries = self.get_all_tags()
if tags_tries:
dict_series = {}
for tag in tags_tries:
# Les moyennes associés au tag
moy_tag = self.moyennes_tags[tag]
dict_series[tag] = moy_tag.synthese["notes"]
df = pd.DataFrame(dict_series)
return df

View File

@ -1,348 +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
class TableTag(object):
"""
Classe mémorisant les moyennes des étudiants à différents tag et permettant de calculer les rangs et les statistiques :
- 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 stastitiques (moyenne, min, max) des résultats par tag de la forme :
{ tag : (moy, min, max), ...}
"""
def __init__(self, nom=""):
self.nom = nom
self.inscrlist = []
self.identdict = {}
self.taglist = []
self.resultats = {}
self.rangs = {}
self.statistiques = {}
# *****************************************************************************************************************
# 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.resultats[tag][etudid][0]
if tag in self.resultats and etudid in self.resultats[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.resultats and etudid in self.resultats[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.resultats[tag][etudid][1]
if tag in self.resultats and etudid in self.resultats[tag]
else None
)
# -----------------------------------------------------------------------------------------------------------
def get_all_tags(self):
"""Renvoie la liste des tags du semestre triée par ordre alphabétique"""
# return self.taglist
return sorted(self.resultats.keys())
# -----------------------------------------------------------------------------------------------------------
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) ]
"""
# ajout des moyennes au dictionnaire résultat
if listMoyEtCoeff:
self.resultats[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.resultats:
return stats
notes = [
self.get_moy_from_resultats(tag, etudid) for etudid in self.resultats[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.resultats[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 str_tagtable(self, delim=";", decimal_sep=","):
"""Renvoie une chaine de caractère listant toutes les moyennes, les rangs des étudiants pour tous les tags."""
entete = ["etudid", "nom", "prenom"]
for tag in self.get_all_tags():
entete += [titre + "_" + tag for titre in ["note", "rang", "nb_inscrit"]]
chaine = delim.join(entete) + "\n"
for etudid in self.identdict:
descr = delim.join(
[
etudid,
self.identdict[etudid]["nom"],
self.identdict[etudid]["prenom"],
]
)
descr += delim + self.str_res_d_un_etudiant(etudid, delim)
chaine += descr + "\n"
# Ajout des stats ... à faire
if decimal_sep != ".":
return chaine.replace(".", decimal_sep)
else:
return chaine
# ************************************************************************
# 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

View File

@ -1,960 +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 os
import datetime
import re
import unicodedata
from flask import g
import app.scodoc.sco_utils as scu
from app import log
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"
# /opt/scodoc/tools/doc_poursuites_etudes
REP_DEFAULT_AVIS = os.path.join(scu.SCO_TOOLS_DIR, "doc_poursuites_etudes/")
REP_LOCAL_AVIS = os.path.join(scu.SCODOC_CFG_DIR, "doc_poursuites_etudes/")
PE_DEFAULT_AVIS_LATEX_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_avis.tex"
PE_LOCAL_AVIS_LATEX_TMPL = REP_LOCAL_AVIS + "local/modeles/un_avis.tex"
PE_DEFAULT_FOOTER_TMPL = REP_DEFAULT_AVIS + "distrib/modeles/un_footer.tex"
PE_LOCAL_FOOTER_TMPL = REP_LOCAL_AVIS + "local/modeles/un_footer.tex"
# ----------------------------------------------------------------------------------------
# ----------------------------------------------------------------------------------------
def print_semestres_description(sems, avec_affichage_debug=False):
"""Dediee a l'affichage d'un semestre pour debug du module"""
def chaine_semestre(sem):
desc = (
"S"
+ str(sem["semestre_id"])
+ " "
+ sem["modalite"]
+ " "
+ sem["anneescolaire"]
)
desc += " (" + sem["annee_debut"] + "/" + sem["annee_fin"] + ") "
desc += str(sem["formation_id"]) + " / " + str(sem["formsemestre_id"])
desc += " - " + sem["titre_num"]
return desc
if avec_affichage_debug == True:
if isinstance(sems, list):
for sem in sems:
pe_print(chaine_semestre(sem))
else:
pe_print(chaine_semestre(sems))
# ----------------------------------------------------------------------------------------
def calcul_age(born):
"""Calcule l'age à partir de la date de naissance sous forme d'une chaine de caractère 'jj/mm/aaaa'.
Aucun test de validité sur le format de la date n'est fait.
"""
if not isinstance(born, str) or born == "":
return ""
donnees = born.split("/")
naissance = datetime.datetime(int(donnees[2]), int(donnees[1]), int(donnees[0]))
today = datetime.date.today()
return (
today.year
- naissance.year
- ((today.month, today.day) < (naissance.month, naissance.day))
)
# ----------------------------------------------------------------------------------------
def remove_accents(input_unicode_str):
"""Supprime les accents d'une chaine unicode"""
nfkd_form = unicodedata.normalize("NFKD", input_unicode_str)
only_ascii = nfkd_form.encode("ASCII", "ignore")
return only_ascii
def escape_for_latex(s):
"""Protège les caractères pour inclusion dans du source LaTeX"""
if not s:
return ""
conv = {
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\^{}",
"\\": r"\textbackslash{}",
"<": r"\textless ",
">": r"\textgreater ",
}
exp = re.compile(
"|".join(
re.escape(key)
for key in sorted(list(conv.keys()), key=lambda item: -len(item))
)
)
return exp.sub(lambda match: conv[match.group()], s)
# ----------------------------------------------------------------------------------------
def list_directory_filenames(path):
"""List of regular filenames in a directory (recursive)
Excludes files and directories begining with .
"""
R = []
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
def add_local_file_to_zip(zipfile, ziproot, pathname, path_in_zip):
"""Read pathname server file and add content to zip under path_in_zip"""
rooted_path_in_zip = os.path.join(ziproot, path_in_zip)
zipfile.write(filename=pathname, arcname=rooted_path_in_zip)
# data = open(pathname).read()
# zipfile.writestr(rooted_path_in_zip, data)
def add_refs_to_register(register, directory):
"""Ajoute les fichiers trouvés dans directory au registre (dictionaire) sous la forme
filename => pathname
"""
length = len(directory)
for pathname in list_directory_filenames(directory):
filename = pathname[length + 1 :]
register[filename] = pathname
def add_pe_stuff_to_zip(zipfile, ziproot):
"""Add auxiliary files to (already opened) zip
Put all local files found under config/doc_poursuites_etudes/local
and config/doc_poursuites_etudes/distrib
If a file is present in both subtrees, take the one in local.
Also copy logos
"""
register = {}
# first add standard (distrib references)
distrib_dir = os.path.join(REP_DEFAULT_AVIS, "distrib")
add_refs_to_register(register=register, directory=distrib_dir)
# then add local references (some oh them may overwrite distrib refs)
local_dir = os.path.join(REP_LOCAL_AVIS, "local")
add_refs_to_register(register=register, directory=local_dir)
# at this point register contains all refs (filename, pathname) to be saved
for filename, pathname in register.items():
add_local_file_to_zip(zipfile, ziproot, pathname, "avis/" + filename)
# Logos: (add to logos/ directory in zip)
logos_names = ["header", "footer"]
for name in logos_names:
logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id)
if logo is not None:
add_local_file_to_zip(
zipfile, ziproot, logo.filepath, "avis/logos/" + logo.filename
)
# ----------------------------------------------------------------------------------------
# Variable pour le debug des avislatex (en squeezant le calcul du jury souvent long)
JURY_SYNTHESE_POUR_DEBUG = {
"EID1810": {
"nom": "ROUX",
"entree": "2016",
"civilite_str": "M.",
"promo": 2016,
"S2": {
"groupe": {
"informatique": (
13.184230769230767,
0.21666666666666667,
"18",
78,
9.731491508491509,
18.46846153846154,
18.46846153846154,
),
"technique": (
12.975409073359078,
0.6166666666666666,
"16",
78,
9.948540264387688,
18.29285714285714,
18.29285714285714,
),
"pe": (
12.016584900684544,
1.116666666666667,
"20",
78,
9.83147528118408,
17.691755169172936,
17.691755169172936,
),
"mathematiques": (
12.25,
0.1,
"15 ex",
78,
8.45153073717949,
19.0625,
19.0625,
),
"dut": (
12.43750128724589,
1.0,
"19",
78,
10.151630181286441,
17.881104750512645,
17.881104750512645,
),
},
"promo": {
"informatique": (
13.184230769230767,
0.21666666666666667,
"25",
73,
11.696187214611871,
18.51346153846154,
18.51346153846154,
),
"technique": (
12.975409073359078,
0.6166666666666666,
"23",
73,
11.862307379173147,
17.616047267953675,
17.616047267953675,
),
"pe": (
12.016584900684544,
1.116666666666667,
"28",
73,
11.571004424603757,
16.706338951857248,
16.706338951857248,
),
"mathematiques": (
12.25,
0.1,
"18 ex",
73,
10.00886454908676,
19.0625,
19.0625,
),
"dut": (
12.43750128724589,
1.0,
"25",
73,
11.88798432763965,
17.397627309377608,
17.397627309377608,
),
},
},
"S1": {
"groupe": {
"informatique": (
16.064999999999998,
0.16666666666666669,
"11",
82,
11.020296296296294,
19.325999999999997,
19.325999999999997,
),
"technique": (
14.513007894736845,
0.6333333333333333,
"11",
82,
11.195082967479676,
18.309764912280702,
18.309764912280702,
),
"pe": (
13.260301515151516,
1.1,
"19",
82,
10.976036277232245,
17.7460505050505,
17.7460505050505,
),
"mathematiques": (
11.142850000000001,
0.13333333333333333,
"34",
82,
10.314605121951217,
19.75,
19.75,
),
"dut": (
13.54367375,
1.0,
"19",
82,
11.22193801880508,
18.226902529333334,
18.226902529333334,
),
},
"promo": {
"informatique": (
16.064999999999998,
0.16666666666666669,
"15",
73,
13.265276712328768,
19.325999999999997,
19.325999999999997,
),
"technique": (
14.513007894736845,
0.6333333333333333,
"16",
73,
12.996048795361693,
18.309764912280702,
18.309764912280702,
),
"pe": (
13.260301515151516,
1.1,
"25",
73,
12.4107195879539,
17.7460505050505,
17.7460505050505,
),
"mathematiques": (
11.142850000000001,
0.13333333333333333,
"39",
73,
11.320606952054794,
19.75,
19.75,
),
"dut": (
13.54367375,
1.0,
"25",
73,
12.730581289342638,
18.226902529333334,
18.226902529333334,
),
},
},
"4S": {
"groupe": {
"informatique": (
14.84359375,
0.5333333333333333,
"2",
19,
10.69933552631579,
18.28646875,
18.28646875,
),
"pe": (
12.93828572598162,
3.75,
"4",
19,
11.861967145815218,
15.737718967605682,
15.737718967605682,
),
"mathematiques": (None, None, "1 ex", 19, None, None, None),
"ptut": (None, None, "1 ex", 19, None, None, None),
"dut": (
13.511767410105122,
4.0,
"4",
19,
12.573349864933606,
15.781651391587998,
15.781651391587998,
),
},
"promo": {
"informatique": (
16.075,
0.1,
"4",
73,
10.316541095890413,
19.333333333333336,
19.333333333333336,
),
"pe": (
13.52416666666667,
0.49999999999999994,
"13",
73,
11.657102668465479,
16.853208080808084,
16.853208080808084,
),
"mathematiques": (
None,
None,
"55 ex",
73,
7.705091805555555,
19.8,
19.8,
),
"dut": (
14.425416666666665,
1.0,
"12",
73,
13.188168241098825,
16.612613522048612,
16.612613522048612,
),
},
},
"S4": {
"groupe": {
"informatique": (
16.075,
0.1,
"1",
19,
8.799078947368422,
16.075,
16.075,
),
"technique": (
13.835576923076923,
0.4333333333333333,
"4",
19,
12.238304655870447,
16.521153846153847,
16.521153846153847,
),
"pe": (
13.52416666666667,
0.49999999999999994,
"4",
19,
12.292846491228072,
16.25833333333334,
16.25833333333334,
),
"dut": (
14.425416666666665,
1.0,
"6",
19,
13.628367861842106,
15.267566666666665,
15.267566666666665,
),
},
"promo": {
"informatique": (
16.075,
0.1,
"4",
73,
10.316541095890413,
19.333333333333336,
19.333333333333336,
),
"pe": (
13.52416666666667,
0.49999999999999994,
"13",
73,
11.657102668465479,
16.853208080808084,
16.853208080808084,
),
"technique": (
13.835576923076923,
0.4333333333333333,
"11",
73,
12.086685508009952,
17.25909420289855,
17.25909420289855,
),
"mathematiques": (
None,
None,
"55 ex",
73,
7.705091805555555,
19.8,
19.8,
),
"ptut": (
13.5,
0.13333333333333333,
"50",
73,
13.898173515981734,
17.083333333333332,
17.083333333333332,
),
"dut": (
14.425416666666665,
1.0,
"12",
73,
13.188168241098825,
16.612613522048612,
16.612613522048612,
),
},
},
"1A": {
"groupe": {
"informatique": (
14.43673913043478,
0.38333333333333336,
"16",
78,
11.046040002787066,
18.85992173913043,
18.85992173913043,
),
"technique": (
13.754459142857144,
1.25,
"14",
78,
11.179785631638866,
18.493250340136054,
18.493250340136054,
),
"pe": (
12.633767581547854,
2.216666666666667,
"18",
78,
10.912253971396854,
18.39547581699347,
18.39547581699347,
),
"mathematiques": (
11.617342857142857,
0.23333333333333334,
"24",
78,
9.921286855287565,
19.375000000000004,
19.375000000000004,
),
"dut": (
12.990587518622945,
2.0,
"18",
78,
11.2117147027821,
18.391345156695156,
18.391345156695156,
),
},
"promo": {
"informatique": (
13.184230769230767,
0.21666666666666667,
"25",
73,
11.696187214611871,
18.51346153846154,
18.51346153846154,
),
"technique": (
12.975409073359078,
0.6166666666666666,
"23",
73,
11.862307379173147,
17.616047267953675,
17.616047267953675,
),
"pe": (
12.016584900684544,
1.116666666666667,
"28",
73,
11.571004424603757,
16.706338951857248,
16.706338951857248,
),
"mathematiques": (
12.25,
0.1,
"18 ex",
73,
10.00886454908676,
19.0625,
19.0625,
),
"dut": (
12.43750128724589,
1.0,
"25",
73,
11.88798432763965,
17.397627309377608,
17.397627309377608,
),
},
},
"2A": {
"groupe": {
"informatique": (
15.88333333333333,
0.15000000000000002,
"2",
19,
9.805818713450288,
17.346666666666668,
17.346666666666668,
),
"pe": (
13.378513043478259,
1.5333333333333334,
"6",
19,
12.099566454042717,
16.06209927536232,
16.06209927536232,
),
"technique": (
13.965093333333336,
1.1666666666666665,
"5",
19,
12.51068332957394,
16.472092380952386,
16.472092380952386,
),
"mathematiques": (None, None, "1 ex", 19, None, None, None),
"dut": (
14.032947301587301,
2.0,
"4",
19,
13.043386086541773,
15.574706269841268,
15.574706269841268,
),
},
"promo": {
"informatique": (
16.075,
0.1,
"4",
73,
10.316541095890413,
19.333333333333336,
19.333333333333336,
),
"pe": (
13.52416666666667,
0.49999999999999994,
"13",
73,
11.657102668465479,
16.853208080808084,
16.853208080808084,
),
"technique": (
13.835576923076923,
0.4333333333333333,
"11",
73,
12.086685508009952,
17.25909420289855,
17.25909420289855,
),
"mathematiques": (
None,
None,
"55 ex",
73,
7.705091805555555,
19.8,
19.8,
),
"dut": (
14.425416666666665,
1.0,
"12",
73,
13.188168241098825,
16.612613522048612,
16.612613522048612,
),
},
},
"nbSemestres": 4,
"code_nip": "21414563",
"prenom": "Baptiste",
"age": "21",
"lycee": "PONCET",
"3S": {
"groupe": {
"informatique": (
14.559423076923077,
0.43333333333333335,
"3",
19,
11.137856275303646,
18.8095,
18.8095,
),
"pe": (
12.84815019664546,
3.25,
"4",
19,
11.795678015751701,
15.657624449801428,
15.657624449801428,
),
"technique": (
13.860638395358142,
1.9833333333333334,
"3",
19,
12.395950358235925,
17.340302131732695,
17.340302131732695,
),
"mathematiques": (
11.494044444444445,
0.3,
"6",
19,
9.771571754385965,
14.405358333333334,
14.405358333333334,
),
"dut": (
13.207217657917942,
3.0,
"4",
19,
12.221677199297439,
15.953012966561774,
15.953012966561774,
),
},
"promo": {
"informatique": (15.5, 0.05, "13", 73, 10.52222222222222, 20.0, 20.0),
"pe": (
13.308035483870967,
1.0333333333333334,
"17",
73,
11.854843423685786,
16.191317607526884,
16.191317607526884,
),
"technique": (
14.041625757575758,
0.7333333333333333,
"10",
73,
11.929466899200335,
16.6400384469697,
16.6400384469697,
),
"mathematiques": (
11.0625,
0.06666666666666667,
"40",
73,
11.418430205479451,
19.53,
19.53,
),
"dut": (
13.640477936507937,
1.0,
"14",
73,
12.097377866597594,
16.97088994741667,
16.97088994741667,
),
},
},
"bac": "STI2D",
"S3": {
"groupe": {
"informatique": (15.5, 0.05, "5", 19, 12.842105263157896, 20.0, 20.0),
"pe": (
13.308035483870967,
1.0333333333333334,
"8",
19,
12.339608902093943,
15.967147311827956,
15.967147311827956,
),
"technique": (
14.041625757575758,
0.7333333333333333,
"7",
19,
13.128539816586922,
16.44310151515152,
16.44310151515152,
),
"mathematiques": (
11.0625,
0.06666666666666667,
"6",
19,
9.280921052631578,
16.125,
16.125,
),
"dut": (
13.640477936507937,
1.0,
"8",
19,
12.83638061385213,
15.881845873015871,
15.881845873015871,
),
},
"promo": {
"informatique": (15.5, 0.05, "13", 73, 10.52222222222222, 20.0, 20.0),
"pe": (
13.308035483870967,
1.0333333333333334,
"17",
73,
11.854843423685786,
16.191317607526884,
16.191317607526884,
),
"technique": (
14.041625757575758,
0.7333333333333333,
"10",
73,
11.929466899200335,
16.6400384469697,
16.6400384469697,
),
"mathematiques": (
11.0625,
0.06666666666666667,
"40",
73,
11.418430205479451,
19.53,
19.53,
),
"dut": (
13.640477936507937,
1.0,
"14",
73,
12.097377866597594,
16.97088994741667,
16.97088994741667,
),
},
},
"parcours": [
{
"nom_semestre_dans_parcours": "semestre 4 FAP 2016",
"titreannee": "DUT RT UFA (PPN 2013), semestre 4 FAP 2016",
},
{
"nom_semestre_dans_parcours": "semestre 3 FAP 2015-2016",
"titreannee": "DUT RT UFA (PPN 2013), semestre 3 FAP 2015-2016",
},
{
"nom_semestre_dans_parcours": "semestre 2 FI 2015",
"titreannee": "DUT RT, semestre 2 FI 2015",
},
{
"nom_semestre_dans_parcours": "semestre 1 FI 2014-2015",
"titreannee": "DUT RT, semestre 1 FI 2014-2015",
},
],
}
}

View File

@ -35,148 +35,70 @@
"""
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.models import FormSemestre
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.scodoc import sco_formsemestre
from app.scodoc import html_sco_header
from app.scodoc import sco_preferences
from app.pe import pe_tools
from app.pe import pe_jurype
from app.pe import pe_avislatex
from app.views import notes_bp as bp
def _pe_view_sem_recap_form(formsemestre_id):
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</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
</a>.
</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"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if not formsemestre.formation.is_apc():
raise ScoValueError(
"""Le module de Poursuites d'Etudes
n'est disponible que pour des formations BUT"""
)
if formsemestre.formation.get_cursus().NB_SEM < 6:
raise ScoValueError(
"""Le module de Poursuites d'Etudes n'est pas prévu
pour une formation de moins de 6 semestres"""
)
# L'année du diplome
annee_diplome = pe_comp.get_annee_diplome_semestre(formsemestre)
# Cosemestres diplomants
cosemestres = pe_comp.get_cosemestres_diplomants(annee_diplome)
if request.method == "GET":
return _pe_view_sem_recap_form(formsemestre_id)
prefs = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id)
semBase = sco_formsemestre.get_formsemestre(formsemestre_id)
jury = pe_jurype.JuryPE(semBase)
# Ajout avis LaTeX au même zip:
etudids = list(jury.syntheseJury.keys())
# Récupération du template latex, du footer latex et du tag identifiant les annotations relatives aux PE
# (chaines unicodes, html non quoté)
template_latex = ""
# template fourni via le formulaire Web
if avis_tmpl_file:
try:
template_latex = avis_tmpl_file.read().decode("utf-8")
except UnicodeDecodeError as e:
raise ScoValueError(
"Données (template) invalides (caractères non UTF8 ?)"
) from e
else:
# template indiqué dans préférences ScoDoc ?
template_latex = pe_avislatex.get_code_latex_from_scodoc_preference(
formsemestre_id, champ="pe_avis_latex_tmpl"
return render_template(
"pe/pe_view_sem_recap.j2",
annee_diplome=annee_diplome,
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
cosemestres=cosemestres,
)
template_latex = template_latex.strip()
if not template_latex:
# pas de preference pour le template: utilise fichier du serveur
template_latex = pe_avislatex.get_templates_from_distrib("avis")
# Footer:
footer_latex = ""
# template fourni via le formulaire Web
if footer_tmpl_file:
footer_latex = footer_tmpl_file.read().decode("utf-8")
else:
footer_latex = pe_avislatex.get_code_latex_from_scodoc_preference(
formsemestre_id, champ="pe_avis_latex_footer"
)
footer_latex = footer_latex.strip()
if not footer_latex:
# pas de preference pour le footer: utilise fichier du serveur
footer_latex = pe_avislatex.get_templates_from_distrib(
"footer"
) # fallback: footer vides
tag_annotation_pe = pe_avislatex.get_code_latex_from_scodoc_preference(
formsemestre_id, champ="pe_tag_annotation_avis_latex"
)
# Ajout des annotations PE dans un fichier excel
sT = pe_avislatex.table_syntheseAnnotationPE(jury.syntheseJury, tag_annotation_pe)
if sT:
jury.add_file_to_zip(
jury.NOM_EXPORT_ZIP + "_annotationsPE" + scu.XLSX_SUFFIX, sT.excel()
# request.method == "POST"
jury = pe_jury.JuryPE(annee_diplome)
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,
)
)
latex_pages = {} # Dictionnaire de la forme nom_fichier => contenu_latex
for etudid in etudids:
[nom_fichier, contenu_latex] = pe_avislatex.get_avis_poursuite_par_etudiant(
jury,
etudid,
template_latex,
tag_annotation_pe,
footer_latex,
prefs,
)
jury.add_file_to_zip("avis/" + nom_fichier + ".tex", contenu_latex)
latex_pages[nom_fichier] = contenu_latex # Sauvegarde dans un dico
# Nouvelle version : 1 fichier par étudiant avec 1 fichier appelant créée ci-dessous
doc_latex = "\n% -----\n".join(
["\\include{" + nom + "}" for nom in sorted(latex_pages.keys())]
)
jury.add_file_to_zip("avis/avis_poursuite.tex", doc_latex)
# Ajoute image, LaTeX class file(s) and modeles
pe_tools.add_pe_stuff_to_zip(jury.zipfile, jury.NOM_EXPORT_ZIP)
data = jury.get_zipped_data()
return send_file(
data,
mimetype="application/zip",
download_name=scu.sanitize_filename(jury.NOM_EXPORT_ZIP + ".zip"),
download_name=scu.sanitize_filename(jury.nom_export_zip + ".zip"),
as_attachment=True,
)

View File

@ -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:
@ -685,6 +685,11 @@ class TF(object):
'<input type="text" name="%s" size="10" value="%s" class="datepicker">'
% (field, values[field])
)
elif input_type == "time": # JavaScript widget for date input
lem.append(
f"""<input type="text" name="{field}" maxlength="5" size="5" value="{
values[field]}" class="timepicker">"""
)
elif input_type == "text_suggest":
lem.append(
'<input type="text" name="%s" id="%s" size="%d" %s'

View File

@ -145,7 +145,9 @@ def sco_header(
etudid=None,
formsemestre_id=None,
):
"Main HTML page header for ScoDoc"
"""Main HTML page header for ScoDoc
Utilisé dans les anciennes pages. Les nouvelles pages utilisent le template Jinja.
"""
from app.scodoc.sco_formsemestre_status import formsemestre_page_title
if etudid is not None:
@ -189,7 +191,12 @@ def sco_header(
# jQuery UI
# can modify loaded theme here
H.append(
f'<link type="text/css" rel="stylesheet" href="{scu.STATIC_DIR}/libjs/jquery-ui-1.10.4.custom/css/smoothness/jquery-ui-1.10.4.custom.min.css" />\n'
f"""
<link type="text/css" rel="stylesheet"
href="{scu.STATIC_DIR}/libjs/jquery-ui-1.10.4.custom/css/smoothness/jquery-ui-1.10.4.custom.min.css" />
<link type="text/css" rel="stylesheet"
href="{scu.STATIC_DIR}/libjs/timepicker-1.3.5/jquery.timepicker.min.css" />
"""
)
if init_google_maps:
# It may be necessary to add an API key:
@ -219,19 +226,26 @@ def sco_header(
# jQuery
H.append(
f"""<script src="{scu.STATIC_DIR}/jQuery/jquery.js"></script>
<script src="{scu.STATIC_DIR}/libjs/jquery.field.min.js"></script>"""
f"""
<script src="{scu.STATIC_DIR}/jQuery/jquery.js"></script>
<script src="{scu.STATIC_DIR}/libjs/jquery.field.min.js"></script>
"""
)
# qTip
if init_qtip:
H.append(
f"""<script src="{scu.STATIC_DIR}/libjs/qtip/jquery.qtip-3.0.3.min.js"></script>
<link type="text/css" rel="stylesheet" href="{scu.STATIC_DIR}/libjs/qtip/jquery.qtip-3.0.3.min.css" />"""
<link type="text/css" rel="stylesheet"
href="{scu.STATIC_DIR}/libjs/qtip/jquery.qtip-3.0.3.min.css" />
"""
)
H.append(
f"""<script src="{scu.STATIC_DIR}/libjs/jquery-ui-1.10.4.custom/js/jquery-ui-1.10.4.custom.min.js"></script>
<script src="{scu.STATIC_DIR}/js/scodoc.js"></script>"""
f"""<script
src="{scu.STATIC_DIR}/libjs/jquery-ui-1.10.4.custom/js/jquery-ui-1.10.4.custom.min.js"></script>
<script src="{scu.STATIC_DIR}/libjs/timepicker-1.3.5/jquery.timepicker.min.js"></script>
<script src="{scu.STATIC_DIR}/js/scodoc.js"></script>
"""
)
if init_google_maps:
H.append(

View File

@ -32,12 +32,68 @@ from flask import render_template, url_for
from flask import g, request
from flask_login import current_user
from app import db
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
from sco_version import SCOVERSION
def retreive_formsemestre_from_request() -> int:
"""Cherche si on a de quoi déduire le semestre affiché à partir des
arguments de la requête:
formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id
Returns None si pas défini.
"""
if request.method == "GET":
args = request.args
elif request.method == "POST":
args = request.form
else:
return None
formsemestre_id = None
# Search formsemestre
group_ids = args.get("group_ids", [])
if "formsemestre_id" in args:
formsemestre_id = args["formsemestre_id"]
elif "moduleimpl_id" in args and args["moduleimpl_id"]:
modimpl = db.session.get(ModuleImpl, args["moduleimpl_id"])
if not modimpl:
return None # suppressed ?
formsemestre_id = modimpl.formsemestre_id
elif "evaluation_id" in args:
evaluation = db.session.get(Evaluation, args["evaluation_id"])
if not evaluation:
return None # evaluation suppressed ?
formsemestre_id = evaluation.moduleimpl.formsemestre_id
elif "group_id" in args:
group = db.session.get(GroupDescr, args["group_id"])
if not group:
return None
formsemestre_id = group.partition.formsemestre_id
elif group_ids:
if isinstance(group_ids, str):
group_ids = group_ids.split(",")
group_id = group_ids[0]
group = db.session.get(GroupDescr, group_id)
if not group:
return None
formsemestre_id = group.partition.formsemestre_id
elif "partition_id" in args:
partition = db.session.get(Partition, args["partition_id"])
if not partition:
return None
formsemestre_id = partition.formsemestre_id
if formsemestre_id is None:
return None # no current formsemestre
try:
return int(formsemestre_id)
except ValueError:
return None # no current formsemestre
def sidebar_common():
"partie commune à toutes les sidebar"
home_link = url_for("scodoc.index", scodoc_dept=g.scodoc_dept)
@ -104,46 +160,58 @@ 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.ficheEtud", 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)
inscription = etud.inscription_courante()
if inscription:
formsemestre = inscription.formsemestre
nbabs, nbabsjust = sco_assiduites.formsemestre_get_assiduites_count(
etudid, formsemestre
)
nbabsnj = nbabs - nbabsjust
H.append(
f"""<span title="absences du { cur_sem["date_debut"] } au {
cur_sem["date_fin"] }">({
sco_preferences.get_preference("assi_metrique", None)})
f"""<span title="absences du {
formsemestre.date_debut.strftime("%d/%m/%Y")
} au {
formsemestre.date_fin.strftime("%d/%m/%Y")
}">({
sco_preferences.get_preference("assi_metrique", None)})
<br>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
)
H.append("<ul>")
if current_user.has_permission(Permission.AbsChange):
# essaie de conserver le semestre actuellement en vue
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)
}">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"""

View File

@ -265,7 +265,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 +300,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 +322,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 +353,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 +447,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:

File diff suppressed because it is too large Load Diff

View File

@ -129,7 +129,7 @@ def table_billets(
] = f'id="{billet.etudiant.id}" class="etudinfo"'
if with_links:
billet_dict["_nomprenom_target"] = url_for(
"scolar.ficheEtud",
"scolar.fiche_etud",
scodoc_dept=g.scodoc_dept,
etudid=billet_dict["etudid"],
)

View File

@ -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 current_app, g, url_for
from flask import g, url_for
from flask_mail import Message
from app import db
@ -42,6 +42,7 @@ from app import email
from app import log
from app.auth.models import User
from app.models.absences import AbsenceNotification
from app.models.etudiants import Identite
from app.models.events import Scolog
from app.models.formsemestre import FormSemestre
import app.scodoc.notesdb as ndb
@ -108,7 +109,6 @@ def do_abs_notify(
return # abort
# Vérification fréquence (pour ne pas envoyer de mails trop souvent)
# TODO Mettre la fréquence dans les préférences assiduités
abs_notify_max_freq = sco_preferences.get_preference("abs_notify_max_freq")
destinations_filtered = []
for email_addr in destinations:
@ -175,9 +175,15 @@ def abs_notify_get_destinations(
if prefs["abs_notify_email"]:
destinations.append(prefs["abs_notify_email"])
if prefs["abs_notify_etud"]:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
if etud["email_default"]:
destinations.append(etud["email_default"])
etud = Identite.get_etud(etudid)
adresse = etud.adresses.first()
if adresse:
# Mail à utiliser pour les envois vers l'étudiant:
# choix qui pourrait être controlé par une preference
# ici priorité au mail institutionnel:
email_default = adresse.email or adresse.emailperso
if email_default:
destinations.append(email_default)
# Notification (à chaque fois) des resp. de modules ayant des évaluations
# à cette date
@ -262,16 +268,18 @@ 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
values["nbabsnonjust"] = nbabs - nbabsjust
values["url_ficheetud"] = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid, _external=True
)
template = prefs["abs_notification_mail_tmpl"]
@ -281,7 +289,7 @@ def abs_notification_message(
log("abs_notification_message: empty template, not sending message")
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

View File

@ -62,7 +62,7 @@ def can_edit_etud_archive(authuser):
def etud_list_archives_html(etud: Identite):
"""HTML snippet listing archives"""
"""HTML snippet listing archives."""
can_edit = can_edit_etud_archive(current_user)
etud_archive_id = etud.id
L = []
@ -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.ficheEtud", 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.ficheEtud", 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,26 +198,23 @@ 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.ficheEtud",
"scolar.fiche_etud",
scodoc_dept=g.scodoc_dept,
etudid=etudid,
head_message="annulation",
@ -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.ficheEtud", 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
)

View File

@ -17,7 +17,15 @@ from app import log
class Trace:
"""gestionnaire de la trace des fichiers justificatifs
XXX TODO à documenter: rôle et format des fichier strace
Role des fichiers traces :
- 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
"""
def __init__(self, path: str) -> None:
@ -39,7 +47,7 @@ class Trace:
continue
entry_date: datetime = is_iso_formated(csv[1], True)
delete_date: datetime = is_iso_formated(csv[2], True)
user_id = csv[3]
user_id = csv[3].strip()
self.content[fname] = [entry_date, delete_date, user_id]
if os.path.isfile(self.path):
@ -84,7 +92,14 @@ class Trace:
self, fnames: list[str] = None
) -> dict[str, list[datetime, datetime, str]]:
"""Récupère la trace pour les noms de fichiers.
si aucun nom n'est donné, récupère tous les fichiers"""
si aucun nom n'est donné, récupère tous les fichiers
retour :
{
"nom_fichier_srv": [datetime_depot, datetime_suppr/None, user_id],
...
}
"""
if fnames is None:
return self.content
@ -101,7 +116,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>
@ -215,8 +230,7 @@ class JustificatifArchiver(BaseArchiver):
filenames = self.list_archive(archive_id, dept_id=etud.dept_id)
trace: Trace = Trace(archive_id)
traced = trace.get_trace(filenames)
return [(key, value[2]) for key, value in traced.items()]
return [(key, value[2]) for key, value in traced.items() if value is not None]
def get_justificatif_file(self, archive_name: str, etud: Identite, filename: str):
"""

View File

@ -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
import app.scodoc.sco_utils as scu
from app import log, db, set_sco_dept
from app.models import (
Identite,
FormSemestre,
FormSemestreInscription,
ModuleImpl,
ModuleImplInscription,
ScoDocSiteConfig,
)
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.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:
@ -93,9 +99,9 @@ class CountCalculator:
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)
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
@ -170,7 +176,7 @@ class CountCalculator:
"""Récupère une clé de dictionnaire en fonction de l'état de l'assiduité
et si elle est justifié
"""
keys: dict[EtatAssiduite, str] = {
keys: dict[scu.EtatAssiduite, str] = {
scu.EtatAssiduite.ABSENT: "absent",
scu.EtatAssiduite.RETARD: "retard",
scu.EtatAssiduite.PRESENT: "present",
@ -188,7 +194,7 @@ class CountCalculator:
if assi.etat != scu.EtatAssiduite.PRESENT:
_key: str = scu.EtatAssiduite.inverse().get(assi.etat).name.lower()
if key not in self.data[_key]["demi"]:
self.data[_key]["demi"].append(day)
self.data[_key]["demi"].append(key)
if key not in self.data["total"]["demi"]:
self.data["total"]["demi"].append(key)
@ -349,6 +355,11 @@ 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
if filtered is not None:
deb, fin = None, None
@ -366,6 +377,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]
@ -390,13 +405,18 @@ def get_assiduites_stats(
# Récupération des états
etats: list[str] = (
filtered["etat"].split(",")
if "etat" in filtered
else ["absent", "present", "retard"]
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)
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()
if etat != "present":
output[etat] = count[etat]
output[etat]["justifie"] = count[etat + "_just"]
@ -448,8 +468,6 @@ def filter_by_date(
if date_fin is None:
date_fin = datetime.max
date_deb = scu.localize_datetime(date_deb) # TODO A modifier (timezone ?)
date_fin = scu.localize_datetime(date_fin)
if not strict:
return collection.filter(
collection_cls.date_debut <= date_fin, collection_cls.date_fin >= date_deb
@ -481,7 +499,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:
@ -496,14 +516,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:
@ -556,15 +608,19 @@ def get_all_justified(
return after
def create_absence(
def create_absence_billet(
date_debut: datetime,
date_fin: datetime,
etudid: int,
description: str = None,
est_just: bool = False,
) -> int:
"""TODO: doc, dire quand l'utiliser"""
# TODO
"""
Permet de rapidement créer une absence.
**UTILISÉ UNIQUEMENT POUR LES BILLETS**
Ne pas utiliser autre par.
TALK: Vérifier si nécessaire
"""
etud: Identite = Identite.query.filter_by(etudid=etudid).first_or_404()
assiduite_unique: Assiduite = Assiduite.create_assiduite(
etud=etud,
@ -646,8 +702,7 @@ def get_assiduites_count_in_interval(
"""
date_debut_iso = date_debut_iso or date_debut.isoformat()
date_fin_iso = date_fin_iso or date_fin.isoformat()
# TODO Question: pourquoi ne pas cacher toutes les métriques, si l'API les veut toutes ?
key = f"{etudid}_{date_debut_iso}_{date_fin_iso}{metrique}_assiduites"
key = f"{etudid}_{date_debut_iso}_{date_fin_iso}_assiduites"
r = sco_cache.AbsSemEtudCache.get(key)
if not r or moduleimpl_id is not None:
@ -663,27 +718,28 @@ def get_assiduites_count_in_interval(
calculator: CountCalculator = CountCalculator()
calculator.compute_assiduites(assiduites)
calcul : dict = calculator.to_dict(only_total=False)
nb_abs: dict = calcul["absent"][metrique]
nb_abs_just: dict = calcul["absent_just"][metrique]
calcul: dict = calculator.to_dict(only_total=False)
r = (nb_abs, nb_abs_just)
r = calcul
if moduleimpl_id is None:
ans = sco_cache.AbsSemEtudCache.set(key, r)
if not ans:
log("warning: get_assiduites_count failed to cache")
return r
nb_abs: dict = r["absent"][metrique]
nb_abs_just: dict = r["absent_just"][metrique]
return (nb_abs, nb_abs_just)
def invalidate_assiduites_count(etudid: int, sem: dict):
"""Invalidate (clear) cached counts"""
date_debut = sem["date_debut_iso"]
date_fin = sem["date_fin_iso"]
for met in scu.AssiduitesMetrics.TAG:
key = str(etudid) + "_" + date_debut + "_" + date_fin + f"{met}_assiduites"
sco_cache.AbsSemEtudCache.delete(key)
key = str(etudid) + "_" + date_debut + "_" + date_fin + "_assiduites"
sco_cache.AbsSemEtudCache.delete(key)
# Non utilisé
def invalidate_assiduites_count_sem(sem: dict):
"""Invalidate (clear) cached abs counts for all the students of this semestre"""
inscriptions = (
@ -752,3 +808,14 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None):
etudid = etudid if etudid is not None else obj["etudid"]
invalidate_assiduites_etud_date(etudid, date_debut)
invalidate_assiduites_etud_date(etudid, date_fin)
# mettre à jour le scodoc_dept en fonction de l'étudiant
etud = Identite.query.filter_by(etudid=etudid).first_or_404()
set_sco_dept(etud.departement.acronym)
# Invalide les caches des tableaux de l'étudiant
sco_cache.RequeteTableauAssiduiteCache.delete_pattern(
pattern=f"tableau-etud-{etudid}*"
)
# Invalide les tableaux "bilan dept"
sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern=f"tableau-dept*")

View File

@ -795,7 +795,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", "")

View File

@ -60,6 +60,7 @@ import traceback
from flask import g, request
from app import log, ScoValueError
from app.comp.res_but import ResultatsSemestreBUT
from app.models import FormSemestre, Identite
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
@ -165,9 +166,9 @@ def process_field(
values={pprint.pformat(cdict)}
"""
)
text = f"""<para><i>format invalide: champs</i> {missing_key} <i>inexistant !</i></para>"""
text = f"""<para><i>format invalide: champ</i> {missing_key} <i>inexistant !</i></para>"""
scu.flash_once(
f"Attention: format PDF invalide (champs {field}, clef {missing_key})"
f"Attention: format PDF invalide (champ {field}, clef {missing_key})"
)
raise
except: # pylint: disable=bare-except
@ -318,14 +319,34 @@ def get_etud_bulletins_pdf(etudid, version="selectedevals"):
return pdfdoc, filename
def get_filigranne(etud_etat: str, prefs, decision_sem=None) -> str:
"""Texte à placer en "filigranne" sur le bulletin pdf"""
def get_filigranne(
etud_etat: str, prefs, decision_sem: str | None | bool = None
) -> str:
"""Texte à placer en "filigranne" sur le bulletin pdf.
etud_etat : etat de l'inscription (I ou D)
decision_sem = code jury ou vide
"""
if etud_etat == scu.DEMISSION:
return "Démission"
elif etud_etat == codes_cursus.DEF:
if etud_etat == codes_cursus.DEF:
return "Défaillant"
elif (prefs["bul_show_temporary"] and not decision_sem) or prefs[
if (prefs["bul_show_temporary"] and not decision_sem) or prefs[
"bul_show_temporary_forced"
]:
return prefs["bul_temporary_txt"]
return ""
def get_filigranne_apc(
etud_etat: str, prefs, etudid: int, res: ResultatsSemestreBUT
) -> str:
"""Texte à placer en "filigranne" sur le bulletin pdf.
Version optimisée pour BUT
"""
if prefs["bul_show_temporary_forced"]:
return get_filigranne(etud_etat, prefs)
if prefs["bul_show_temporary"]:
# requete les décisions de jury
decision_sem = res.etud_has_decision(etudid)
return get_filigranne(etud_etat, prefs, decision_sem=decision_sem)
return get_filigranne(etud_etat, prefs)

View File

@ -121,6 +121,33 @@ class ScoDocCache:
for oid in oids:
cls.delete(oid)
@classmethod
def delete_pattern(cls, pattern: str, std_prefix=True) -> int:
"""Delete all keys matching pattern.
The pattern starts with flask_cache_<dept_acronym>.
If std_prefix is true (default), the prefix is added
to the given pattern.
Examples:
'TABASSI_tableau-etud-1234:*'
Or, with std_prefix false, 'flask_cache_RT_TABASSI_tableau-etud-1234:*'
Returns number of keys deleted.
"""
# see https://stackoverflow.com/questions/36708461/flask-cache-list-keys-based-on-a-pattern
assert CACHE.cache.__class__.__name__ == "RedisCache" # Redis specific
import redis
if std_prefix:
pattern = "flask_cache_" + g.scodoc_dept + "_" + cls.prefix + "_" + pattern
r = redis.Redis()
count = 0
for key in r.scan_iter(pattern):
log(f"{cls.__name__}.delete_pattern({key})")
r.delete(key)
count += 1
return count
class EvaluationCache(ScoDocCache):
"""Cache for evaluations.
@ -369,3 +396,13 @@ class ValidationsSemestreCache(ScoDocCache):
prefix = "VSC"
timeout = 60 * 60 # ttl 1 heure (en phase de mise au point)
class RequeteTableauAssiduiteCache(ScoDocCache):
"""
clé : "<titre_tableau>:<type_obj>:<show_pres>:<show_retard>:<show_desc>:<order_col>:<order>"
Valeur = liste de dicts
"""
prefix = "TABASSI"
timeout = 60 * 60 # Une heure

View File

@ -39,7 +39,6 @@ from app import log
from app.scodoc.scolog import logdb
from app.scodoc import sco_cache, sco_etud
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formations
from app.scodoc.codes_cursus import (
CMP,
ADC,

View File

@ -134,10 +134,10 @@ def table_debouche_etudids(etudids, keep_numeric=True):
"nom": etud["nom"],
"prenom": etud["prenom"],
"_nom_target": url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid
),
"_prenom_target": url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid
),
"_nom_td_attrs": 'id="%s" class="etudinfo"' % (etud["etudid"]),
# 'debouche' : etud['debouche'],

View File

@ -126,53 +126,59 @@ def html_edit_formation_apc(
UniteEns.type != codes_cursus.UE_SPORT,
).first()
H += [
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Ressources du S{semestre_idx}",
create_element_msg="créer une nouvelle ressource",
# matiere_parent=matiere_parent,
modules=ressources_in_sem,
module_type=ModuleType.RESSOURCE,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else "",
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Situations d'Apprentissage et d'Évaluation (SAÉs) S{semestre_idx}",
create_element_msg="créer une nouvelle SAÉ",
# matiere_parent=matiere_parent,
modules=saes_in_sem,
module_type=ModuleType.SAE,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else "",
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Autres modules (non BUT) du S{semestre_idx}",
create_element_msg="créer un nouveau module",
modules=other_modules_in_sem,
module_type=ModuleType.STANDARD,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else """<span class="fontred">créer une UE pour pouvoir ajouter des modules</span>""",
(
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Ressources du S{semestre_idx}",
create_element_msg="créer une nouvelle ressource",
# matiere_parent=matiere_parent,
modules=ressources_in_sem,
module_type=ModuleType.RESSOURCE,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else ""
),
(
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Situations d'Apprentissage et d'Évaluation (SAÉs) S{semestre_idx}",
create_element_msg="créer une nouvelle SAÉ",
# matiere_parent=matiere_parent,
modules=saes_in_sem,
module_type=ModuleType.SAE,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else ""
),
(
render_template(
"pn/form_mods.j2",
formation=formation,
titre=f"Autres modules (non BUT) du S{semestre_idx}",
create_element_msg="créer un nouveau module",
modules=other_modules_in_sem,
module_type=ModuleType.STANDARD,
editable=editable,
tag_editable=tag_editable,
icons=icons,
scu=scu,
semestre_id=semestre_idx,
)
if ues_by_sem[semestre_idx].count() > 0
else """<span class="fontred">créer une UE pour pouvoir ajouter des modules</span>"""
),
]
return "\n".join(H)
@ -202,7 +208,7 @@ def html_ue_infos(ue):
)
return render_template(
"pn/ue_infos.j2",
titre=f"UE {ue.acronyme} {ue.titre}",
titre=f"UE {ue.acronyme} {ue.titre or ''}",
ue=ue,
formsemestres=formsemestres,
nb_etuds_valid_ue=nb_etuds_valid_ue,

View File

@ -104,7 +104,7 @@ def matiere_create(ue_id=None):
default_numero = max([mat.numero for mat in ue.matieres] or [9]) + 1
H = [
html_sco_header.sco_header(page_title="Création d'une matière"),
f"""<h2>Création d'une matière dans l'UE {ue.titre} ({ue.acronyme})</h2>
f"""<h2>Création d'une matière dans l'UE {ue.titre or ''} ({ue.acronyme})</h2>
<p class="help">Les matières sont des groupes de modules dans une UE
d'une formation donnée. Les matières servent surtout pour la
présentation (bulletins, etc) mais <em>n'ont pas de rôle dans le calcul

View File

@ -85,7 +85,7 @@ _moduleEditor = ndb.EditableTable(
"heures_tp": ndb.float_null_is_zero,
"numero": ndb.int_null_is_zero,
"coefficient": ndb.float_null_is_zero,
"module_type": ndb.int_null_is_zero
"module_type": ndb.int_null_is_zero,
#'ects' : ndb.float_null_is_null
},
)
@ -387,14 +387,16 @@ def module_edit(
"scodoc/help/modules.j2",
is_apc=is_apc,
semestre_id=semestre_id,
formsemestres=FormSemestre.query.filter(
ModuleImpl.formsemestre_id == FormSemestre.id,
ModuleImpl.module_id == module_id,
)
.order_by(FormSemestre.date_debut)
.all()
if not create
else None,
formsemestres=(
FormSemestre.query.filter(
ModuleImpl.formsemestre_id == FormSemestre.id,
ModuleImpl.module_id == module_id,
)
.order_by(FormSemestre.date_debut)
.all()
if not create
else None
),
create=create,
),
]
@ -413,9 +415,11 @@ def module_edit(
}
if module:
module_types |= {
scu.ModuleType(module.module_type)
if module.module_type
else scu.ModuleType.STANDARD
(
scu.ModuleType(module.module_type)
if module.module_type
else scu.ModuleType.STANDARD
)
}
# Numéro du module
# cherche le numero adéquat (pour placer le module en fin de liste)
@ -571,15 +575,17 @@ def module_edit(
"input_type": "menu",
"title": "Rattachement :" if is_apc else "Matière :",
"explanation": (
"UE de rattachement, utilisée notamment pour les malus"
+ (
" (module utilisé, ne peut pas être changé de semestre)"
if in_use
else ""
(
"UE de rattachement, utilisée notamment pour les malus"
+ (
" (module utilisé, ne peut pas être changé de semestre)"
if in_use
else ""
)
)
)
if is_apc
else "un module appartient à une seule matière.",
if is_apc
else "un module appartient à une seule matière."
),
"labels": mat_names,
"allowed_values": ue_mat_ids,
"enabled": unlocked,
@ -733,7 +739,7 @@ def module_edit(
"title": f"""<span class="fontred">{scu.EMO_WARNING }
L'UE <a class="stdlink" href="{
url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue.id)
}">{ue.acronyme} {ue.titre}</a>
}">{ue.acronyme} {ue.titre or ''}</a>
n'est pas associée à un niveau de compétences
</span>""",
},
@ -766,12 +772,14 @@ def module_edit(
request.base_url,
scu.get_request_args(),
descr,
html_foot_markup=f"""<div class="sco_tag_module_edit"><span
html_foot_markup=(
f"""<div class="sco_tag_module_edit"><span
class="sco_tag_edit"><textarea data-module_id="{module_id}" class="module_tag_editor"
>{','.join(sco_tag_module.module_tag_list(module_id))}</textarea></span></div>
"""
if not create
else "",
if not create
else ""
),
initvalues=module_dict if module else {},
submitlabel="Modifier ce module" if module else "Créer ce module",
cancelbutton="Annuler",
@ -814,7 +822,7 @@ def module_edit(
tf[2]["matiere_id"] = matiere.id
else:
matiere_id = sco_edit_matiere.do_matiere_create(
{"ue_id": ue.id, "titre": ue.titre, "numero": 1},
{"ue_id": ue.id, "titre": ue.titre or "", "numero": 1},
)
tf[2]["matiere_id"] = matiere_id

View File

@ -89,6 +89,7 @@ _ueEditor = ndb.EditableTable(
"color",
"niveau_competence_id",
),
convert_empty_to_nulls=False, # necessaire pour ue_code == ""
sortkey="numero",
input_formators={
"type": ndb.int_null_is_zero,
@ -110,7 +111,7 @@ def ue_list(*args, **kw):
return _ueEditor.list(cnx, *args, **kw)
def do_ue_create(args):
def do_ue_create(args, allow_empty_ue_code=False):
"create an ue"
cnx = ndb.GetDBConnexion()
# check duplicates
@ -120,19 +121,27 @@ def do_ue_create(args):
f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé !
(chaque UE doit avoir un acronyme unique dans la formation)"""
)
if (
(not "ue_code" in args)
or (args["ue_code"] is None)
or (not args["ue_code"].strip())
):
# évite les conflits de code
while True:
cursor = db.session.execute(sa.text("select notes_newid_ucod();"))
code = cursor.fetchone()[0]
if UniteEns.query.filter_by(ue_code=code).count() == 0:
break
args["ue_code"] = code
if "ue_code" not in args or args["ue_code"] is None or not args["ue_code"].strip():
if allow_empty_ue_code:
args["ue_code"] = ""
else:
# évite les conflits: génère nouveau ue_code
while True:
cursor = db.session.execute(sa.text("select notes_newid_ucod();"))
code = cursor.fetchone()[0]
if UniteEns.query.filter_by(ue_code=code).count() == 0:
break
args["ue_code"] = code
# last checks
if not args.get("acronyme"):
raise ScoValueError("acronyme vide")
args["coefficient"] = args.get("coefficient", None)
if args["coefficient"] == "":
args["coefficient"] = None
# create
# XXX TODO utiliser UniteEns.create_from_dict
ue_id = _ueEditor.create(cnx, args)
log(f"do_ue_create: created {ue_id} with {args}")
@ -158,7 +167,7 @@ def do_ue_delete(ue: UniteEns, delete_validations=False, force=False):
if not ue.can_be_deleted():
raise ScoNonEmptyFormationObject(
f"UE (id={ue.id}, dud)",
msg=ue.titre,
msg=f"{ue.titre or ''} ({ue.acronyme})",
dest_url=url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
@ -302,11 +311,13 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
<p class="help">Note: sauf exception, l'UE n'a pas de coefficient associé.
Seuls les <em>modules</em> ont des coefficients.
</p>""",
f"""
(
f"""
<h4>UE du semestre S{ue.semestre_idx}</h4>
"""
if is_apc and ue
else "",
if is_apc and ue
else ""
),
]
ue_types = cursus.ALLOWED_UE_TYPES
@ -628,8 +639,8 @@ def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False):
)
if not ue.can_be_deleted():
raise ScoNonEmptyFormationObject(
f"UE",
msg=ue.titre,
"UE",
msg=f"{ue.titre or ''} ({ue.acronyme})",
dest_url=url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
@ -640,7 +651,7 @@ def ue_delete(ue_id=None, delete_validations=False, dialog_confirmed=False):
if not dialog_confirmed:
return scu.confirm_dialog(
f"<h2>Suppression de l'UE {ue.titre} ({ue.acronyme})</h2>",
f"<h2>Suppression de l'UE {ue.titre or ''} ({ue.acronyme})</h2>",
dest_url="",
parameters={"ue_id": ue.id},
cancel_url=url_for(
@ -669,6 +680,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
semestre_idx = None
else:
semestre_idx = int(semestre_idx)
show_tags = scu.to_bool(request.args.get("show_tags", 0))
locked = formation.has_locked_sems(semestre_idx)
semestre_ids = range(1, parcours.NB_SEM + 1)
# transition: on requete ici via l'ORM mais on utilise les fonctions ScoDoc7
@ -874,11 +886,13 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
)
# Description des UE/matières/modules
H.append(
"""
f"""
<div class="formation_ue_list">
<div class="ue_list_tit">Programme pédagogique:</div>
<form>
<input type="checkbox" class="sco_tag_checkbox">montrer les tags des modules</input>
<input type="checkbox" class="sco_tag_checkbox"
{'checked' if show_tags else ''}
> Montrer les tags des modules voire en ajouter <i>(ceux correspondant aux titres des compétences étant ajoutés par défaut)</i></input>
</form>
"""
)
@ -977,6 +991,13 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
formation_id=formation_id)
}">Table récapitulative de la formation</a>
</li>
<li><a class="stdlink" href="{
url_for('notes.formation_tag_modules_by_type', scodoc_dept=g.scodoc_dept,
formation_id=formation_id,
semestre_idx=1 if semestre_idx is None else semestre_idx
)
}">Tagguer tous les modules par leur type</a> (tag <tt>res</tt>, <tt>sae</tt>).
</li>
<li><a class="stdlink" href="{
url_for('notes.formation_export', scodoc_dept=g.scodoc_dept,
formation_id=formation_id, fmt='xml')
@ -1060,6 +1081,7 @@ def _html_select_semestre_idx(formation_id, semestre_ids, semestre_idx):
htm += f"""
</select>
<input type="hidden" name="formation_id" value="{formation_id}"></input>
<input type="hidden" name="show_tags" value="0"></input>
</form>"""
return htm
@ -1430,7 +1452,7 @@ def ue_sharing_code(ue_code: str = "", ue_id: int = None, hide_ue_id: int = None
H.append("<ul>")
for ue in ues:
H.append(
f"""<li>{ue.acronyme} ({ue.titre}) dans
f"""<li>{ue.acronyme} ({ue.titre or ''}) dans
<a class="stdlink" href="{
url_for("notes.ue_table",
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)}"

View File

@ -177,12 +177,26 @@ def formsemestre_edt_dict(
TODO: spécifier intervalle de dates start et end
"""
t0 = time.time()
group_ids_set = set(group_ids) if group_ids else set()
try:
events_scodoc, _ = load_and_convert_ics(formsemestre)
except ScoValueError as exc:
return exc.args[0]
# Génération des événements pour le calendrier html
edt_dict = translate_calendar(
events_scodoc, group_ids, show_modules_titles=show_modules_titles
)
log(
f"formsemestre_edt_dict: loaded edt for {formsemestre} in {(time.time()-t0):g}s"
)
return edt_dict
def translate_calendar(
events_scodoc: list[dict],
group_ids: list[int] = None,
show_modules_titles=True,
) -> list[dict]:
"""Génération des événements pour le calendrier html"""
group_ids_set = set(group_ids) if group_ids else set()
promo_icon = f"""<img height="18px" src="{scu.STATIC_DIR}/icons/promo.svg"
title="promotion complète" alt="promotion"/>"""
abs_icon = f"""<img height="28px" src="{scu.STATIC_DIR}/icons/absences.svg"
@ -211,7 +225,6 @@ def formsemestre_edt_dict(
url_for(
"assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
heure_deb=event["heure_deb"],
heure_fin=event["heure_fin"],
@ -271,17 +284,17 @@ def formsemestre_edt_dict(
"start": event["start"],
"end": event["end"],
"backgroundColor": event["group_bg_color"],
"raw": event["raw"],
# Infos brutes pour usage API éventuel
"edt_ens_ids": event["edt_ens_ids"],
"ens_user_names": ens_user_names,
"group_id": group.id if group else None,
"group_edt_id": event["edt_group"],
"moduleimpl_id": modimpl.id if modimpl else None,
"UID": event["UID"], # icalendar event UID
}
events_cal.append(d)
log(
f"formsemestre_edt_dict: loaded edt for {formsemestre} in {(time.time()-t0):g}s"
)
return events_cal
@ -302,7 +315,35 @@ def get_ics_uid_pattern() -> re.Pattern:
def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[str]]:
"""Chargement fichier ics, filtrage et extraction des identifiants.
"""Chargement fichier ics.
Renvoie une liste d'évènements, et la liste des identifiants de groupes
trouvés (utilisée pour l'aide).
"""
# Chargement du calendier ics
_, calendar = formsemestre_load_calendar(formsemestre)
if not calendar:
return [], []
# --- Correspondances id edt -> id scodoc pour groupes
edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre)
default_group = formsemestre.get_default_group()
# --- Correspondances id edt -> id scodoc pour modimpls
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre)
return convert_ics(
calendar,
edt2group=edt2group,
default_group=default_group,
edt2modimpl=edt2modimpl,
)
def convert_ics(
calendar: icalendar.cal.Calendar,
edt2group: dict[str, GroupDescr] = None,
default_group: GroupDescr = None,
edt2modimpl: dict[str, ModuleImpl] = None,
) -> tuple[list[dict], list[str]]:
"""Filtrage et extraction des identifiants des évènements calendrier.
Renvoie une liste d'évènements, et la liste des identifiants de groupes
trouvés (utilisée pour l'aide).
@ -310,10 +351,6 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
- False si extraction regexp non configuré
- "tous" (promo) si pas de correspondance trouvée.
"""
# Chargement du calendier ics
_, calendar = formsemestre_load_calendar(formsemestre)
if not calendar:
return []
# --- Paramètres d'extraction
edt_ics_title_field = ScoDocSiteConfig.get("edt_ics_title_field")
edt_ics_title_regexp = ScoDocSiteConfig.get("edt_ics_title_regexp")
@ -348,15 +385,13 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
edt_ics_uid_field = ScoDocSiteConfig.get("edt_ics_uid_field")
edt_ics_uid_pattern = get_ics_uid_pattern()
# --- Correspondances id edt -> id scodoc pour groupes, modules et enseignants
edt2group = formsemestre_retreive_groups_from_edt_id(formsemestre)
# --- Groupes
group_colors = {
group_name: _COLOR_PALETTE[i % (len(_COLOR_PALETTE) - 1) + 1]
for i, group_name in enumerate(edt2group)
}
edt_groups_ids = set() # les ids de groupes normalisés tels que dans l'ics
default_group = formsemestre.get_default_group()
edt2modimpl = formsemestre_retreive_modimpls_from_edt_id(formsemestre)
edt2user: dict[str, User | None] = {} # construit au fur et à mesure (cache)
# ---
events = [e for e in calendar.walk() if e.name == "VEVENT"]
@ -371,29 +406,6 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
)
# title remplacé par le nom du module scodoc quand il est trouvé
title = title_edt
# --- Group
if edt_ics_group_pattern:
edt_group = extract_event_edt_id(
event, edt_ics_group_field, edt_ics_group_pattern
)
edt_groups_ids.add(edt_group)
# si pas de groupe dans l'event, ou si groupe non reconnu,
# prend toute la promo ("tous")
group: GroupDescr = (
edt2group.get(edt_group, default_group)
if edt_group
else default_group
)
group_bg_color = (
group_colors.get(edt_group, _EVENT_DEFAULT_COLOR)
if group
else "lightgrey"
)
else:
edt_group = ""
group = False
group_bg_color = _EVENT_DEFAULT_COLOR
# --- ModuleImpl
if edt_ics_mod_pattern:
edt_module = extract_event_edt_id(
@ -405,6 +417,34 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
else:
modimpl = False
edt_module = ""
# --- Group
if edt_ics_group_pattern:
edt_group = extract_event_edt_id(
event, edt_ics_group_field, edt_ics_group_pattern
)
edt_groups_ids.add(edt_group)
# si pas de groupe dans l'event, ou si groupe non reconnu,
# prend toute la promo ("tous")
event_default_group = (
default_group
if default_group
else (modimpl.formsemestre.get_default_group() if modimpl else None)
)
group: GroupDescr = (
edt2group.get(edt_group, event_default_group)
if edt_group
else event_default_group
)
group_bg_color = (
group_colors.get(edt_group, _EVENT_DEFAULT_COLOR)
if group
else "lightgrey"
)
else:
edt_group = ""
group = False
group_bg_color = _EVENT_DEFAULT_COLOR
# --- Enseignants
users: list[User] = []
if edt_ics_uid_pattern:
@ -446,6 +486,8 @@ def load_and_convert_ics(formsemestre: FormSemestre) -> tuple[list[dict], list[s
"jour": event.decoded("dtstart").date().isoformat(),
"start": event.decoded("dtstart").isoformat(),
"end": event.decoded("dtend").isoformat(),
"UID": event.decoded("UID").decode("utf-8"),
"raw": event.to_ical().decode("utf-8"),
}
)
return events_sco, sorted(edt_groups_ids)

View File

@ -542,7 +542,9 @@ def view_scodoc_etuds(semset_id, title="", nip_list="", fmt="html"):
etuds = [sco_etud.get_etud_info(code_nip=nip, filled=True)[0] for nip in nips]
for e in etuds:
tgt = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"])
tgt = url_for(
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"]
)
e["_nom_target"] = tgt
e["_prenom_target"] = tgt
e["_nom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """
@ -769,10 +771,10 @@ def view_apo_csv(etape_apo="", semset_id="", fmt="html"):
e["in_scodoc"] = e["nip"] not in nips_no_sco
e["in_scodoc_str"] = {True: "oui", False: "non"}[e["in_scodoc"]]
if e["in_scodoc"]:
e["_in_scodoc_str_target"] = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, code_nip=e["nip"]
)
e.update(sco_etud.get_etud_info(code_nip=e["nip"], filled=True)[0])
e["_in_scodoc_str_target"] = url_for(
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=e["etudid"]
)
e["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"],)
e["_prenom_td_attrs"] = 'id="pre-%s" class="etudinfo"' % (e["etudid"],)
else:

View File

@ -692,7 +692,7 @@ class EtapeBilan:
@staticmethod
def link_etu(etudid, nom):
return '<a class="stdlink" href="%s">%s</a>' % (
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid),
nom,
)

View File

@ -39,6 +39,7 @@ from app import db, email
from app import log
from app.models import Admission, Identite
from app.models.etudiants import (
check_etud_duplicate_code,
input_civilite,
input_civilite_etat_civil,
make_etud_args,
@ -64,7 +65,7 @@ def format_etud_ident(etud: dict):
Note: par rapport à Identite.to_dict_bul(),
ajoute les champs:
'email_default', 'nom_disp', 'nom_usuel', 'civilite_etat_civil_str', 'ne', 'civilite_str'
'nom_disp', 'nom_usuel', 'civilite_etat_civil_str', 'ne', 'civilite_str'
"""
etud["nom"] = format_nom(etud["nom"])
if "nom_usuel" in etud:
@ -98,10 +99,6 @@ def format_etud_ident(etud: dict):
etud["ne"] = "e"
else: # 'X'
etud["ne"] = "(e)"
# Mail à utiliser pour les envois vers l'étudiant:
# choix qui pourrait être controé par une preference
# ici priorité au mail institutionnel:
etud["email_default"] = etud.get("email", "") or etud.get("emailperso", "")
def force_uppercase(s):
@ -117,36 +114,6 @@ def _format_etat_civil(etud: dict) -> str:
return etud["nomprenom"]
def format_lycee(nomlycee):
nomlycee = nomlycee.strip()
s = nomlycee.lower()
if s[:5] == "lycee" or s[:5] == "lycée":
return nomlycee[5:]
else:
return nomlycee
def format_telephone(n):
if n is None:
return ""
if len(n) < 7:
return n
else:
n = n.replace(" ", "").replace(".", "")
i = 0
r = ""
j = len(n) - 1
while j >= 0:
r = n[j] + r
if i % 2 == 1 and j != 0:
r = " " + r
i += 1
j -= 1
if len(r) == 13 and r[0] != "0":
r = "0" + r
return r
def format_pays(s):
"laisse le pays seulement si != FRANCE"
if s.upper() != "FRANCE":
@ -263,74 +230,12 @@ def check_nom_prenom_homonyms(
return True, query.all()
def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True):
"""Vérifie que le code n'est pas dupliqué.
Raises ScoGenError si problème.
"""
etudid = args.get("etudid", None)
if args.get(code_name, None):
etuds = identite_list(cnx, {code_name: str(args[code_name])})
duplicate = False
if edit:
duplicate = (len(etuds) > 1) or (
(len(etuds) == 1) and etuds[0]["id"] != args["etudid"]
)
else:
duplicate = len(etuds) > 0
if duplicate:
listh = [] # liste des doubles
for e in etuds:
listh.append(
f"""Autre étudiant: <a href="{
url_for(
"scolar.ficheEtud",
scodoc_dept=g.scodoc_dept,
etudid=e["etudid"]
)}">{e['nom']} {e['prenom']}</a>"""
)
if etudid:
OK = "retour à la fiche étudiant"
dest_endpoint = "scolar.ficheEtud"
parameters = {"etudid": etudid}
else:
if "tf_submitted" in args:
del args["tf_submitted"]
OK = "Continuer"
dest_endpoint = "scolar.etudident_create_form"
parameters = args
else:
OK = "Annuler"
dest_endpoint = "notes.index_html"
parameters = {}
if not disable_notify:
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) }
">{OK}</a>
</p>
"""
else:
err_page = f"""<h3>Code étudiant ({code_name}) dupliqué !</h3>"""
log(f"*** error: code {code_name} duplique: {args[code_name]}")
raise ScoGenError(err_page)
def identite_edit(cnx, args, disable_notify=False):
"""Modifie l'identite d'un étudiant.
Si pref notification et difference, envoie message notification, sauf si disable_notify
"""
_check_duplicate_code(
cnx, args, "code_nip", disable_notify=disable_notify, edit=True
)
_check_duplicate_code(
cnx, args, "code_ine", disable_notify=disable_notify, edit=True
)
check_etud_duplicate_code(args, "code_nip", edit=True)
check_etud_duplicate_code(args, "code_ine", edit=True)
notify_to = None
if not disable_notify:
try:
@ -359,16 +264,14 @@ def identite_edit(cnx, args, disable_notify=False):
def identite_create(cnx, args):
"check unique etudid, then create"
_check_duplicate_code(cnx, args, "code_nip", edit=False)
_check_duplicate_code(cnx, args, "code_ine", edit=False)
check_etud_duplicate_code(args, "code_nip", edit=False)
check_etud_duplicate_code(args, "code_ine", edit=False)
if "etudid" in args:
etudid = args["etudid"]
r = identite_list(cnx, {"etudid": etudid})
if r:
raise ScoValueError(
"Code identifiant (etudid) déjà utilisé ! (%s)" % etudid
)
raise ScoValueError(f"Code identifiant (etudid) déjà utilisé ! ({etudid})")
return _identiteEditor.create(cnx, args)
@ -619,7 +522,7 @@ def create_etud(cnx, args: dict = None):
etud_dict = etudident_list(cnx, {"etudid": etudid})[0]
fill_etuds_info([etud_dict])
etud_dict["url"] = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid
)
ScolarNews.add(
typ=ScolarNews.NEWS_INSCR,
@ -724,19 +627,28 @@ def get_etablissements():
def get_lycee_infos(codelycee):
E = get_etablissements()
return E.get(codelycee, None)
etablissements = get_etablissements()
return etablissements.get(codelycee, None)
def format_lycee_from_code(codelycee):
def format_lycee_from_code(codelycee: str) -> str:
"Description lycee à partir du code"
E = get_etablissements()
if codelycee in E:
e = E[codelycee]
etablissements = get_etablissements()
if codelycee in etablissements:
e = etablissements[codelycee]
nomlycee = e["name"]
return "%s (%s)" % (nomlycee, e["commune"])
return f"{nomlycee} ({e['commune']})"
return f"{codelycee} (établissement inconnu)"
def format_lycee(nomlycee: str) -> str:
"mise en forme nom de lycée"
nomlycee = nomlycee.strip()
s = nomlycee.lower()
if s[:5] == "lycee" or s[:5] == "lycée":
return nomlycee[5:]
else:
return "%s (établissement inconnu)" % codelycee
return nomlycee
def etud_add_lycee_infos(etud):
@ -821,49 +733,20 @@ def fill_etuds_info(etuds: list[dict], add_admission=True):
# nettoyage champs souvent vides
etud["codepostallycee"] = etud.get("codepostallycee", "") or ""
etud["nomlycee"] = etud.get("nomlycee", "") or ""
if etud.get("nomlycee"):
etud["ilycee"] = "Lycée " + format_lycee(etud["nomlycee"])
if etud["villelycee"]:
etud["ilycee"] += " (%s)" % etud.get("villelycee", "")
etud["ilycee"] += "<br>"
else:
if etud.get("codelycee"):
etud["ilycee"] = format_lycee_from_code(etud["codelycee"])
else:
etud["ilycee"] = ""
rap = ""
if etud.get("rapporteur") or etud.get("commentaire"):
rap = "Note du rapporteur"
if etud.get("rapporteur"):
rap += " (%s)" % etud["rapporteur"]
rap += ": "
if etud.get("commentaire"):
rap += "<em>%s</em>" % etud["commentaire"]
etud["rap"] = rap
if etud.get("telephone"):
etud["telephonestr"] = "<b>Tél.:</b> " + format_telephone(etud["telephone"])
else:
etud["telephonestr"] = ""
if etud.get("telephonemobile"):
etud["telephonemobilestr"] = "<b>Mobile:</b> " + format_telephone(
etud["telephonemobile"]
)
else:
etud["telephonemobilestr"] = ""
# voir Identite.inscription_descr et Identite.to_dict_scodoc7(with_inscriptions=True)
def etud_inscriptions_infos(etudid: int, ne="") -> dict:
"""Dict avec les informations sur les semestres passés et courant.
{
"sems" : , # trie les semestres par date de debut, le plus recent d'abord
"ins" : ,
"cursem" : ,
"inscription" : ,
"inscriptionstr" : ,
"inscription_formsemestre_id" : ,
"etatincursem" : ,
"situation" : ,
"inscription" : , # cursem["titremois"]
"inscriptionstr" : , # "Inscrit en " + cursem["titremois"]
"inscription_formsemestre_id" : , # cursem["formsemestre_id"]
"etatincursem" : , # curi["etat"]
"situation" : , # descr_situation_etud(etudid, ne)
}
"""
from app.scodoc import sco_formsemestre

View File

@ -156,7 +156,7 @@ def evaluation_check_absences_html(
H.append(
f"""<li><a class="discretelink" href="{
url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid
)
}">{etud.nomprenom}</a>"""
)

View File

@ -162,50 +162,48 @@ def do_evaluation_get_all_notes(
return d
def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1):
def moduleimpl_evaluation_move(evaluation_id: int, after=0):
"""Move before/after previous one (decrement/increment numero)
(published)
"""
evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
redirect = int(redirect)
evaluation = Evaluation.get_evaluation(evaluation_id)
# access: can change eval ?
if not evaluation.moduleimpl.can_edit_evaluation(current_user):
raise AccessDenied(
f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
)
Evaluation.moduleimpl_evaluation_renumber(
evaluation.moduleimpl, only_if_unumbered=True
)
e = get_evaluations_dict(args={"evaluation_id": evaluation_id})[0]
after = int(after) # 0: deplace avant, 1 deplace apres
if after not in (0, 1):
raise ValueError('invalid value for "after"')
mod_evals = get_evaluations_dict({"moduleimpl_id": e["moduleimpl_id"]})
if len(mod_evals) > 1:
idx = [p["evaluation_id"] for p in mod_evals].index(evaluation_id)
Evaluation.moduleimpl_evaluation_renumber(
evaluation.moduleimpl, only_if_unumbered=True
)
mod_evaluations = evaluation.moduleimpl.evaluations.all()
if len(mod_evaluations) > 1:
idx = [e.id for e in mod_evaluations].index(evaluation.id)
neigh = None # object to swap with
if after == 0 and idx > 0:
neigh = mod_evals[idx - 1]
elif after == 1 and idx < len(mod_evals) - 1:
neigh = mod_evals[idx + 1]
neigh = mod_evaluations[idx - 1]
elif after == 1 and idx < len(mod_evaluations) - 1:
neigh = mod_evaluations[idx + 1]
if neigh: #
if neigh["numero"] == e["numero"]:
if neigh.numero == evaluation.numero:
log("Warning: moduleimpl_evaluation_move: forcing renumber")
Evaluation.moduleimpl_evaluation_renumber(
evaluation.moduleimpl, only_if_unumbered=False
)
else:
# swap numero with neighbor
e["numero"], neigh["numero"] = neigh["numero"], e["numero"]
do_evaluation_edit(e)
do_evaluation_edit(neigh)
evaluation.numero, neigh.numero = neigh.numero, evaluation.numero
db.session.add(evaluation)
db.session.add(neigh)
db.session.commit()
# redirect to moduleimpl page:
if redirect:
return flask.redirect(
url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=e["moduleimpl_id"],
)
return flask.redirect(
url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=evaluation.moduleimpl.id,
)
)

View File

@ -37,7 +37,7 @@ from flask_login import current_user
from flask import request
from app import db
from app.models import Evaluation, FormSemestre, ModuleImpl
from app.models import Evaluation, Module, ModuleImpl
from app.models.evaluations import heure_to_time
import app.scodoc.sco_utils as scu
@ -47,7 +47,6 @@ from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc import html_sco_header
from app.scodoc import sco_cache
from app.scodoc import sco_evaluations
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
@ -58,27 +57,20 @@ def evaluation_create_form(
page_title="Évaluation",
):
"Formulaire création/édition d'une évaluation (pas de ses notes)"
evaluation: Evaluation
if evaluation_id is not None:
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
evaluation = db.session.get(Evaluation, evaluation_id)
if evaluation is None:
raise ScoValueError("Cette évaluation n'existe pas ou plus !")
moduleimpl_id = evaluation.moduleimpl_id
#
modimpl: ModuleImpl = (
ModuleImpl.query.filter_by(id=moduleimpl_id)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
.first_or_404()
)
modimpl_o = sco_moduleimpl.moduleimpl_withmodule_list(moduleimpl_id=moduleimpl_id)[
0
]
mod = modimpl_o["module"]
formsemestre_id = modimpl_o["formsemestre_id"]
modimpl = ModuleImpl.get_modimpl(moduleimpl_id)
formsemestre_id = modimpl.formsemestre_id
formsemestre = modimpl.formsemestre
module: Module = modimpl.module
sem_ues = formsemestre.get_ues(with_sport=False)
is_malus = mod["module_type"] == ModuleType.MALUS
is_apc = mod["module_type"] in (ModuleType.RESSOURCE, ModuleType.SAE)
is_malus = module.module_type == ModuleType.MALUS
is_apc = module.module_type in (ModuleType.RESSOURCE, ModuleType.SAE)
preferences = sco_preferences.SemPreferences(formsemestre.id)
can_edit_poids = not preferences["but_disable_edit_poids_evaluations"]
min_note_max = scu.NOTES_PRECISION # le plus petit bareme possible
@ -98,9 +90,11 @@ def evaluation_create_form(
# création nouvel
if moduleimpl_id is None:
raise ValueError("missing moduleimpl_id parameter")
numeros = [(e.numero or 0) for e in modimpl.evaluations]
initvalues = {
"note_max": 20,
"jour": time.strftime("%d/%m/%Y", time.localtime()),
"note_max": 20,
"numero": (max(numeros) + 1) if numeros else 0,
"publish_incomplete": is_malus,
"visibulletin": 1,
}
@ -128,18 +122,7 @@ def evaluation_create_form(
min_note_max_str = scu.fmt_note(min_note_max)
else:
min_note_max_str = "0"
#
H = [
f"""<h3>{action} en
{scu.MODULE_TYPE_NAMES[mod["module_type"]]} <a class="stdlink" href="{
url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
}">{mod["code"] or "module sans code"} {mod["titre"]}</a> {link}</h3>
"""
]
heures = [f"{h:02d}h{m:02d}" for h in range(8, 19) for m in (0, 30)]
#
initvalues["coefficient"] = initvalues.get("coefficient", 1.0)
vals = scu.get_request_args()
#
@ -164,6 +147,7 @@ def evaluation_create_form(
("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}),
("formsemestre_id", {"default": formsemestre_id, "input_type": "hidden"}),
("moduleimpl_id", {"default": moduleimpl_id, "input_type": "hidden"}),
("numero", {"default": initvalues["numero"], "input_type": "hidden"}),
(
"jour",
{
@ -178,9 +162,7 @@ def evaluation_create_form(
{
"title": "Heure de début",
"explanation": "heure du début de l'épreuve",
"input_type": "menu",
"allowed_values": heures,
"labels": heures,
"input_type": "time",
},
),
(
@ -188,9 +170,7 @@ def evaluation_create_form(
{
"title": "Heure de fin",
"explanation": "heure de fin de l'épreuve",
"input_type": "menu",
"allowed_values": heures,
"labels": heures,
"input_type": "time",
},
),
]
@ -303,7 +283,7 @@ def evaluation_create_form(
"coef. mod.:" +str(coef_ue) if coef_ue
else "ce module n'a pas de coef. dans cette UE"
})</span>
<span class="eval_coef_ue_titre">{ue.titre}</span>
<span class="eval_coef_ue_titre">{ue.titre or ''}</span>
""",
"allow_null": False,
# ok si poids nul ou coef vers l'UE nul:
@ -327,6 +307,16 @@ def evaluation_create_form(
dest_url = url_for(
"notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id
)
H = [
f"""<h3>{action} en
{scu.MODULE_TYPE_NAMES[module.module_type]} <a class="stdlink" href="{
url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, moduleimpl_id=moduleimpl_id)
}">{module.code or "module sans code"} {
module.titre or module.abbrev or "(sans titre)"
}</a> {link}</h3>
"""
]
if tf[0] == 0:
head = html_sco_header.sco_header(page_title=page_title)
return (
@ -335,6 +325,7 @@ def evaluation_create_form(
+ "\n"
+ tf[1]
+ render_template("scodoc/help/evaluations.j2", is_apc=is_apc)
+ render_template("sco_timepicker.j2")
+ html_sco_header.sco_footer()
)
elif tf[0] == -1:

View File

@ -40,7 +40,7 @@ from app import db
from app.auth.models import User
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Evaluation, FormSemestre
from app.models import Evaluation, FormSemestre, ModuleImpl
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
@ -280,86 +280,21 @@ def do_evaluation_etat(
}
def do_evaluation_list_in_sem(formsemestre_id, with_etat=True):
"""Liste les évaluations de tous les modules de ce semestre.
Triée par module, numero desc, date_debut desc
Donne pour chaque eval son état (voir do_evaluation_etat)
{ evaluation_id,nb_inscrits, nb_notes, nb_abs, nb_neutre, moy, median, last_modif ... }
Exemple:
[ {
'coefficient': 1.0,
'description': 'QCM et cas pratiques',
'etat': {
'evalattente': False,
'evalcomplete': True,
'evaluation_id': 'GEAEVAL82883',
'gr_incomplets': [],
'gr_moyennes': [{
'gr_median': '12.00', # sur 20
'gr_moy': '11.88',
'gr_nb_att': 0,
'gr_nb_notes': 166,
'group_id': 'GEAG266762',
'group_name': None
}],
'groups': {'GEAG266762': {'etudid': 'GEAEID80603',
'group_id': 'GEAG266762',
'group_name': None,
'partition_id': 'GEAP266761'}
},
'last_modif': datetime.datetime(2015, 12, 3, 15, 15, 16),
'median': '12.00',
'moy': '11.84',
'nb_abs': 2,
'nb_att': 0,
'nb_inscrits': 166,
'nb_neutre': 0,
'nb_notes': 168,
'nb_notes_total': 169
},
'evaluation_id': 'GEAEVAL82883',
'evaluation_type': 0,
'heure_debut': datetime.time(8, 0),
'heure_fin': datetime.time(9, 30),
'jour': datetime.date(2015, 11, 3), // vide => 1/1/1900
'moduleimpl_id': 'GEAMIP80490',
'note_max': 20.0,
'numero': 0,
'publish_incomplete': 0,
'visibulletin': 1} ]
"""
req = """SELECT E.id AS evaluation_id, E.*
FROM notes_evaluation E, notes_moduleimpl MI
WHERE MI.formsemestre_id = %(formsemestre_id)s
and MI.id = E.moduleimpl_id
ORDER BY MI.id, numero desc, date_debut desc
"""
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(req, {"formsemestre_id": formsemestre_id})
res = cursor.dictfetchall()
# etat de chaque evaluation:
for r in res:
if with_etat:
r["etat"] = do_evaluation_etat(r["evaluation_id"])
r["jour"] = r["date_debut"] or datetime.date(1900, 1, 1)
return res
def _eval_etat(evals):
"""evals: list of mappings (etats)
-> nb_eval_completes, nb_evals_en_cours,
nb_evals_vides, date derniere modif
def _summarize_evals_etats(etat_evals: list[dict]) -> dict:
"""Synthétise les états d'une liste d'évaluations
evals: list of mappings (etats),
utilise e["etat"]["evalcomplete"], e["etat"]["nb_notes"], e["etat"]["last_modif"]
->
nb_eval_completes (= prises en compte)
nb_evals_en_cours (= avec des notes, mais pas complete)
nb_evals_vides (= sans aucune note)
date derniere modif
Une eval est "complete" ssi tous les etudiants *inscrits* ont une note.
"""
nb_evals_completes, nb_evals_en_cours, nb_evals_vides = 0, 0, 0
dates = []
for e in evals:
for e in etat_evals:
if e["etat"]["evalcomplete"]:
nb_evals_completes += 1
elif e["etat"]["nb_notes"] == 0:
@ -370,11 +305,8 @@ def _eval_etat(evals):
if last_modif is not None:
dates.append(e["etat"]["last_modif"])
if dates:
dates = scu.sort_dates(dates)
last_modif = dates[-1] # date de derniere modif d'une note dans un module
else:
last_modif = ""
# date de derniere modif d'une note dans un module
last_modif = sorted(dates)[-1] if dates else ""
return {
"nb_evals_completes": nb_evals_completes,
@ -384,37 +316,42 @@ def _eval_etat(evals):
}
def do_evaluation_etat_in_sem(formsemestre_id):
"""-> nb_eval_completes, nb_evals_en_cours, nb_evals_vides,
date derniere modif, attente
XXX utilisé par
- formsemestre_status_head
- gen_formsemestre_recapcomplet_xml
- gen_formsemestre_recapcomplet_json
"nb_evals_completes"
"nb_evals_en_cours"
"nb_evals_vides"
"date_derniere_note"
"last_modif"
"attente"
def do_evaluation_etat_in_sem(formsemestre: FormSemestre) -> dict:
"""-> { nb_eval_completes, nb_evals_en_cours, nb_evals_vides,
date derniere modif, attente }
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# Note: utilisé par
# - formsemestre_status_head
# nb_evals_completes, nb_evals_en_cours, nb_evals_vides, last_modif
# pour la ligne
# Évaluations: 20 ok, 8 en cours, 5 vides (dernière note saisie le 11/01/2024 à 19h49)
# attente
#
# - gen_formsemestre_recapcomplet_xml
# - gen_formsemestre_recapcomplet_json
# nb_evals_completes, nb_evals_en_cours, nb_evals_vides, last_modif
#
# "nb_evals_completes"
# "nb_evals_en_cours"
# "nb_evals_vides"
# "last_modif"
# "attente"
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
evals = nt.get_evaluations_etats()
etat = _eval_etat(evals)
evaluations_etats = nt.get_evaluations_etats()
# raccordement moche...
etat = _summarize_evals_etats([{"etat": v} for v in evaluations_etats.values()])
# Ajoute information sur notes en attente
etat["attente"] = len(nt.get_moduleimpls_attente()) > 0
return etat
def do_evaluation_etat_in_mod(nt, moduleimpl_id):
def do_evaluation_etat_in_mod(nt, modimpl: ModuleImpl):
"""état des évaluations dans ce module"""
evals = nt.get_mod_evaluation_etat_list(moduleimpl_id)
etat = _eval_etat(evals)
etat_evals = nt.get_mod_evaluation_etat_list(modimpl)
etat = _summarize_evals_etats(etat_evals)
# Il y a-t-il des notes en attente dans ce module ?
etat["attente"] = nt.modimpls_results[moduleimpl_id].en_attente
etat["attente"] = nt.modimpls_results[modimpl.id].en_attente
return etat
@ -691,7 +628,7 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True)
group_ids=group_id,
evaluation_id=evaluation.id,
date_debut=evaluation.date_debut.isoformat(),
date_fin=evaluation.date_fin.isoformat(),
date_fin=evaluation.date_fin.isoformat() if evaluation.date_fin else "",
)
}">absences ce jour</a>
</span>

View File

@ -230,3 +230,15 @@ class APIInvalidParams(Exception):
class ScoFormationConflict(Exception):
"""Conflit cohérence formation (APC)"""
class ScoTemporaryError(ScoValueError):
"""Erreurs temporaires rarissimes (caches ?)"""
def __init__(self, msg: str = ""):
msg = """
<p>"Erreur temporaire</p>
<p>Veuillez -essayer. Si le problème persiste, merci de contacter l'assistance ScoDoc
</p>
"""
super().__init__(msg)

View File

@ -173,9 +173,10 @@ def _build_results_list(dpv_by_sem, etuds_infos):
"nom_usuel": etud["nom_usuel"],
"prenom": etud["prenom"],
"civilite_str": etud["civilite_str"],
"_nom_target": "%s"
% url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
"_nom_td_attrs": 'id="%s" class="etudinfo"' % etudid,
"_nom_target": url_for(
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid
),
"_nom_td_attrs": f'id="{etudid}" class="etudinfo"',
"bac": bac.abbrev(),
"parcours": dec["parcours"],
}

View File

@ -145,7 +145,7 @@ def search_etud_in_dept(expnom=""):
if "dest_url" in vals:
endpoint = vals["dest_url"]
else:
endpoint = "scolar.ficheEtud"
endpoint = "scolar.fiche_etud"
if "parameters_keys" in vals:
for key in vals["parameters_keys"].split(","):
url_args[key] = vals[key]
@ -328,8 +328,9 @@ def table_etud_in_accessible_depts(expnom=None):
"""
result, accessible_depts = search_etud_in_accessible_depts(expnom=expnom)
H = [
"""<div class="table_etud_in_accessible_depts">""",
"""<h3>Recherche multi-département de "<tt>%s</tt>"</h3>""" % expnom,
f"""<div class="table_etud_in_accessible_depts">
<h3>Recherche multi-département de "<tt>{expnom}</tt>"</h3>
""",
]
for etuds in result:
if etuds:
@ -337,9 +338,9 @@ def table_etud_in_accessible_depts(expnom=None):
# H.append('<h3>Département %s</h3>' % DeptId)
for e in etuds:
e["_nomprenom_target"] = url_for(
"scolar.ficheEtud", scodoc_dept=dept_id, etudid=e["etudid"]
"scolar.fiche_etud", scodoc_dept=dept_id, etudid=e["etudid"]
)
e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"])
e["_nomprenom_td_attrs"] = f"""id="{e['etudid']}" class="etudinfo" """
tab = GenTable(
titles={"nomprenom": "Étudiants en " + dept_id},

View File

@ -103,6 +103,7 @@ def formation_table_recap(formation_id, fmt="html") -> Response:
"heures_cours": mod.heures_cours,
"heures_td": mod.heures_td,
"heures_tp": mod.heures_tp,
"tags": ", ".join(t.title for t in mod.tags if t.title),
"_css_row_class": f"mod {mod.type_abbrv()}",
}
)
@ -117,6 +118,7 @@ def formation_table_recap(formation_id, fmt="html") -> Response:
"heures_cours",
"heures_td",
"heures_tp",
"tags",
]
if not formation.is_apc():
columns_ids.insert(columns_ids.index("ects"), "coef")
@ -132,6 +134,7 @@ def formation_table_recap(formation_id, fmt="html") -> Response:
"heures_cours": "Cours (h)",
"heures_td": "TD (h)",
"heures_tp": "TP (h)",
"tags": "Tags",
"ects": "ECTS",
}

View File

@ -168,16 +168,14 @@ def formsemestre_associate_new_version(
formation_id=new_formation_id,
)
)
else:
return flask.redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
return flask.redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
else:
raise ScoValueError("Méthode invalide")
)
raise ScoValueError("Méthode invalide")
def do_formsemestres_associate_new_version(

View File

@ -117,6 +117,7 @@ def formation_export_dict(
ues = ues.all()
ues.sort(key=lambda u: (u.semestre_idx or 0, u.numero or 0, u.acronyme))
f_dict["ue"] = []
ue: UniteEns
for ue in ues:
ue_dict = ue.to_dict()
f_dict["ue"].append(ue_dict)
@ -142,8 +143,8 @@ def formation_export_dict(
if not export_codes_apo:
ue_dict.pop("code_apogee", None)
if ue_dict["ects"] is None:
del ue_dict["ects"]
if ue_dict.get("ects") is None:
ue_dict.pop("ects", None)
mats = sco_edit_matiere.matiere_list({"ue_id": ue.id})
mats.sort(key=lambda m: m["numero"] or 0)
ue_dict["matiere"] = mats
@ -363,7 +364,9 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
ue_info[1]["niveau_competence_id"] = _formation_retreive_apc_niveau(
referentiel_competence_id, ue_info[1]
)
ue_id = sco_edit_ue.do_ue_create(ue_info[1])
# Note: si le code est indiqué "" dans le xml, il faut le conserver vide
# pour la comparaison ultérieure des formations XXX
ue_id = sco_edit_ue.do_ue_create(ue_info[1], allow_empty_ue_code=True)
ue: UniteEns = db.session.get(UniteEns, ue_id)
assert ue
if xml_ue_id:

View File

@ -241,7 +241,7 @@ def do_formsemestre_create(args, silent=False):
write_formsemestre_etapes(args)
if args["responsables"]:
args["formsemestre_id"] = formsemestre_id
write_formsemestre_responsables(args)
_write_formsemestre_responsables(args)
# create default partition
partition_id = sco_groups.partition_create(
@ -275,7 +275,7 @@ def do_formsemestre_edit(sem, cnx=None, **kw):
_formsemestreEditor.edit(cnx, sem, **kw)
write_formsemestre_etapes(sem)
write_formsemestre_responsables(sem)
_write_formsemestre_responsables(sem)
sco_cache.invalidate_formsemestre(
formsemestre_id=sem["formsemestre_id"]
@ -296,8 +296,12 @@ def read_formsemestre_responsables(formsemestre_id: int) -> list[int]: # py3.9+
return [x["responsable_id"] for x in r]
def write_formsemestre_responsables(sem):
return _write_formsemestre_aux(sem, "responsables", "responsable_id")
def _write_formsemestre_responsables(sem): # TODO old, à ré-écrire avec models
if sem and "responsables" in sem:
sem["responsables"] = [
uid for uid in sem["responsables"] if (uid is not None) and (uid != -1)
]
_write_formsemestre_aux(sem, "responsables", "responsable_id")
# ---------------------- Coefs des UE
@ -362,10 +366,11 @@ def read_formsemestre_etapes(formsemestre_id): # OBSOLETE
return [ApoEtapeVDI(x["etape_apo"]) for x in r if x["etape_apo"]]
def write_formsemestre_etapes(sem):
def write_formsemestre_etapes(sem): # TODO old, à ré-écrire avec models
return _write_formsemestre_aux(sem, "etapes", "etape_apo")
# TODO old, à ré-écrire avec models
def _write_formsemestre_aux(sem, fieldname, valuename):
"""fieldname: 'etapes' ou 'responsables'
valuename: 'etape_apo' ou 'responsable_id'

View File

@ -577,7 +577,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
{
"input_type": "boolcheckbox",
"title": "Bloquer moyennes",
"explanation": "empêcher le calcul des moyennes d'UE et générale.",
"explanation": "empêcher le calcul des moyennes d'UE et générale (en BUT, empèche sa prise en compte dans les jurys annuels)",
},
),
(
@ -857,17 +857,20 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
tf[2]["bul_hide_xml"] = True
# remap les identifiants de responsables:
for field in resp_fields:
tf[2][field] = User.get_user_id_from_nomplogin(tf[2][field])
resp = User.get_user_from_nomplogin(tf[2][field])
tf[2][field] = resp.id if resp else -1
tf[2]["responsables"] = []
for field in resp_fields:
if tf[2][field]:
tf[2]["responsables"].append(tf[2][field])
for module_id in tf[2]["tf-checked"]:
mod_resp_id = User.get_user_id_from_nomplogin(tf[2][module_id])
if mod_resp_id is None:
mod_resp = User.get_user_from_nomplogin(tf[2][module_id])
if mod_resp is None:
# Si un module n'a pas de responsable (ou inconnu),
# l'affecte au 1er directeur des etudes:
mod_resp_id = tf[2]["responsable_id"]
else:
mod_resp_id = mod_resp.id
tf[2][module_id] = mod_resp_id
# etapes:
@ -1227,9 +1230,12 @@ def formsemestre_clone(formsemestre_id):
"formsemestre_status?formsemestre_id=%s" % formsemestre_id
)
else:
resp = User.get_user_from_nomplogin(tf[2]["responsable_id"])
if not resp:
raise ScoValueError("id responsable invalide")
new_formsemestre_id = do_formsemestre_clone(
formsemestre_id,
User.get_user_id_from_nomplogin(tf[2]["responsable_id"]),
resp.id,
tf[2]["date_debut"],
tf[2]["date_fin"],
clone_evaluations=tf[2]["clone_evaluations"],
@ -1273,29 +1279,24 @@ def do_formsemestre_clone(
log(f"created formsemestre {formsemestre_id}")
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
# 2- create moduleimpls
mods_orig = sco_moduleimpl.moduleimpl_list(formsemestre_id=orig_formsemestre_id)
for mod_orig in mods_orig:
args = mod_orig.copy()
modimpl_orig: ModuleImpl
for modimpl_orig in formsemestre_orig.modimpls:
args = modimpl_orig.to_dict(with_module=False)
args["formsemestre_id"] = formsemestre_id
mid = sco_moduleimpl.do_moduleimpl_create(args)
# copy notes_modules_enseignants
ens = sco_moduleimpl.do_ens_list(
args={"moduleimpl_id": mod_orig["moduleimpl_id"]}
)
for e in ens:
args = e.copy()
args["moduleimpl_id"] = mid
sco_moduleimpl.do_ens_create(args)
modimpl_new = ModuleImpl.create_from_dict(args)
db.session.flush()
# copy enseignants
for ens in modimpl_orig.enseignants:
modimpl_new.enseignants.append(ens)
db.session.add(modimpl_new)
# optionally, copy evaluations
if clone_evaluations:
for e in Evaluation.query.filter_by(
moduleimpl_id=mod_orig["moduleimpl_id"]
):
for e in Evaluation.query.filter_by(moduleimpl_id=modimpl_orig.id):
# copie en enlevant la date
new_eval = e.clone(
not_copying=("date_debut", "date_fin", "moduleimpl_id")
)
new_eval.moduleimpl_id = mid
new_eval.moduleimpl_id = modimpl_new.id
# Copie les poids APC de l'évaluation
new_eval.set_ue_poids_dict(e.get_ue_poids_dict())
db.session.commit()
@ -1592,7 +1593,9 @@ def formsemestre_edit_options(formsemestre_id):
ok, err = sco_permissions_check.check_access_diretud(formsemestre_id)
if not ok:
return err
return sco_preferences.SemPreferences(formsemestre_id).edit(categories=["bul"])
return sco_preferences.SemPreferences(formsemestre_id).edit(
categories=["bul", "bul_but_pdf"]
)
def formsemestre_change_publication_bul(

View File

@ -102,7 +102,7 @@ def formsemestre_ext_create_form(etudid, formsemestre_id):
scodoc_dept=g.scodoc_dept, etudid=etudid, only_ext=1) }">
inscrire à un autre semestre</a>"
</p>
<h3><a href="{ url_for('scolar.ficheEtud',
<h3><a href="{ url_for('scolar.fiche_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}" class="stdlink">Étudiant {etud.nomprenom}</a></h3>
""",
@ -221,7 +221,7 @@ def formsemestre_ext_create_form(etudid, formsemestre_id):
tf[2]["formation_id"] = orig_sem["formation_id"]
formsemestre_ext_create(etudid, tf[2])
return flask.redirect(
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
@ -258,13 +258,17 @@ def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid):
submitlabel="Enregistrer ces validations",
cancelbutton="Annuler",
initvalues=initvalues,
cssclass="tf_ext_edit_ue_validations ext_apc"
if formsemestre.formation.is_apc()
else "tf_ext_edit_ue_validations",
cssclass=(
"tf_ext_edit_ue_validations ext_apc"
if formsemestre.formation.is_apc()
else "tf_ext_edit_ue_validations"
),
# En APC, stocke les coefficients pour l'affichage de la moyenne en direct
form_attrs=f"""data-ue_coefs='[{', '.join(str(ue.ects or 0) for ue in ues)}]'"""
if formsemestre.formation.is_apc()
else "",
form_attrs=(
f"""data-ue_coefs='[{', '.join(str(ue.ects or 0) for ue in ues)}]'"""
if formsemestre.formation.is_apc()
else ""
),
)
if tf[0] == -1:
return "<h4>annulation</h4>"
@ -421,12 +425,18 @@ def _ue_form_description(
"input_type": "text",
"size": 4,
"template": itemtemplate,
"title": "<tt>"
+ (f"S{ue.semestre_idx} " if ue.semestre_idx is not None else "")
+ f"<b>{ue.acronyme}</b></tt> {ue.titre}"
+ f" ({ue.ects} ECTS)"
if ue.ects is not None
else "",
"title": (
"<tt>"
+ (
f"S{ue.semestre_idx} "
if ue.semestre_idx is not None
else ""
)
+ f"<b>{ue.acronyme}</b></tt> {ue.titre or ''}"
+ f" ({ue.ects} ECTS)"
if ue.ects is not None
else ""
),
"attributes": [coef_disabled],
},
)

View File

@ -400,7 +400,7 @@ def formsemestre_inscription_with_modules_form(etudid, only_ext=False):
H.append("<p>aucune session de formation !</p>")
H.append(
f"""<h3>ou</h3> <a class="stdlink" href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">retour à la fiche de {etud.nomprenom}</a>"""
)
return "\n".join(H) + footer
@ -440,7 +440,7 @@ def formsemestre_inscription_with_modules(
dans le semestre {formsemestre.titre_mois()}
</p>
<ul>
<li><a href="{url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
<li><a href="{url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}" class="stdlink">retour à la fiche de {etud.nomprenom}</a>
</li>
<li><a href="{url_for(
@ -501,7 +501,7 @@ def formsemestre_inscription_with_modules(
method="formsemestre_inscription_with_modules",
)
return flask.redirect(
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
else:
# formulaire choix groupe
@ -656,7 +656,7 @@ function chkbx_select(field_id, state) {
return "\n".join(H) + "\n" + tf[1] + footer
elif tf[0] == -1:
return flask.redirect(
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
else:
# Inscriptions aux modules choisis
@ -697,7 +697,7 @@ function chkbx_select(field_id, state) {
"""<h3>Aucune modification à effectuer</h3>
<p><a class="stdlink" href="%s">retour à la fiche étudiant</a></p>
"""
% url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
% url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
return "\n".join(H) + footer
@ -755,7 +755,7 @@ function chkbx_select(field_id, state) {
etudid,
modulesimpls_ainscrire,
modulesimpls_adesinscrire,
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid),
)
)
return "\n".join(H) + footer
@ -820,7 +820,7 @@ def do_moduleimpl_incription_options(
<p><a class="stdlink" href="%s">
Retour à la fiche étudiant</a></p>
"""
% url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
% url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid),
html_sco_header.sco_footer(),
]
return "\n".join(H)
@ -885,7 +885,7 @@ def formsemestre_inscrits_ailleurs(formsemestre_id):
'<li><a href="%s" class="discretelink">%s</a> : '
% (
url_for(
"scolar.ficheEtud",
"scolar.fiche_etud",
scodoc_dept=g.scodoc_dept,
etudid=etud["etudid"],
),

View File

@ -51,13 +51,14 @@ from app.models import (
NotesNotes,
)
from app.scodoc.codes_cursus import UE_SPORT
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import (
ScoValueError,
ScoInvalidIdType,
)
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_archives_formsemestre
@ -75,12 +76,14 @@ from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
from app.scodoc import sco_users
from app.scodoc.gen_tables import GenTable
from app.scodoc.html_sidebar import retreive_formsemestre_from_request
from app.scodoc.sco_formsemestre_custommenu import formsemestre_custommenu_html
import sco_version
def _build_menu_stats(formsemestre_id):
def _build_menu_stats(formsemestre: FormSemestre):
"Définition du menu 'Statistiques'"
formsemestre_id = formsemestre.id
return [
{
"title": "Statistiques...",
@ -109,7 +112,7 @@ def _build_menu_stats(formsemestre_id):
"title": "Lycées d'origine",
"endpoint": "notes.formsemestre_etuds_lycees",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"enabled": current_user.has_permission(Permission.ViewEtudData),
},
{
"title": 'Table "poursuite"',
@ -121,7 +124,8 @@ def _build_menu_stats(formsemestre_id):
"title": "Documents Avis Poursuite Etudes (xp)",
"endpoint": "notes.pe_view_sem_recap",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True, # current_app.config["TESTING"] or current_app.config["DEBUG"],
"enabled": formsemestre.formation.is_apc(),
# current_app.config["TESTING"] or current_app.config["DEBUG"],
},
{
"title": 'Table "débouchés"',
@ -277,9 +281,11 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
menu_inscriptions = [
{
"title": "Gérer les inscriptions aux UE et modules"
if formsemestre.formation.is_apc()
else "Gérer les inscriptions aux modules",
"title": (
"Gérer les inscriptions aux UE et modules"
if formsemestre.formation.is_apc()
else "Gérer les inscriptions aux modules"
),
"endpoint": "notes.moduleimpl_inscriptions_stats",
"args": {"formsemestre_id": formsemestre_id},
}
@ -336,6 +342,7 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
formsemestre_id, fix_if_missing=True
),
},
"enabled": current_user.has_permission(Permission.ViewEtudData),
},
{
"title": "Vérifier inscriptions multiples",
@ -459,7 +466,7 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
},
]
menu_stats = _build_menu_stats(formsemestre_id)
menu_stats = _build_menu_stats(formsemestre)
H = [
'<ul id="sco_menu">',
htmlutils.make_menu("Semestre", menu_semestre),
@ -474,57 +481,6 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
return "\n".join(H)
def retreive_formsemestre_from_request() -> int:
"""Cherche si on a de quoi déduire le semestre affiché à partir des
arguments de la requête:
formsemestre_id ou moduleimpl ou evaluation ou group_id ou partition_id
Returns None si pas défini.
"""
if request.method == "GET":
args = request.args
elif request.method == "POST":
args = request.form
else:
return None
formsemestre_id = None
# Search formsemestre
group_ids = args.get("group_ids", [])
if "formsemestre_id" in args:
formsemestre_id = args["formsemestre_id"]
elif "moduleimpl_id" in args and args["moduleimpl_id"]:
modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=args["moduleimpl_id"])
if not modimpl:
return None # suppressed ?
modimpl = modimpl[0]
formsemestre_id = modimpl["formsemestre_id"]
elif "evaluation_id" in args:
E = sco_evaluation_db.get_evaluations_dict(
{"evaluation_id": args["evaluation_id"]}
)
if not E:
return None # evaluation suppressed ?
E = E[0]
modimpl = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
formsemestre_id = modimpl["formsemestre_id"]
elif "group_id" in args:
group = sco_groups.get_group(args["group_id"])
formsemestre_id = group["formsemestre_id"]
elif group_ids:
if isinstance(group_ids, str):
group_ids = group_ids.split(",")
group_id = group_ids[0]
group = sco_groups.get_group(group_id)
formsemestre_id = group["formsemestre_id"]
elif "partition_id" in args:
partition = sco_groups.get_partition(args["partition_id"])
formsemestre_id = partition["formsemestre_id"]
if not formsemestre_id:
return None # no current formsemestre
return int(formsemestre_id)
# Element HTML decrivant un semestre (barre de menu et infos)
def formsemestre_page_title(formsemestre_id=None):
"""Element HTML decrivant un semestre (barre de menu et infos)
@ -665,17 +621,15 @@ def formsemestre_description_table(
if ue.color:
for k in list(ue_info.keys()):
if not k.startswith("_"):
ue_info[
f"_{k}_td_attrs"
] = f'style="background-color: {ue.color} !important;"'
ue_info[f"_{k}_td_attrs"] = (
f'style="background-color: {ue.color} !important;"'
)
if not is_apc:
# n'affiche la ligne UE qu'en formation classique
# car l'UE de rattachement n'a pas d'intérêt en BUT
rows.append(ue_info)
mod_inscrits = sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=modimpl.id
)
mod_nb_inscrits = nt.modimpls_results[modimpl.id].nb_inscrits_module
enseignants = ", ".join(ens.get_prenomnom() for ens in modimpl.enseignants)
row = {
@ -684,7 +638,7 @@ def formsemestre_description_table(
"Code": modimpl.module.code or "",
"Module": modimpl.module.abbrev or modimpl.module.titre,
"_Module_class": "scotext",
"Inscrits": len(mod_inscrits),
"Inscrits": mod_nb_inscrits,
"Responsable": sco_users.user_info(modimpl.responsable_id)["nomprenom"],
"_Responsable_class": "scotext",
"Enseignants": enseignants,
@ -726,7 +680,7 @@ def formsemestre_description_table(
if with_evals:
# Ajoute lignes pour evaluations
evals = nt.get_mod_evaluation_etat_list(modimpl.id)
evals = nt.get_mod_evaluation_etat_list(modimpl)
evals.reverse() # ordre chronologique
# Ajoute etat:
eval_rows = []
@ -917,7 +871,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
<a class="btn" href="{
url_for("assiduites.bilan_dept",
scodoc_dept=g.scodoc_dept,
formsemestre=formsemestre.id,
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
<button>Justificatifs en attente</button></a>
@ -988,10 +942,10 @@ def html_expr_diagnostic(diagnostics):
def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None):
"""En-tête HTML des pages "semestre" """
sem: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if not sem:
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
if not formsemestre:
raise ScoValueError("Semestre inexistant (il a peut être été supprimé ?)")
formation: Formation = sem.formation
formation: Formation = formsemestre.formation
parcours = formation.get_cursus()
page_title = page_title or "Modules de "
@ -1003,25 +957,25 @@ def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None
f"""<table>
<tr><td class="fichetitre2">Formation: </td><td>
<a href="{url_for('notes.ue_table',
scodoc_dept=g.scodoc_dept, formation_id=sem.formation.id)}"
scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)}"
class="discretelink" title="Formation {
formation.acronyme}, v{formation.version}">{formation.titre}</a>
""",
]
if sem.semestre_id >= 0:
H.append(f", {parcours.SESSION_NAME} {sem.semestre_id}")
if sem.modalite:
H.append(f"&nbsp;en {sem.modalite}")
if sem.etapes:
if formsemestre.semestre_id >= 0:
H.append(f", {parcours.SESSION_NAME} {formsemestre.semestre_id}")
if formsemestre.modalite:
H.append(f"&nbsp;en {formsemestre.modalite}")
if formsemestre.etapes:
H.append(
f"""&nbsp;&nbsp;&nbsp;(étape <b><tt>{
sem.etapes_apo_str() or "-"
formsemestre.etapes_apo_str() or "-"
}</tt></b>)"""
)
H.append("</td></tr>")
if formation.is_apc():
# Affiche les parcours BUT cochés. Si aucun, tous ceux du référentiel.
sem_parcours = sem.get_parcours_apc()
sem_parcours = formsemestre.get_parcours_apc()
H.append(
f"""
<tr><td class="fichetitre2">Parcours: </td>
@ -1030,7 +984,7 @@ def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None
"""
)
evals = sco_evaluations.do_evaluation_etat_in_sem(formsemestre_id)
evals = sco_evaluations.do_evaluation_etat_in_sem(formsemestre)
H.append(
'<tr><td class="fichetitre2">Évaluations: </td><td> %(nb_evals_completes)s ok, %(nb_evals_en_cours)s en cours, %(nb_evals_vides)s vides'
% evals
@ -1048,11 +1002,11 @@ def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None
"""<span class="fontred">Il y a des notes en attente !</span>
Le classement des étudiants n'a qu'une valeur indicative."""
)
if sem.bul_hide_xml:
if formsemestre.bul_hide_xml:
warnings.append("""Bulletins non publiés sur la passerelle.""")
if sem.block_moyennes:
if formsemestre.block_moyennes:
warnings.append("Calcul des moyennes bloqué !")
if sem.semestre_id >= 0 and not sem.est_sur_une_annee():
if formsemestre.semestre_id >= 0 and not formsemestre.est_sur_une_annee():
warnings.append("""<em>Ce semestre couvre plusieurs années scolaires !</em>""")
if warnings:
H += [
@ -1074,18 +1028,14 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
# S'assure que les groupes de parcours sont à jour:
if int(check_parcours):
formsemestre.setup_parcours_groups()
modimpls = sco_moduleimpl.moduleimpl_withmodule_list(
formsemestre_id=formsemestre_id
)
modimpls = formsemestre.modimpls_sorted
nt = res_sem.load_formsemestre_results(formsemestre)
# Construit la liste de tous les enseignants de ce semestre:
mails_enseignants = set(u.email for u in formsemestre.responsables)
for modimpl in modimpls:
mails_enseignants.add(sco_users.user_info(modimpl["responsable_id"])["email"])
mails_enseignants |= set(
[sco_users.user_info(m["ens_id"])["email"] for m in modimpl["ens"]]
)
mails_enseignants.add(sco_users.user_info(modimpl.responsable_id)["email"])
mails_enseignants |= {u.email for u in modimpl.enseignants if u.email}
can_edit = formsemestre.can_be_edited_by(current_user)
can_change_all_notes = current_user.has_permission(Permission.EditAllNotes) or (
@ -1102,9 +1052,11 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
formsemestre_id=formsemestre_id, page_title="Tableau de bord"
),
formsemestre_warning_apc_setup(formsemestre, nt),
formsemestre_warning_etuds_sans_note(formsemestre, nt)
if can_change_all_notes
else "",
(
formsemestre_warning_etuds_sans_note(formsemestre, nt)
if can_change_all_notes
else ""
),
"""<p style="font-size: 130%"><b>Tableau de bord&nbsp;: </b>""",
]
if formsemestre.est_courant():
@ -1135,13 +1087,13 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
if nt.parcours.APC_SAE:
# BUT: tableau ressources puis SAE
ressources = [
m for m in modimpls if m["module"]["module_type"] == ModuleType.RESSOURCE
m for m in modimpls if m.module.module_type == ModuleType.RESSOURCE
]
saes = [m for m in modimpls if m["module"]["module_type"] == ModuleType.SAE]
saes = [m for m in modimpls if m.module.module_type == ModuleType.SAE]
autres = [
m
for m in modimpls
if m["module"]["module_type"] not in (ModuleType.RESSOURCE, ModuleType.SAE)
if m.module.module_type not in (ModuleType.RESSOURCE, ModuleType.SAE)
]
H += [
f"""
@ -1182,7 +1134,7 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
modimpls_classic = [
m
for m in modimpls
if m["module"]["module_type"] not in (ModuleType.RESSOURCE, ModuleType.SAE)
if m.module.module_type not in (ModuleType.RESSOURCE, ModuleType.SAE)
]
H += [
"<p>",
@ -1214,8 +1166,10 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
adrlist = list(mails_enseignants - {None, ""})
if adrlist:
H.append(
'<p><a class="stdlink" href="mailto:?cc=%s">Courrier aux %d enseignants du semestre</a></p>'
% (",".join(adrlist), len(adrlist))
f"""<p>
<a class="stdlink" href="mailto:?cc={','.join(adrlist)}">Courrier aux {
len(adrlist)} enseignants du semestre</a>
</p>"""
)
return "".join(H) + html_sco_header.sco_footer()
@ -1235,7 +1189,7 @@ _TABLEAU_MODULES_FOOT = """</table>"""
def formsemestre_tableau_modules(
modimpls: list[dict],
modimpls: list[ModuleImpl],
nt,
formsemestre: FormSemestre,
can_edit=True,
@ -1246,11 +1200,11 @@ def formsemestre_tableau_modules(
H = []
prev_ue_id = None
for modimpl in modimpls:
mod: Module = db.session.get(Module, modimpl["module_id"])
mod: Module = modimpl.module
moduleimpl_status_url = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl["moduleimpl_id"],
moduleimpl_id=modimpl.id,
)
mod_descr = "Module " + (mod.titre or "")
if mod.is_apc():
@ -1267,54 +1221,52 @@ def formsemestre_tableau_modules(
mod_descr += " (pas de coefficients) "
else:
mod_descr += ", coef. " + str(mod.coefficient)
mod_ens = sco_users.user_info(modimpl["responsable_id"])["nomcomplet"]
if modimpl["ens"]:
mod_ens = sco_users.user_info(modimpl.responsable_id)["nomcomplet"]
if modimpl.enseignants.count():
mod_ens += " (resp.), " + ", ".join(
[sco_users.user_info(e["ens_id"])["nomcomplet"] for e in modimpl["ens"]]
[u.get_nomcomplet() for u in modimpl.enseignants]
)
mod_inscrits = sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=modimpl["moduleimpl_id"]
)
ue = modimpl["ue"]
if show_ues and (prev_ue_id != ue["ue_id"]):
prev_ue_id = ue["ue_id"]
titre = ue["titre"]
mod_nb_inscrits = nt.modimpls_results[modimpl.id].nb_inscrits_module
ue = modimpl.module.ue
if show_ues and (prev_ue_id != ue.id):
prev_ue_id = ue.id
titre = ue.titre or ""
if use_ue_coefs:
titre += f""" <b>(coef. {ue["coefficient"] or 0.0})</b>"""
titre += f""" <b>(coef. {ue.coefficient or 0.0})</b>"""
H.append(
f"""<tr class="formsemestre_status_ue"><td colspan="4">
<span class="status_ue_acro">{ue["acronyme"]}</span>
<span class="status_ue_acro">{ue.acronyme}</span>
<span class="status_ue_title">{titre}</span>
</td><td colspan="2">"""
)
expr = sco_compute_moy.get_ue_expression(
formsemestre.id, ue["ue_id"], html_quote=True
formsemestre.id, ue.id, html_quote=True
)
if expr:
H.append(
f""" <span class="formula" title="mode de calcul de la moyenne d'UE">{expr}</span>
<span class="warning">formule inutilisée en 9.2: <a href="{
url_for("notes.delete_ue_expr", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, ue_id=ue["ue_id"] )
url_for("notes.delete_ue_expr", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, ue_id=ue.id )
}
">supprimer</a></span>"""
)
H.append("</td></tr>")
if modimpl["ue"]["type"] != codes_cursus.UE_STANDARD:
if ue.type != codes_cursus.UE_STANDARD:
fontorange = " fontorange" # style css additionnel
else:
fontorange = ""
etat = sco_evaluations.do_evaluation_etat_in_mod(nt, modimpl["moduleimpl_id"])
etat = sco_evaluations.do_evaluation_etat_in_mod(nt, modimpl)
# if nt.parcours.APC_SAE:
# tbd style si module non conforme
if (
etat["nb_evals_completes"] > 0
and etat["nb_evals_en_cours"] == 0
and etat["nb_evals_vides"] == 0
and not etat["attente"]
):
H.append(f'<tr class="formsemestre_status_green{fontorange}">')
else:
@ -1328,10 +1280,10 @@ def formsemestre_tableau_modules(
<td class="scotext"><a href="{moduleimpl_status_url}" title="{mod_descr}"
class="formsemestre_status_link">{mod.abbrev or mod.titre or ""}</a>
</td>
<td class="formsemestre_status_inscrits">{len(mod_inscrits)}</td>
<td class="formsemestre_status_inscrits">{mod_nb_inscrits}</td>
<td class="resp scotext">
<a class="discretelink" href="{moduleimpl_status_url}" title="{mod_ens}">{
sco_users.user_info(modimpl["responsable_id"])["prenomnom"]
sco_users.user_info(modimpl.responsable_id)["prenomnom"]
}</a>
</td>
<td>
@ -1368,6 +1320,7 @@ def formsemestre_tableau_modules(
if nb_evals != 0:
H.append(
f"""<a href="{moduleimpl_status_url}"
title="les évaluations 'ok' sont celles prises en compte dans les calculs"
class="formsemestre_status_link">{nb_evals} prévues,
{etat["nb_evals_completes"]} ok</a>"""
)
@ -1378,17 +1331,14 @@ def formsemestre_tableau_modules(
etat["nb_evals_en_cours"]
} en cours</a></span>"""
)
if etat["attente"]:
H.append(
f""" <span><a class="redlink" href="{moduleimpl_status_url}"
title="Il y a des notes en attente">[en attente]</a></span>"""
)
if etat["attente"]:
H.append(
f""" <span><a class="redlink" href="{moduleimpl_status_url}"
title="Il y a des notes en attente">[en attente]</a></span>"""
)
elif mod.module_type == ModuleType.MALUS:
nb_malus_notes = sum(
[
e["etat"]["nb_notes"]
for e in nt.get_mod_evaluation_etat_list(modimpl["moduleimpl_id"])
]
e["etat"]["nb_notes"] for e in nt.get_mod_evaluation_etat_list(modimpl)
)
H.append(
f"""<td class="malus">
@ -1457,7 +1407,7 @@ def formsemestre_warning_etuds_sans_note(
noms = ", ".join(
[
f"""<a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}" class="discretelink">{etud.nomprenom}</a>"""
for etud in etuds
]
@ -1519,13 +1469,13 @@ def formsemestre_note_etuds_sans_notes(
a déjà des notes"""
)
return redirect(
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
else:
noms = "</li><li>".join(
[
f"""<a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}" class="discretelink">{etud.nomprenom}</a>"""
for etud in etuds
]

View File

@ -117,8 +117,8 @@ def formsemestre_validation_etud_form(
if read_only:
check = True
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
etud_d = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
Se = sco_cursus.get_situation_etud_cursus(etud_d, formsemestre_id)
if not Se.sem["etat"]:
raise ScoValueError("validation: semestre verrouille")
@ -132,7 +132,7 @@ def formsemestre_validation_etud_form(
H = [
html_sco_header.sco_header(
page_title=f"Parcours {etud['nomprenom']}",
page_title=f"Parcours {etud.nomprenom}",
javascripts=["js/recap_parcours.js"],
)
]
@ -177,26 +177,22 @@ def formsemestre_validation_etud_form(
H.append('<table style="width: 100%"><tr><td>')
if not check:
H.append(
'<h2 class="formsemestre">%s: validation %s%s</h2>Parcours: %s'
% (
etud["nomprenom"],
Se.parcours.SESSION_NAME_A,
Se.parcours.SESSION_NAME,
Se.get_cursus_descr(),
)
f"""<h2 class="formsemestre">{etud.nomprenom}: validation {
Se.parcours.SESSION_NAME_A}{Se.parcours.SESSION_NAME
}</h2>Parcours: {Se.get_cursus_descr()}
"""
)
else:
H.append(
'<h2 class="formsemestre">Parcours de %s</h2>%s'
% (etud["nomprenom"], Se.get_cursus_descr())
f"""<h2 class="formsemestre">Parcours de {etud.nomprenom}</h2>{Se.get_cursus_descr()}"""
)
H.append(
'</td><td style="text-align: right;"><a href="%s">%s</a></td></tr></table>'
% (
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]),
)
f"""</td><td style="text-align: right;"><a href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a></td></tr>
</table>
"""
)
etud_etat = nt.get_etud_etat(etudid)
@ -210,7 +206,7 @@ def formsemestre_validation_etud_form(
<div class="warning">
Impossible de statuer sur cet étudiant:
il est démissionnaire ou défaillant (voir <a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">sa fiche</a>)
</div>
"""
@ -289,7 +285,7 @@ def formsemestre_validation_etud_form(
etudid=etudid, origin_formsemestre_id=formsemestre_id
).all()
if autorisations:
H.append(". Autorisé%s à s'inscrire en " % etud["ne"])
H.append(f". Autorisé{etud.e} à s'inscrire en ")
H.append(", ".join([f"S{aut.semestre_id}" for aut in autorisations]) + ".")
H.append("</p>")
@ -732,7 +728,9 @@ def formsemestre_recap_parcours_table(
)
# Dispense BUT ?
if (etudid, ue.id) in nt.dispense_ues:
moy_ue_txt = "" if (ue_status and ue_status["is_capitalized"]) else ""
moy_ue_txt = (
"" if (ue_status and ue_status["is_capitalized"]) else ""
)
explanation_ue.append("non inscrit (dispense)")
else:
moy_ue_txt = scu.fmt_note(moy_ue)
@ -1102,7 +1100,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
ue_names = ["Choisir..."] + [
f"""{('S'+str(ue.semestre_idx)+' : ') if ue.semestre_idx is not None else ''
}{ue.acronyme} {ue.titre} ({ue.ue_code or ""})"""
}{ue.acronyme} {ue.titre or ''} ({ue.ue_code or ""})"""
for ue in ues
]
ue_ids = [""] + [ue.id for ue in ues]

View File

@ -52,7 +52,7 @@ from app.scodoc import sco_preferences
from app.scodoc import sco_etud
from app.scodoc.sco_etud import etud_sort_key
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_exceptions import ScoValueError, ScoPermissionDenied
from app.scodoc.sco_permissions import Permission
JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [
@ -118,6 +118,16 @@ def groups_view(
init_qtip=True,
)
}
<style>
div.multiselect-container.dropdown-menu {{
min-width: 180px;
}}
span.warning_unauthorized {{
color: pink;
font-style: italic;
margin-left: 12px;
}}
</style>
<div id="group-tabs">
<!-- Menu choix groupe -->
{form_groups_choice(groups_infos, submit_on_change=True)}
@ -474,15 +484,12 @@ def groups_table(
"""
from app.scodoc import sco_report
# log(
# "enter groups_table %s: %s"
# % (groups_infos.members[0]["nom"], groups_infos.members[0].get("etape", "-"))
# )
can_view_etud_data = int(current_user.has_permission(Permission.ViewEtudData))
with_codes = int(with_codes)
with_paiement = int(with_paiement)
with_archives = int(with_archives)
with_annotations = int(with_annotations)
with_bourse = int(with_bourse)
with_paiement = int(with_paiement) and can_view_etud_data
with_archives = int(with_archives) and can_view_etud_data
with_annotations = int(with_annotations) and can_view_etud_data
with_bourse = int(with_bourse) and can_view_etud_data
base_url_np = groups_infos.base_url + f"&with_codes={with_codes}"
base_url = (
@ -527,7 +534,8 @@ def groups_table(
if fmt != "html": # ne mentionne l'état que en Excel (style en html)
columns_ids.append("etat")
columns_ids.append("email")
columns_ids.append("emailperso")
if can_view_etud_data:
columns_ids.append("emailperso")
if fmt == "moodlecsv":
columns_ids = ["email", "semestre_groupe"]
@ -561,7 +569,7 @@ def groups_table(
else:
etud["_emailperso_target"] = ""
fiche_url = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"]
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"]
)
etud["_nom_disp_target"] = fiche_url
etud["_nom_disp_order"] = etud_sort_key(etud)
@ -616,7 +624,7 @@ def groups_table(
+ "+".join(sorted(moodle_groupenames))
)
else:
filename = "etudiants_%s" % groups_infos.groups_filename
filename = f"etudiants_{groups_infos.groups_filename}"
prefs = sco_preferences.SemPreferences(groups_infos.formsemestre_id)
tab = GenTable(
@ -664,28 +672,33 @@ def groups_table(
"""
]
if groups_infos.members:
Of = []
menu_options = []
options = {
"with_paiement": "Paiement inscription",
"with_archives": "Fichiers archivés",
"with_annotations": "Annotations",
"with_codes": "Codes",
"with_bourse": "Statut boursier",
"with_codes": "Affiche codes",
}
for option in options:
if can_view_etud_data:
options.update(
{
"with_paiement": "Paiement inscription",
"with_archives": "Fichiers archivés",
"with_annotations": "Annotations",
"with_bourse": "Statut boursier",
}
)
for option, label in options.items():
if locals().get(option, False):
selected = "selected"
else:
selected = ""
Of.append(
"""<option value="%s" %s>%s</option>"""
% (option, selected, options[option])
menu_options.append(
f"""<option value="{option}" {selected}>{label}</option>"""
)
H.extend(
[
"""<span style="margin-left: 2em;"><select name="group_list_options" id="group_list_options" class="multiselect" multiple="multiple">""",
"\n".join(Of),
"""<span style="margin-left: 2em;">
<select name="group_list_options" id="group_list_options" class="multiselect" multiple="multiple">""",
"\n".join(menu_options),
"""</select></span>
<script type="text/javascript">
$(document).ready(function() {
@ -701,6 +714,9 @@ def groups_table(
});
</script>
""",
"""<span class="warning_unauthorized">accès aux données personnelles interdit</span>"""
if not can_view_etud_data
else "",
]
)
H.append("</div></form>")
@ -708,41 +724,45 @@ def groups_table(
H.extend(
[
tab.html(),
"<ul>",
'<li><a class="stdlink" href="%s&fmt=xlsappel">Feuille d\'appel Excel</a></li>'
% (tab.base_url,),
'<li><a class="stdlink" href="%s&fmt=xls">Table Excel</a></li>'
% (tab.base_url,),
'<li><a class="stdlink" href="%s&fmt=moodlecsv">Fichier CSV pour Moodle (groupe sélectionné)</a></li>'
% (tab.base_url,),
"""<li>
<a class="stdlink" href="export_groups_as_moodle_csv?formsemestre_id=%s">Fichier CSV pour Moodle (tous les groupes)</a>
f"""
<ul>
<li><a class="stdlink" href="{tab.base_url}&fmt=xlsappel">Feuille d'appel Excel</a>
</li>
<li><a class="stdlink" href="{tab.base_url}&fmt=xls">Table Excel</a>
</li>
<li><a class="stdlink" href="{tab.base_url}&fmt=moodlecsv">Fichier CSV pour Moodle (groupe sélectionné)</a>
</li>
<li>
<a class="stdlink" href="export_groups_as_moodle_csv?formsemestre_id={groups_infos.formsemestre_id}">
Fichier CSV pour Moodle (tous les groupes)</a>
<em>(voir le paramétrage pour modifier le format des fichiers Moodle exportés)</em>
</li>"""
% groups_infos.formsemestre_id,
</li>""",
]
)
if amail_inst:
H.append(
'<li><a class="stdlink" href="mailto:?bcc=%s">Envoyer un mail collectif au groupe de %s (via %d adresses institutionnelles)</a></li>'
% (
",".join(amail_inst),
groups_infos.groups_titles,
len(amail_inst),
)
f"""<li>
<a class="stdlink" href="mailto:?bcc={','.join(amail_inst)
}">Envoyer un mail collectif au groupe de {groups_infos.groups_titles}
(via {len(amail_inst)} adresses institutionnelles)</a>
</li>"""
)
if amail_perso:
H.append(
'<li><a class="stdlink" href="mailto:?bcc=%s">Envoyer un mail collectif au groupe de %s (via %d adresses personnelles)</a></li>'
% (
",".join(amail_perso),
groups_infos.groups_titles,
len(amail_perso),
if can_view_etud_data:
if amail_perso:
H.append(
f"""<li>
<a class="stdlink" href="mailto:?bcc={','.join(amail_perso)
}">Envoyer un mail collectif au groupe de {groups_infos.groups_titles}
(via {len(amail_perso)} adresses personnelles)</a>
</li>"""
)
)
else:
H.append("<li><em>Adresses personnelles non renseignées</em></li>")
else:
H.append("<li><em>Adresses personnelles non renseignées</em></li>")
H.append(
"""<li class="unauthorized">adresses mail personnelles protégées</li>"""
)
H.append("</ul>")
@ -772,6 +792,10 @@ def groups_table(
filename = "liste_%s" % groups_infos.groups_filename
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE)
elif fmt == "allxls":
if not can_view_etud_data:
raise ScoPermissionDenied(
"Vous n'avez pas la permission requise (ViewEtudData)"
)
# feuille Excel avec toutes les infos etudiants
if not groups_infos.members:
return ""
@ -829,7 +853,9 @@ def groups_table(
etud, groups_infos.formsemestre_id
)
m["parcours"] = Se.get_cursus_descr()
m["code_cursus"], _ = sco_report.get_code_cursus_etud(etud)
m["code_cursus"], _ = sco_report.get_code_cursus_etud(
etud["etudid"], sems=etud["sems"]
)
rows = [[m.get(k, "") for k in keys] for m in groups_infos.members]
title = "etudiants_%s" % groups_infos.groups_filename
xls = sco_excel.excel_simple_table(titles=titles, lines=rows, sheet_name=title)
@ -879,8 +905,9 @@ def tab_absences_html(groups_infos, etat=None):
% groups_infos.groups_query_args,
"""<li><a class="stdlink" href="trombino?%s&fmt=pdflist">Liste d'appel avec photos</a></li>"""
% groups_infos.groups_query_args,
"""<li><a class="stdlink" href="groups_export_annotations?%s">Liste des annotations</a></li>"""
% groups_infos.groups_query_args,
f"""<li><a class="stdlink" href="groups_export_annotations?{groups_infos.groups_query_args}">Liste des annotations</a></li>"""
if authuser.has_permission(Permission.ViewEtudData)
else """<li class="unauthorized" title="non autorisé">Liste des annotations</li>""",
"</ul>",
]
)
@ -899,14 +926,19 @@ def tab_absences_html(groups_infos, etat=None):
"""
)
# Lien pour ajout fichiers étudiants
if authuser.has_permission(Permission.EtudAddAnnotations):
text = "Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)"
if authuser.has_permission(
Permission.EtudAddAnnotations
) and authuser.has_permission(Permission.ViewEtudData):
H.append(
f"""<li><a class="stdlink" href="{
url_for('scolar.etudarchive_import_files_form',
scodoc_dept=g.scodoc_dept,
group_id=group_id
)}">Télécharger des fichiers associés aux étudiants (e.g. dossiers d'admission)</a></li>"""
)}">{text}</a></li>"""
)
else:
H.append(f"""<li class="unauthorized" title="non autorisé">{text}</li>""")
H.append("</ul></div>")
return "".join(H)

View File

@ -508,15 +508,20 @@ def students_import_admission(
H = [html_sco_header.sco_header(page_title="Import données admissions")]
H.append("<p>Import terminé !</p>")
H.append(
'<p><a class="stdlink" href="%s">Continuer</a></p>'
% url_for(
f"""<p><a class="stdlink" href="{ url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
}">Continuer</a></p>"""
)
if diag:
H.append("<p>Diagnostic: <ul><li>%s</li></ul></p>" % "</li><li>".join(diag))
H.append(
f"""<p>Diagnostic: <ul><li>{
"</li><li>".join(diag)
}</li></ul></p>
"""
)
return "\n".join(H) + html_sco_header.sco_footer()

View File

@ -669,7 +669,7 @@ def etuds_select_boxes(
elink = """<a class="discretelink %s" href="%s">%s</a>""" % (
c,
url_for(
"scolar.ficheEtud",
"scolar.fiche_etud",
scodoc_dept=g.scodoc_dept,
etudid=etud["etudid"],
),

Some files were not shown because too many files have changed in this diff Show More