Compare commits

...

456 Commits

Author SHA1 Message Date
Emmanuel Viennet 18b1f00586 version 9.6.964 2024-04-24 20:58:02 +02:00
Iziram 6b985620e9 Assiduité : signal_assiduites_diff : plage depuis args url closes #741 2024-04-24 20:55:21 +02:00
Iziram 4d234ba353 Assiduité : désactiver saisie présence closes #793 2024-04-24 20:55:21 +02:00
Iziram 5d45fcf656 Assiduité : signal_assiduites_group : bug fix suppr assi autre 2024-04-24 20:55:21 +02:00
Iziram 0a5919b788 Assiduité : pseudo-fix (catch + http error) #872 2024-04-24 20:55:21 +02:00
Iziram 09f4525e66 Assiduité : maj couleurs minitimeline + legende 2024-04-24 20:55:21 +02:00
Emmanuel Viennet 0bc57807de release build script using gitea again 2024-04-24 20:37:03 +02:00
Emmanuel Viennet 87aaf12d27 Protect against Reflected XSS on home page (and other exception-handling pages) 2024-04-23 18:28:00 +02:00
Emmanuel Viennet c8ab9b9b6c Invalidation cache lors d'une erreur sur association UE/Niveau. Peut-être cause de #874. 2024-04-15 18:06:26 +02:00
Emmanuel Viennet ad7b48e110 Calendrier évaluations: fix #875 2024-04-15 17:53:02 +02:00
Emmanuel Viennet f2ce16f161 Archive PV: gzip large files 2024-04-15 03:21:32 +02:00
Emmanuel Viennet 1ddf9b6ab8 Fix: création utilisateur si un seul département 2024-04-12 15:50:53 +02:00
Emmanuel Viennet 0a2e39cae1 Ajoute aide sur édition parcours UEs 2024-04-12 01:10:42 +02:00
Emmanuel Viennet a194b4b6e0 Edition parcours UE: si tous cochés, tronc commun 2024-04-12 01:05:02 +02:00
Emmanuel Viennet cbe85dfb7d anonymize_users: ignore admin 2024-04-12 01:04:27 +02:00
Emmanuel Viennet beba69bfe4 Améliore/met à jour tests unitaires API 2024-04-11 06:00:00 +02:00
Emmanuel Viennet 41fec29452 Bulletin BUT: ne mentionne pas les évaluations rattrapage/session2 sans notes. (c'est déjà le cas en classic) 2024-04-11 01:45:25 +02:00
Emmanuel Viennet 9bd05ea241 Modify SCO_URL in all js: no trailing slash. 2024-04-11 01:44:17 +02:00
Emmanuel Viennet 58b831513d Améliore traitement des erreurs lors de la génération des PDF 2024-04-10 15:29:30 +02:00
Emmanuel Viennet b861aba6a3 Tableaux génériques: possibilité de déclarer un colonne seulement pour excel. Assiduité: ajout etudid et NIP a visu_assi_group: closes #873. 2024-04-10 15:09:32 +02:00
Emmanuel Viennet c2443c361f Améliore page activation module entreprises. Implements #634 2024-04-09 00:36:46 +02:00
Emmanuel Viennet ab4731bd43 Suppression des anciennes fonctions ScoDoc7 donnant les URLs de base. 2024-04-08 18:57:00 +02:00
Emmanuel Viennet c17bc8b61b Fix: liste semestres avec code 2024-04-08 16:26:38 +02:00
Emmanuel Viennet e44a5ee55d Corrige templates formsemestre 2024-04-07 19:52:22 +02:00
Emmanuel Viennet a747ed22e2 Ajoute équivalences pour ref. comp. QLIO 2024-04-07 19:51:34 +02:00
Emmanuel Viennet 5d0a932634 Bulletins BUT: utilisation de l'abbréviation du titre module si présente. 2024-04-06 12:33:07 +02:00
Emmanuel Viennet 2b150cf521 Modif config Jina2. Refonte ScoData, fournit par défaut à tous les templates. 2024-04-06 12:16:53 +02:00
Emmanuel Viennet 5a5ddcacd7 Associer une formation BUT à un nouveau référentiel 'équivalent'. 2024-04-05 23:41:34 +02:00
Emmanuel Viennet 3f6e65b9da Elimine @cached_property sur Identite, pourrait provoquer incohérences temporaires en multithread 2024-04-05 11:00:01 +02:00
Emmanuel Viennet 5eba6170a5 Fix: typo bloquant affichage formations avec UEs sans semestre_idx 2024-04-05 10:11:34 +02:00
Emmanuel Viennet bd9bf87112 Enrichissement du tableau des formations (coche 'détails') 2024-04-05 00:23:29 +02:00
Emmanuel Viennet a0e2af481f Fonction expérimentale pour changer le ref. de compétences d'une formation 2024-04-05 00:22:14 +02:00
Emmanuel Viennet 42e8f97441 Fix: missing exception 2024-04-04 11:23:26 +02:00
Emmanuel Viennet 8ec0171ca0 Script préparation démos: renommage de tous les étudiants 2024-04-03 19:02:40 +02:00
Emmanuel Viennet 6dfab2d843 Fix typo affichage heures 2024-04-03 18:47:44 +02:00
Emmanuel Viennet 523ec59833 Harmonisation formats affichage dates et heures 2024-04-02 23:37:23 +02:00
Emmanuel Viennet bde6325391 Enrichi tableau jury BUT PV 2024-04-02 17:11:07 +02:00
Emmanuel Viennet 0577347622 Tableau décision jury BUT excel: améliore colonne ECTS 2024-04-02 16:53:04 +02:00
Emmanuel Viennet 28d46e413d Filtrage par groupes dans els pages statistiques: fix #791 2024-03-31 23:04:54 +02:00
Emmanuel Viennet 126ea0741a Edition UE: cosmetic + arg check + invalidation cache desassoc_ue_niveau 2024-03-31 10:21:44 +02:00
Emmanuel Viennet a5b5f49f76 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-03-29 16:39:36 +01:00
Iziram b7ab10bf4e Assiduité : docs : erratum samples 2024-03-29 16:38:14 +01:00
Emmanuel Viennet 3e0b19c4a8 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-03-29 16:37:19 +01:00
Emmanuel Viennet 1dd5187fae lien vers doc évaluations 2024-03-29 16:36:05 +01:00
Iziram 9a3a7d33b2 Assiduité : Docs : ajout samples 2024-03-29 16:16:13 +01:00
Iziram a7569fe4f5 Assiduité : signal_assiduites_diff : fix visibilité tableau 2024-03-29 16:15:19 +01:00
Iziram 79e973f06d Assiduité : XXX todo #831 (non fini) 2024-03-29 15:36:35 +01:00
Emmanuel Viennet b6940e4882 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-03-27 17:13:36 +01:00
Emmanuel Viennet 1f24095c57 Ajout timestamp supplémentaire dans log mail 2024-03-27 17:13:05 +01:00
Emmanuel Viennet 0ed2455028 ne présente plus le lien 'ajouter semestre' si on n'a pas le droit 2024-03-27 17:12:28 +01:00
Emmanuel Viennet b841b2f708 Remplace préférence dépt. bul_display_publication par paramètre global: passerelle_disabled + cosmetic 2024-03-27 16:27:45 +01:00
Iziram 0fa1478138 Assiduité : recup_assiduites_plage: ajout justificatifs 2024-03-27 15:01:58 +01:00
Iziram 85ad7b5f29 Assiduité : suppr pref limite_annee + closes #766 2024-03-27 11:51:50 +01:00
Emmanuel Viennet 6bfd461bf2 Fix: jury BUT cas particulier sans ue 2024-03-27 09:22:34 +01:00
Emmanuel Viennet e1f1a95a14 merge 2024-03-26 14:43:11 +01:00
Emmanuel Viennet 70e3006981 merge 2024-03-26 14:37:02 +01:00
Emmanuel Viennet bae46c2794 Page accueil département: refonte liste semestres en cours 2024-03-26 14:17:42 +01:00
Iziram b1055a4ebe Assiduité : signal_assiduites_group : fix bug photo etud 2024-03-26 09:02:55 +01:00
Iziram b2ef6a4c53 Assiduité : liste_assiduites : formatage des dates 2024-03-25 16:19:05 +01:00
Iziram a7c7bd655d Assiduité : ajout_justif_etud : dates avec heures 2024-03-25 16:15:01 +01:00
Iziram 1309043a98 Assiduités : assiduites_bubble : ajout d'un bouton 📝 pour éditer l'assiduité visée 2024-03-25 15:19:59 +01:00
Iziram a75b41ca5f Assiduité : signal_assiduites_diff : vérification date 2024-03-25 15:12:08 +01:00
Emmanuel Viennet 8df25ca02f Ajout infos semestres dans bulletin classique JSON. Close #583 2024-03-25 14:41:20 +01:00
Emmanuel Viennet 61f9dddeb6 Modif. clé trie étudiants et utilisation dans éditeur partition. 2024-03-25 14:41:20 +01:00
Emmanuel Viennet a1f5340935 Débouchés: tags. Implements #396 2024-03-25 14:41:20 +01:00
Emmanuel Viennet 68128c27d5 Conversion date naissance étudiant. complète #593. 2024-03-25 14:41:20 +01:00
Emmanuel Viennet 8ecaa2bed0 Conversion dates édition évaluations et formsemestres. Fix #593. 2024-03-25 14:41:20 +01:00
Emmanuel Viennet 7c61dd8d63 Cosmetic + reorganisation css edit formation 2024-03-25 14:41:20 +01:00
Emmanuel Viennet f493ba344f Jury BUT: améliore présentation et information sur les UEs capitalisées. Closes #670 2024-03-25 14:41:20 +01:00
Emmanuel Viennet f5079d9aef Jury BUT: affiche la liste des modules avec note en ATTente 2024-03-25 14:41:20 +01:00
Emmanuel Viennet 55add2ffb3 cosmetic: eye, table semestres 2024-03-25 14:41:20 +01:00
Emmanuel Viennet 5865b67652 Adapte ref. pour test_api_formsemestre.py sans nom_short 2024-03-25 14:41:20 +01:00
Emmanuel Viennet 3c8b088d5e Jury BUT auto: avertissement si semestres pairs non bloqués 2024-03-25 14:41:20 +01:00
Emmanuel Viennet 2da359ae41 Fix export excel table jury. Closes #868 2024-03-25 14:41:20 +01:00
Emmanuel Viennet 09ec53f573 Ajout infos semestres dans bulletin classique JSON. Close #583 2024-03-24 15:47:42 +01:00
Emmanuel Viennet 3787e0145a Modif. clé trie étudiants et utilisation dans éditeur partition. 2024-03-24 14:34:55 +01:00
Emmanuel Viennet edf989ee04 Débouchés: tags. Implements #396 2024-03-24 11:23:40 +01:00
Emmanuel Viennet 203f3a5342 Conversion date naissance étudiant. complète #593. 2024-03-24 10:34:02 +01:00
Emmanuel Viennet 161f8476ca Conversion dates édition évaluations et formsemestres. Fix #593. 2024-03-24 09:17:01 +01:00
Emmanuel Viennet d419d75515 Cosmetic + reorganisation css edit formation 2024-03-24 08:27:09 +01:00
Emmanuel Viennet f23630d7fd Jury BUT: améliore présentation et information sur les UEs capitalisées. Closes #670 2024-03-24 07:39:47 +01:00
Emmanuel Viennet fa0417f0b1 Jury BUT: affiche la liste des modules avec note en ATTente 2024-03-23 13:23:26 +01:00
Emmanuel Viennet 12256dc3d4 cosmetic: eye, table semestres 2024-03-23 10:17:49 +01:00
Emmanuel Viennet 46529917ea Adapte ref. pour test_api_formsemestre.py sans nom_short 2024-03-22 22:05:24 +01:00
Emmanuel Viennet 2367984848 Jury BUT auto: avertissement si semestres pairs non bloqués 2024-03-22 21:56:52 +01:00
Emmanuel Viennet 46c86d2928 Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into iziram-main96 2024-03-22 17:45:51 +01:00
Emmanuel Viennet 715e4f94ee Fix export excel table jury. Closes #868 2024-03-22 17:39:48 +01:00
Iziram b2e6ef63b9 Assiduité : traitement des justificatifs closes #818 2024-03-22 15:44:47 +01:00
Iziram 30560e5860 Assiduité : etudid dans tableau excel 2024-03-22 15:44:36 +01:00
Iziram 0fbcfb1124 Assiduites : edit_justificatif_etud - retour à la page précédente (back_url) closes #864 2024-03-22 15:44:27 +01:00
Iziram 2daae1c9c5 Assiduité : bilan_dept : inversion ordre tableau/téléchargement 2024-03-22 15:44:15 +01:00
Emmanuel Viennet 635269ff36 Modifie FormSemestre.etudids_actifs: retire @cached_property. Tests OK. 2024-03-22 11:49:51 +01:00
Emmanuel Viennet 4aa30a40bd Fix: front&back saisie note sur DEM 2024-03-21 16:42:28 +01:00
Emmanuel Viennet 03c03f3725 Fix form recherche par étape 2024-03-21 15:54:56 +01:00
Emmanuel Viennet 29eb8c297b Améliore page accueil dept.: formation, cosmétique, export excel 2024-03-21 13:21:25 +01:00
Emmanuel Viennet 38032a8c09 Ré-écriture de la page d'accueil de département. Template. 2024-03-21 12:06:34 +01:00
Emmanuel Viennet 2f2d98954c Maquette: introduit scobox, reprend certaines pages. WIP 2024-03-20 18:13:19 +01:00
Emmanuel Viennet 2e5d94f048 Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into iziram-main96 2024-03-19 20:59:28 +01:00
Emmanuel Viennet 1b1b8ebdc4 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-03-19 20:59:13 +01:00
Emmanuel Viennet 9c6db169f3 Restreint accès aux bulletins PDF si formsemestre.bul_hide_xml (sémantique changée) + WIP tests unitaires API 2024-03-19 18:22:02 +01:00
Iziram 8ded16b94f Assiduité : liste_assi : colonne code et titre module closes #865 2024-03-19 16:30:13 +01:00
Iziram 5d10ee467e Assiduité : téléchargement des assiduités 2024-03-19 16:30:08 +01:00
Emmanuel Viennet 763f60fb3d Fix: /etud_info_html si pas de données admission 2024-03-19 09:34:03 +01:00
Iziram 7af0dd1e1e Assiduite : signal_assiduites_diff tableau transposer + modifs mineurs 2024-03-18 17:41:17 +01:00
Emmanuel Viennet dece9a82d1 Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into iziram-main96 2024-03-18 14:24:27 +01:00
Emmanuel Viennet 0262b6e2ac Affichage nb évaluations en attente su rtableau de bord. Ne consière plus les évaluations bloquées comme en attente. 2024-03-18 14:20:34 +01:00
Emmanuel Viennet f8f47e05ff Fix: formsemestre_table_estim_cost: error handling 2024-03-17 13:28:49 +01:00
Iziram b74d525c28 Assiduité: signal_assiduites_diff OK 2024-03-15 16:08:41 +01:00
Iziram c617ee321a Assiduité : signal_assiduites_diff suppr titres 2024-03-14 15:39:42 +01:00
Iziram 56ec4ba43d Assiduité : page signal_assiduites_diff WIP 2024-03-13 16:35:56 +01:00
Iziram d14f7e21b7 Assiduité : calendrier utilisation couleur générale (assiduites.css) 2024-03-11 11:39:36 +01:00
Iziram c3cb1da561 Assiduité : refonte signal_assiduites_group 2024-03-11 11:39:06 +01:00
Iziram cce60d432d Assiduité : timeline ajout timepicker 2024-03-11 11:37:58 +01:00
Iziram 4386994f7d Assiduité : bilan_etud suppr bouton suppression + avertissement tableau 2024-03-11 11:37:21 +01:00
Iziram fddfddfa7b Assiduité : minitimeline utilisation couleur assiduite + assiduite_bubble 2024-03-11 11:36:24 +01:00
Iziram 39dca32d2e Assiduité : date_utils suppression scodoc-datetime + ajout time conflit 2024-03-11 11:35:43 +01:00
Iziram e2b9cd3ded Assiduité : suppression assiduite js non utilisé 2024-03-11 11:35:09 +01:00
Iziram be227f4a2f Assiduité : Prompt : blocage scroll + fermeture on success 2024-03-11 11:33:05 +01:00
Emmanuel Viennet 959a98d0a2 Fix bug: get_assiduites_count / feuille_preparation_jury 2024-03-10 04:44:42 +01:00
Emmanuel Viennet 35a038fd3a code fmt 2024-03-03 23:27:29 +01:00
Emmanuel Viennet b46556c189 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-03-03 23:26:59 +01:00
Iziram 71f90f5261 Assiduité : annulation / suppression fichier justif 2024-03-01 16:35:39 +01:00
Iziram 1b037d6c7c Assiduité : fix format nbabs sidebar 2024-03-01 16:04:16 +01:00
Emmanuel Viennet 60a97b7baf Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-03-01 15:55:03 +01:00
Iziram 0332553587 Assiduité : correction bug cache 2024-03-01 12:56:00 +01:00
Iziram 958cf435c8 Assiduité : ajout tests unitaire cache + cas justificatifs 2024-03-01 12:56:00 +01:00
Iziram c69e9c34a0 Assiduité : fix format date 'absences du' 2024-03-01 12:56:00 +01:00
Iziram 17f8771b0b Assiduité : fix bug tableau (actualisation sur les lignes) 2024-03-01 12:56:00 +01:00
Iziram 7eb41fb2eb Assiduité : ajout test api manquant closes #689 2024-03-01 12:56:00 +01:00
Iziram a79ca4a17d Assiduité : suppression ancien tableaux (inutilisé) 2024-03-01 12:56:00 +01:00
Emmanuel Viennet 411ef8ae0d vocabulaire: portail > passerelle 2024-03-01 12:56:00 +01:00
Emmanuel Viennet 169bf17fdd Ajout colonne référentiel à la table des formations 2024-03-01 12:56:00 +01:00
Emmanuel Viennet 75d4c110a8 Améliore anonymisation (users) + lien contact + cosmetic 2024-03-01 12:56:00 +01:00
Emmanuel Viennet 9003a2ca87 vocabulaire: portail > passerelle 2024-03-01 12:03:19 +01:00
Emmanuel Viennet 55ecaa45a9 Ajout colonne référentiel à la table des formations 2024-03-01 12:03:00 +01:00
Emmanuel Viennet ab39454a0d Améliore anonymisation (users) + lien contact + cosmetic 2024-03-01 11:12:36 +01:00
Iziram 5158bd0c8f Assiduité : optimisation justification assiduités 2024-02-29 14:20:39 +01:00
Iziram 21b2e0f582 Assiduité : fix bug module selector signal_assiduites_group 2024-02-29 08:47:03 +01:00
Emmanuel Viennet e56cbfc5a2 Précision nb abs sur table recap 2024-02-28 23:22:15 +01:00
Emmanuel Viennet 9cdab8d1ed Merge branch 'pe-but-v4' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-02-28 12:12:40 +01:00
Cléo Baras 7cdba43e86 Déploie l'option min/max/moy aux tableurs intermédiaires 2024-02-28 11:59:54 +01:00
Iziram 079348bb87 Assiduité : ajout modif justi dans journal etud closes #814 2024-02-28 11:35:16 +01:00
Iziram c882e0d6a0 Assiduité : couleur minitimeline calendrier fixes #817 2024-02-28 11:09:01 +01:00
Cléo Baras 9c7576154c Merge branch 'scodoc-master' into pe-but-v4 2024-02-28 11:02:31 +01:00
Cléo Baras ce0d5ec9fd Corrige prise en compte des inscriptions aux UEs pour les moyennes par tag 2024-02-28 11:00:32 +01:00
Iziram 3d6be2f200 Assiduité : fix bug assi jour complet + affichage calendrier 2024-02-28 10:50:15 +01:00
Cléo Baras e675064cae Corrige bug ressemtagbut sans notes 2024-02-28 10:36:27 +01:00
Iziram 185e061f01 Assiduité : msg erreur ajout_assiduite_etud 2024-02-28 10:23:47 +01:00
Iziram e4c889ec8a Assiduité : calendrier scroll horizontal 2024-02-28 09:26:15 +01:00
Emmanuel Viennet 7ef45e0bac Fix calendrier 2024-02-27 23:17:27 +01:00
Iziram f242fee5ff Assiduité : calendrier fix str_to_time 2024-02-27 23:14:12 +01:00
Emmanuel Viennet c960d943d2 Merge assiduites 2024-02-27 21:51:43 +01:00
Emmanuel Viennet 741168a065 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-02-27 21:40:51 +01:00
Emmanuel Viennet 5c9126d263 Merge PE v4 2024-02-27 21:23:45 +01:00
Emmanuel Viennet ce63b7f2f5 Merge branch 'pe-but-v4' of https://scodoc.org/git/cleo/ScoDoc-PE into cleo 2024-02-27 21:15:38 +01:00
Emmanuel Viennet 5e5cb015d0 cosmetic 2024-02-27 21:10:55 +01:00
Cléo Baras 5fc1800f70 Option nom des colonnes pour publipostage 2024-02-27 19:42:04 +01:00
Cléo Baras 2459356245 Règle le pb d'affichage de l'export excel final 2024-02-27 18:33:33 +01:00
Cléo Baras b1602f0cf3 Neutralise une option 2024-02-27 18:23:31 +01:00
Cléo Baras ba28d5f3c8 Ajout de l'option "Afficher les colonnes min/max/moy" 2024-02-27 18:16:25 +01:00
Cléo Baras b9b9a172c7 Ajout de l'option "Générer les moyennes par RCUEs (compétences)" 2024-02-27 18:00:06 +01:00
Cléo Baras c2a66b607f Ajout de l'option "Générer les moyennes des ressources et des SAEs par UEs" 2024-02-27 17:22:30 +01:00
Cléo Baras 802e8f4648 Ajout de l'option "Générer les moyennes sur les tags" 2024-02-27 17:11:00 +01:00
Iziram 3184d5d92e Assiduite : ordre options select module 2024-02-27 17:03:15 +01:00
Cléo Baras cf7d7d2db8 Merge remote-tracking branch 'scodoc/cleo-pe-BUT-v2' into pe-but-options
# Conflicts:
#	app/pe/pe_view.py
#	app/templates/pe/pe_view_sem_recap.j2
2024-02-27 16:45:15 +01:00
Cléo Baras 5ea65433be Coquilles 2024-02-27 16:35:36 +01:00
Cléo Baras 35a20c3307 Coquille 2024-02-27 16:30:00 +01:00
Cléo Baras 8acd9a12d4 Merge branch 'scodoc-master' into pe-but-v4 2024-02-27 16:20:32 +01:00
Cléo Baras 2020114c1b Ajout des nvo fichiers du master 2024-02-27 16:20:23 +01:00
Cléo Baras a93aa19449 Fin du calcul des moyennes par ressource/saes 2024-02-27 16:18:08 +01:00
Iziram c620c3b0e1 Assiduité : ménage (linter) 2024-02-27 15:59:48 +01:00
Iziram c2e77846b9 Assiduité : fix préférence limite année 2024-02-27 15:06:31 +01:00
Cléo Baras 28b25ad681 Check 2024-02-27 14:58:15 +01:00
Cléo Baras 5ea79c03a3 Ajoute les moyennes de ressources/saes 2024-02-27 14:39:14 +01:00
Emmanuel Viennet fdcf6388f5 Jury BUT: erreur si UE d'un RCUE sans niveau de comp. 2024-02-27 12:59:48 +01:00
Iziram 9dcaf70e18 Assiduité : fix bugs calendrier 2024-02-27 08:58:09 +01:00
Emmanuel Viennet 20d4b4e1b3 cosmetic: avertissements jury 2024-02-26 21:53:45 +01:00
Emmanuel Viennet aaaf41250a Assiduité: formattage comptes marge gauche 2024-02-26 21:28:27 +01:00
Iziram b3b47a755f Assiduite : Justif 24h + test unit 2024-02-26 18:26:04 +01:00
Emmanuel Viennet bc5292b165 Edition des évaluations, nettoyage code, fix #799. Tests OK. 2024-02-26 17:20:36 +01:00
Emmanuel Viennet ee601071f5 cosmetic: tableau bord semestre 2024-02-26 14:14:30 +01:00
Emmanuel Viennet 0cf3b0a782 formsemestre_status: affiche modules avec évals bloquées 2024-02-26 13:55:04 +01:00
Emmanuel Viennet 49a5ec488d get_etud_ue_status: ignore error if missing etud 2024-02-26 12:54:27 +01:00
Cléo Baras a50bbe9223 Fin traitement coeffs 2024-02-26 12:03:19 +01:00
Cléo Baras 57d616da1a Traitement des coeffs (état intermédiaire) 2024-02-26 10:29:45 +01:00
Emmanuel Viennet c0a965d774 Bloque saisie jury si évaluation à paraitre. Modif icon warning. Closes #858 2024-02-25 22:35:48 +01:00
Emmanuel Viennet 1c01d987be Evaluations bloquées jusqu'à une date. Implements #858 2024-02-25 16:58:59 +01:00
Cléo Baras 21a794a760 Diverses améliorations d'affichage 2024-02-25 16:25:28 +01:00
Emmanuel Viennet 41944bcd29 Cache (redis): change timeout par défaut (rafraichissement évaluations chaque heure) 2024-02-25 13:04:50 +01:00
Cléo Baras 960f8a3462 Améliore les affichages de debug 2024-02-25 12:45:58 +01:00
Cléo Baras 6821a02956 Fiche par étudiant 2024-02-25 10:39:51 +01:00
Emmanuel Viennet 47a42d897e Test unitaire évaluation bonus 2024-02-24 17:01:14 +01:00
Emmanuel Viennet 7f32f1fb99 Evaluations de type bonus. Implements #848 2024-02-24 16:49:41 +01:00
Cléo Baras eb56182407 Fiche par étudiant 2024-02-24 12:21:42 +01:00
Cléo Baras 02b057ca5a Finalisation des interclassements 2024-02-24 10:48:38 +01:00
Cléo Baras eff28d64f9 Divers 2024-02-24 09:31:47 +01:00
Emmanuel Viennet 81fab97018 2 small fixes 2024-02-23 19:03:02 +01:00
Emmanuel Viennet a8a711b30a 9.6.943 2024-02-22 18:33:51 +01:00
Emmanuel Viennet 46cdaf75b8 Fix unit tests 2024-02-22 18:32:51 +01:00
Emmanuel Viennet d1d89cc427 Bulletin: détection erreur rare ? 2024-02-22 17:39:18 +01:00
Emmanuel Viennet 61d35ddac0 Fix: création modules (parcours) 2024-02-22 17:22:56 +01:00
Emmanuel Viennet c492cf550a Fix: typo check_formation_ues 2024-02-22 16:50:25 +01:00
Emmanuel Viennet 2dd7154036 Fix: missing UE.ects 2024-02-22 16:46:19 +01:00
Emmanuel Viennet 13e7bd4512 Envoi bulletin, génération classeur: choix groupe étudiants 2024-02-22 16:43:00 +01:00
Emmanuel Viennet f1ce70e6de Envoi bulletin, génération classeur: choix groupe étudiants 2024-02-22 16:34:11 +01:00
Emmanuel Viennet a8ff540e95 Template base: inclusion multiselect + reorganisation 2024-02-22 16:31:42 +01:00
Emmanuel Viennet cc3f5d393f Fix: passage d'un semestre à l'autre sans décision de jury 2024-02-22 13:34:03 +01:00
Emmanuel Viennet 7c794c01d1 Tableau bord semestre: avertissement modules non conformes 2024-02-21 22:39:12 +01:00
Cléo Baras 746314b2fb Etat intermédiaire sur les interclassements et la synthèse du jury (données sans notes ou tags à revoir) 2024-02-21 20:02:38 +01:00
Emmanuel Viennet 624ea39edd Fix: edition coef UE null 2024-02-21 17:51:54 +01:00
Emmanuel Viennet 853bc31422 Fix: traitement erreur si code étape Apo invalide + ajout total ECTS sur fiche 2024-02-21 17:48:19 +01:00
Emmanuel Viennet 09d59848d6 Fix API unit tests (assoc niveaux formation test) 2024-02-21 15:57:38 +01:00
Emmanuel Viennet f31eca97bb Suppression ancien code jury BUT monosemestre inutile 2024-02-21 14:54:17 +01:00
Emmanuel Viennet 3844ae46d1 Fix (imports, tests). API unit tests breaks on BUT config (bul. court). 2024-02-20 21:55:32 +01:00
Emmanuel Viennet fae9fbdd09 Diverses améliorations pour faciliter la config BUT. Voir #862 2024-02-20 21:30:08 +01:00
Cléo Baras 40a57a9b86 Etat intermédiaire 2024-02-20 21:12:18 +01:00
Cléo Baras b5125fa3d7 Génère les RCSTag (mais sont-ils bons ??) 2024-02-20 20:52:44 +01:00
Cléo Baras 0f446fe0d3 Renomme RCs pour faciliter interprétation + corrige détection des RCSemX 2024-02-20 16:22:22 +01:00
Cléo Baras 5f656b431b Corrige modif non voulue dans tests/unit/yaml_setud_but.py 2024-02-20 09:18:03 +01:00
Cléo Baras 83059cd995 Relecture + améliorations diverses (dont tri systématique par etudids_sorted, acronymes_sorted, competences_sorted) des dataframes 2024-02-20 09:13:19 +01:00
Cléo Baras 8de1a44583 Corrige tri etuds/compétences dans traduction SxTag -> RSCTag 2024-02-19 20:12:49 +01:00
Cléo Baras 491d600bd4 Finalisation des SxTags avec situation dans lesquels éval du tag en cours 2024-02-19 20:00:11 +01:00
Emmanuel Viennet 56aa5fbba3 Modernise code inscription/passage semestre. Closes #859 2024-02-19 19:10:20 +01:00
Cléo Baras d6a75b176e Amélioration structure codes + mise en place des capitalisations dans les SxTag 2024-02-19 14:50:38 +01:00
Emmanuel Viennet e6d61fcd8a export nationalite. Closes #860 2024-02-19 14:10:55 +01:00
Cléo Baras 70f399e8b7 Coquilles (état intermédiaire) 2024-02-18 19:50:49 +01:00
Cléo Baras 68bd20f8de Mise en place des RCRCF + de l'agrégation des coeff pour les moyennes de RCSTag 2024-02-18 19:24:03 +01:00
Cléo Baras 1716daafde Améliorations diverses (suite) 2024-02-17 03:30:19 +01:00
Cléo Baras 5e49384a90 Améliorations diverses 2024-02-17 02:35:58 +01:00
Cléo Baras 828c619c74 Améliorations diverses 2024-02-17 02:35:43 +01:00
Cléo Baras b8cb592ac9 Calcul des RCS de type Sx (avec sélection du max des UEs des redoublants) 2024-02-16 16:07:48 +01:00
Cléo Baras d8381884dc Merge branch 'scodoc-master' into pe-moy-par-ue 2024-02-16 09:37:52 +01:00
Cléo Baras 883028216f Débute l'aggrégation des moyennes dans des RCS de type Sx (prise en compte de la meilleure des 2 UE en cas de redoublement) 2024-02-15 17:05:03 +01:00
Emmanuel Viennet d140240909 Code: modernisation (ue_list, ...) et nettoyage. Tests ok. 2024-02-14 21:45:58 +01:00
Cléo Baras 267dbb6460 Ajoute les moy par ue et par tag au semtag 2024-02-14 17:00:05 +01:00
Cléo Baras 02a73de04d Améliore l'analyse des abandons de formation (sans prise en compte du formsemestre_base) 2024-02-14 15:19:21 +01:00
Cléo Baras e78a2d3ffe Corrige bug sur l'analyse des abandons de formation 2024-02-14 14:34:22 +01:00
Emmanuel Viennet a200be586a ue.titre peut être null + formattage code 2024-02-13 13:55:16 +01:00
Emmanuel Viennet 607604f91e Assiduite: retire arg inutile qui faisait planter sur sems vides 2024-02-12 10:21:05 +01:00
Emmanuel Viennet 8eedac0f03 orthographe 2024-02-12 10:12:46 +01:00
Emmanuel Viennet aea2204d9e PE: fix moy promo (max -> moy) 2024-02-12 09:26:23 +01:00
Emmanuel Viennet 9c15cbe647 PE: Fix moys (thx @jmpulille). Ajoute log au zip. 2024-02-11 22:06:37 +01:00
Emmanuel Viennet 6761f5a620 Ajoute une vérification sur les semestres BUT: association aux parcours 2024-02-11 21:19:45 +01:00
Emmanuel Viennet 69a53adb55 Migration pour clés etudiant/annotations et modif clé ModuleImpl/responsable 2024-02-11 12:21:48 +01:00
Emmanuel Viennet b30ea5f5fd Annotations étudiants: API et tests 2024-02-11 12:05:43 +01:00
Emmanuel Viennet 052fb3c7b9 Merge pull request 'Ajout des annotations dans l'API' (#857) from lyanis/ScoDoc:api-annot into master
Reviewed-on: #857
2024-02-11 10:09:50 +01:00
Lyanis Souidi dbd0124c2c Retrait des annotations inutiles sur certaines routes API 2024-02-10 19:32:43 +01:00
Lyanis Souidi e989a4ffa8 Restreint l'accès aux annotations via l'API à la permission ViewEtudData 2024-02-10 15:53:57 +01:00
Lyanis Souidi 6ae2b0eb5f Merge branch 'master' into api-annot 2024-02-10 15:14:14 +01:00
Emmanuel Viennet d7f3376103 fiche_etud: restreint l'accès aux annotations à la permission ViewEtudData 2024-02-10 15:02:18 +01:00
Lyanis Souidi 677415fbfc Ajout des annotations dans l'API 2024-02-10 14:11:34 +01:00
Emmanuel Viennet bcb801662a WIP: PE : form paramétrage pe_view_sem_recap 2024-02-09 21:52:33 +01:00
Emmanuel Viennet 6cbeeedb1c Ajout colonne module sur page recap inscriptions 2024-02-09 15:36:37 +01:00
Emmanuel Viennet 39e7ad3ad6 clarifie titre option publication bulletins 2024-02-09 15:32:48 +01:00
Emmanuel Viennet 177d38428e Fix: jury BUT si une UE a été déassociée depuis la validation 2024-02-09 13:47:48 +01:00
Emmanuel Viennet f4c1d00046 PE: reformattage code, small bug fix. 2024-02-08 22:31:46 +01:00
Emmanuel Viennet 86c12dee08 complete previous commit 2024-02-08 16:26:50 +01:00
Emmanuel Viennet 8cf85f78a8 Assiduite: filtrage par formsemestre: intersection dates 2024-02-08 16:23:14 +01:00
Emmanuel Viennet 9ec0ef27ba Assiduite: visu_assi_group avec restriction aux modules du semestre. 2024-02-08 15:56:58 +01:00
Emmanuel Viennet c8ac796347 Assiduite: fix format cell. nombre dans exports excel 2024-02-08 11:05:57 +01:00
Cléo Baras 2212990788 Corrige le bug lorsque plusieurs UE sont rattachées à la même compétence 2024-02-06 22:31:33 +01:00
Cléo Baras 719d14673d Merge branch 'scodoc-master' into pe-BUT-v2 2024-02-06 18:58:14 +01:00
Cléo Baras 98eb7699a0 Coquille de syntaxe 2024-02-06 18:57:54 +01:00
Cléo Baras 7b22d26095 Supprime les étudiants démissionnaires au dernier semestre du jury PE 2024-02-06 18:57:36 +01:00
Cléo Baras 371d7eff64 Affiche message erreur si utilisation de tags réservés 2024-02-06 18:25:31 +01:00
Cléo Baras 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
Cléo Baras f10d46c230 Supprime le formation_id définitivement des cosemestres 2024-02-06 17:52:52 +01:00
Emmanuel Viennet 4f41ef7050 Fix typo, bloquant archivage fichiers étudiants 2024-02-06 16:52:28 +01:00
Emmanuel Viennet ef4c2fa64b 9.6.935 2024-02-06 00:02:43 +01:00
Cléo Baras be39245e25 Merge branch 'scodoc-master' into pe-BUT-v2 2024-02-05 19:47:36 +01:00
Cléo Baras 196dbab298 Amélioration nomenclature : interclass => RCSInterclasseTag + ménage et refonte codes (état intermédiaire n°2) 2024-02-05 19:46:16 +01:00
Emmanuel Viennet 0594a659fa 9.6.934 2024-02-05 19:06:26 +01:00
Emmanuel Viennet 072d013590 Fix edition resp. modimpl. Une migration devra etre ajoutée plus tard. 2024-02-05 17:35:46 +01:00
Cléo Baras 9c4e2627ba Amélioration nomenclature : trajectoire => RCS + ménage et refonte codes (état intermédiaire n°1) 2024-02-05 12:58:09 +01:00
Emmanuel Viennet bbdf5da2e8 Fix edition formsemestre 2024-02-05 11:03:34 +01:00
Cléo Baras 5828d4aaaf Corrige les erreurs de classement dans synthese_jury_par_tag 2024-02-05 10:23:51 +01:00
Emmanuel Viennet e6a544906e Fix: ajout enseignant module (oubli commit) 2024-02-05 09:07:23 +01:00
Emmanuel Viennet bacd734ab5 Fix: creation formsemestre 2024-02-05 09:03:53 +01:00
Emmanuel Viennet e611fa4bfc Fix: création 1ere évaluation 2024-02-05 08:46:21 +01:00
Emmanuel Viennet 128b282186 API: remet /formsemestre/<int:formsemestre_id>/etat_evals 2024-02-05 00:10:56 +01:00
Emmanuel Viennet 57d36927ac Modernise code evaluations/enseignants 2024-02-04 23:08:08 +01:00
Emmanuel Viennet d5fdd5b8b8 Affichage statut évaluations (attente) 2024-02-04 18:36:11 +01:00
Emmanuel Viennet 7162d83f39 Ordre évaluations 2024-02-04 12:00:26 +01:00
Emmanuel Viennet 7805a6cab9 Assiduité: fix UnboundLocalError in signale_evaluation_abs 2024-02-04 00:48:45 +01:00
Emmanuel Viennet 2c840b7803 Bulletins BUT courts pdf: ajout parcours 2024-02-04 00:44:41 +01:00
Emmanuel Viennet 0645db8ab0 Ajout explication sur colonne RCUE jury BUT 2024-02-04 00:22:49 +01:00
Emmanuel Viennet 9e13b51669 Merge branch 'pe-BUT-v2' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-02-04 00:09:11 +01:00
Emmanuel Viennet 034800ab9a small fixes 2024-02-04 00:07:14 +01:00
Emmanuel Viennet 4b2e88c678 Diverses modernisation du code 2024-02-03 23:25:05 +01:00
Cléo Baras 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
Cléo Baras 0bf0311f2f Ménage + Refactoring du code 2024-02-03 10:46:14 +01:00
Emmanuel Viennet 5bbdc567f3 retrait API + fix 2024-02-02 18:45:58 +01:00
Emmanuel Viennet 027f11e494 Merge branch 'pe-BUT-v2' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-02-02 18:24:00 +01:00
Emmanuel Viennet ef171364a6 Edition/import étudiants / traitement erreurs codes dupliqués 2024-02-02 18:23:35 +01:00
Cléo Baras 9b9d7b611b Coquilles orthographiques dans la doc 2024-02-02 17:17:26 +01:00
Cléo Baras 02bfb626cb Ajoute des info à l'édition des tags à propos de la gestion des compétences 2024-02-02 17:17:14 +01:00
Cléo Baras 597a28f86d Injecte les moyennes de compétences par semestre à la synthèse 2024-02-02 17:16:07 +01:00
Emmanuel Viennet 2915f4e981 Fix: check arg formsemestre_set_elt_sem_apo 2024-02-02 16:34:27 +01:00
Emmanuel Viennet af659d5f09 Merge branch 'pe-BUT-v2' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-02-02 16:00:09 +01:00
Emmanuel Viennet 838ae7cf7e Merge branch 'tmp' 2024-02-02 15:29:45 +01:00
Emmanuel Viennet e4c8637c41 Fix: TypeError in __init__ /opt/scodoc/app/but/jury_but.py 2024-02-02 15:26:59 +01:00
Emmanuel Viennet 0bf3c22cd0 Modernisation code état évaluations / tableau de bord formsemestre 2024-02-02 15:16:55 +01:00
Cléo Baras 6700687e96 Supprime le formsemestre_id en tant que param (inutile) du jury PE 2024-02-02 13:48:07 +01:00
Cléo Baras b8e20b6be8 Merge branch 'scodoc-master' into pe-BUT-v2 2024-02-02 13:31:14 +01:00
Cléo Baras 78eeb9c67f Export Excel : Ajoute un tableur donnant les informations par étudiants 2024-02-02 11:49:24 +01:00
Cléo Baras 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
Cléo Baras 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
Emmanuel Viennet 952132695f Jury BUT: l'option 'bloque moyennes' empeche la prise en compte en jury annuel 2024-01-31 23:07:03 +01:00
Emmanuel Viennet 0b6a4b5c7e Fix: link notes.formation_tag_modules_by_type si "tous semestres" 2024-01-31 22:56:14 +01:00
Emmanuel Viennet 556725b3ef 3 petites corrections 2024-01-31 14:27:08 +01:00
Emmanuel Viennet 90bf31fc03 Tag module formation selon leur type 2024-01-30 22:12:55 +01:00
Emmanuel Viennet f7e41dc7fe Fix: api formsemestres_courants date fin incluse 2024-01-30 14:18:27 +01:00
Emmanuel Viennet eefbe70944 Fix: Assiduité: saisie/éditiion date dépôt justif. Fix #852 2024-01-30 11:47:57 +01:00
Emmanuel Viennet 5446ac0ed2 PE: ajout coloness etudid, INE, NIP + some code cleaning 2024-01-30 11:02:28 +01:00
Emmanuel Viennet 1f6f3620a2 Bul. BUT: traitement etud noin inscrit 2024-01-30 10:54:00 +01:00
Emmanuel Viennet 04d1fbe272 col etudid in formsemestre_poursuite_report. Fix #849 2024-01-29 22:33:06 +01:00
Emmanuel Viennet c270c24c5b Bonus IUT Littoral: modif règle 2024-01-29 17:29:09 +01:00
Emmanuel Viennet 8b751608e1 Bonus IUT Littoral: modif règle 2024-01-29 17:28:25 +01:00
Emmanuel Viennet 0fb45fc9ca Bonus IUT Littoral: modif règle 2024-01-29 17:21:49 +01:00
Emmanuel Viennet 8652ef2e7b API: numero groupe par defaut 2024-01-28 22:36:33 +01:00
Emmanuel Viennet 9be77e4f37 PE: modernise vue 2024-01-27 13:37:01 +01:00
Emmanuel Viennet a00e2da461 PE: template vue 2024-01-27 13:13:44 +01:00
Emmanuel Viennet 3481f7c1c2 PE: structure code et traite cas sans etudiants 2024-01-27 12:21:21 +01:00
Emmanuel Viennet 64d7e1ed42 Merge PE , version. 2024-01-27 10:51:10 +01:00
Cléo Baras d310304e9e Corrige bug ~np.isnan(set_cube) (efface notes chargées de type string) 2024-01-27 10:45:17 +01:00
Cléo Baras 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
Cléo Baras 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
Cléo Baras cb5df2fffd Ménage + Ajout du détail des semestres taggués au zip final 2024-01-27 08:22:36 +01:00
Cléo Baras 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
Emmanuel Viennet 787e514dca Ne propsoe pas chgt de mot de passe scodoc si CAS forcé pour cet user 2024-01-26 22:58:22 +01:00
Emmanuel Viennet e25f7d4fc9 Merge branch 'pe-BUT-v2' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-01-26 17:14:32 +01:00
Cléo Baras f87902d1ac Corrige la boucle en erreur pour le calcul des moyennes d'aggrégat 2024-01-26 16:54:50 +01:00
Emmanuel Viennet 39b3cd9e05 Fix: changement mot de passe par l'utilisateur lui même 2024-01-26 16:50:20 +01:00
Emmanuel Viennet d1074a8227 BUT: Option pour ne pas afficher décision annuelle sur bulletins 2024-01-26 16:14:08 +01:00
Cléo Baras 1b18034adb Coquille 2024-01-26 15:26:11 +01:00
Cléo Baras 871f5c1d61 Merge branch 'scodoc-master' into pe-BUT-v2 2024-01-26 15:25:31 +01:00
Emmanuel Viennet 79f07deac0 Clonage formation: conserve ue_code, même si vide 2024-01-26 14:57:50 +01:00
Emmanuel Viennet 431dd20911 tags formation: élargit caractères autorisés 2024-01-26 12:12:26 +01:00
Emmanuel Viennet 4985182b9a Fix: recherche tag numériques 2024-01-26 11:38:38 +01:00
Emmanuel Viennet 4681294cb8 evaluation_listenotes: remet colonne groupes en excel 2024-01-26 10:28:10 +01:00
Emmanuel Viennet f6051f930f PE: generation du zip 2024-01-26 10:18:46 +01:00
Cléo Baras cf415763b3 Merge branch 'scodoc-master' into pe-BUT-v2
# Conflicts:
#	app/pe/pe_etudiant.py
2024-01-26 09:53:32 +01:00
Cléo Baras 769f6c0ea0 Restreint les analyses aux semestres d'une formation APC (cf. bug lyonnais) 2024-01-26 09:42:41 +01:00
Emmanuel Viennet 33f2afb04b Fix: pe_etudiant.py 2024-01-26 09:29:36 +01:00
Cléo Baras 02bccb58aa Améliore le calcul de l'année de diplôme d'un étudiant 2024-01-26 07:13:09 +01:00
Emmanuel Viennet 4f7da8bfa4 Tag modules formation: vérification longueur et syntaxe 2024-01-25 22:40:10 +01:00
Emmanuel Viennet a439c4c985 Fix typo (bug introduit par le précédent commit) + avertissement travaux en cours 2024-01-25 22:07:24 +01:00
Emmanuel Viennet d991eb007c PE: qq modifs de forme (type hints, comments) 2024-01-25 21:54:22 +01:00
Emmanuel Viennet 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
Cléo Baras be7bb588cf Supprime les dépendances à pe_avislatex 2024-01-25 21:27:24 +01:00
Cléo Baras 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
Emmanuel Viennet 3a3d47ebe4 Merge pe-BUT-v2 de Cléo 2024-01-25 21:06:59 +01:00
Emmanuel Viennet 54be507e35 Modif affichage poids evals (non nuls) 2024-01-25 20:54:12 +01:00
Cléo Baras efd735542e Coquille 2024-01-25 20:04:42 +01:00
Cléo Baras 776b0fb228 Mise à 0 de pe_comp.PE_DEBUG 2024-01-25 19:54:39 +01:00
Cléo Baras cd8d73b41f Version 2 fonctionnelle 2024-01-25 19:42:22 +01:00
Cléo Baras decc28b896 Presque la version 2? 2024-01-25 17:17:01 +01:00
Emmanuel Viennet 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
Emmanuel Viennet 790ba910ee Lien vers page documentation codes jury BUT 2024-01-24 22:54:28 +01:00
Cléo Baras 82713752c2 Mise en place des interclassements 2024-01-24 19:37:45 +01:00
Emmanuel Viennet fc35974951 BUT: Ne génère plus de code annuel lors de jurys de semestre impairs 2024-01-24 19:00:12 +01:00
Cléo Baras 283daae4d9 Etat intermédiaire n°4 2024-01-24 15:37:50 +01:00
Emmanuel Viennet ece689eb10 API: moduleimpl-notes 2024-01-23 22:55:00 +01:00
Emmanuel Viennet 242771c619 Fix: view_apo_csv 2024-01-23 21:42:11 +01:00
Emmanuel Viennet aa45680ed8 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-01-23 20:09:57 +01:00
Emmanuel Viennet 086b8ee191 Assiduite: un peu de nettoyage, corrections 2024-01-23 20:09:13 +01:00
Cléo Baras 8477dc96ca Etat intermédiaire n°3 2024-01-23 19:08:54 +01:00
Cléo Baras e3cde87a0f Adaptation diverses pour la gestion des aggrégats (dont les redoublements de semestre) 2024-01-23 18:44:44 +01:00
Cléo Baras 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
Cléo Baras 02976c9996 Etat temporaire n°2 2024-01-23 09:05:52 +01:00
Emmanuel Viennet 2a239ab92f Assiduites: 3 bugs 2024-01-22 17:01:01 +01:00
Emmanuel Viennet 9989f419cb Fix: 2 typos 2024-01-22 16:30:18 +01:00
Emmanuel Viennet 74b8b90a65 Fix: retreive_formsemestre_from_request / sans semestre 2024-01-22 15:50:01 +01:00
Emmanuel Viennet 2ad77428a5 Fix: désactive etat_abs_date si évaluation sans date de fin 2024-01-22 13:38:04 +01:00
Emmanuel Viennet 505f5e5f1c Fix yet another bug on evaluations dates 2024-01-22 13:35:59 +01:00
Emmanuel Viennet a7848f0a4e typo 2024-01-22 13:18:54 +01:00
Emmanuel Viennet 2660801dd5 API: script exemple: exemple-api-list-modules.p 2024-01-22 13:15:24 +01:00
Emmanuel Viennet e415a5255e Fix bug: evaluations sans dates 2024-01-22 09:57:41 +01:00
Emmanuel Viennet dae04658b7 Fix: bug assiduites stats 2024-01-21 23:15:44 +01:00
Emmanuel Viennet f842fa0b4f typo + TODO #850 2024-01-21 22:30:10 +01:00
Emmanuel Viennet 45c685d725 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-01-21 22:23:39 +01:00
Emmanuel Viennet 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
Emmanuel Viennet 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
Emmanuel Viennet 555e8af818 Accès au détail d'un justificatif avec AbsJustifView: closes #824 2024-01-21 22:02:18 +01:00
Emmanuel Viennet f09b2028e2 Adaptation a minima de la table 'poursuites' pour le BUT. Closes #849. 2024-01-21 22:02:18 +01:00
Emmanuel Viennet 7d2d5a3ea9 typos 2024-01-21 22:02:18 +01:00
Emmanuel Viennet a65c1d3c4a RGPD: durée conservation logs par défaut (1 an). Closes #647 2024-01-21 22:02:18 +01:00
Emmanuel Viennet b8eb8bb77f Deux fichiers oubliés, pour #648 2024-01-21 22:02:18 +01:00
Emmanuel Viennet 4917034b6d RGPD: config. coordonnées DPO. Closes #648 2024-01-21 22:02:17 +01:00
Emmanuel Viennet 81915b1522 RGPD: ViewEtudData. Implements #842 2024-01-21 22:02:17 +01:00
Emmanuel Viennet c6910fc76e RGPD: protection optionnelle des données perso étudiantes (ViewEtudData) sur fiche_etud 2024-01-21 22:02:17 +01:00
Cléo Baras 90c2516d01 Etat temporaire 2024-01-21 18:55:21 +01:00
Emmanuel Viennet aee4f14b81 Accès au détail d'un justificatif avec AbsJustifView: closes #824 2024-01-21 18:07:56 +01:00
Cléo Baras 340aa749b2 Calcul des moyennes par tag d'un settag (avec les tenseurs) 2024-01-21 18:05:00 +01:00
Cléo Baras 7a0b560d54 Celui qui voulait des tenseurs 2024-01-21 13:14:04 +01:00
Cléo Baras 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
Emmanuel Viennet ff63a32bbe Adaptation a minima de la table 'poursuites' pour le BUT. Closes #849. 2024-01-20 21:34:28 +01:00
Emmanuel Viennet ab116ee9e7 typos 2024-01-20 20:19:50 +01:00
Emmanuel Viennet b91609950c Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-01-20 19:42:26 +01:00
Emmanuel Viennet f2f229df4a RGPD: durée conservation logs par défaut (1 an). Closes #647 2024-01-20 19:36:20 +01:00
Emmanuel Viennet 6908b0b8d2 Deux fichiers oubliés, pour #648 2024-01-20 19:30:42 +01:00
Emmanuel Viennet 706b21ede7 RGPD: config. coordonnées DPO. Closes #648 2024-01-20 19:29:32 +01:00
Emmanuel Viennet 238fbe887c RGPD: ViewEtudData. Implements #842 2024-01-20 17:37:24 +01:00
Cléo Baras 3e55391f7e Recode SemestreTag 2024-01-20 16:34:38 +01:00
Emmanuel Viennet 9c1c316f14 RGPD: protection optionnelle des données perso étudiantes (ViewEtudData) sur fiche_etud 2024-01-20 14:49:36 +01:00
Cléo Baras 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
Emmanuel Viennet 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
Emmanuel Viennet 6d3f276cc0 Filigranne bulletins BUT: fix #844 2024-01-17 23:52:14 +01:00
Emmanuel Viennet e2ca673239 Fix typo 2024-01-17 23:51:45 +01:00
Emmanuel Viennet f55f3fe82f Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-01-17 22:01:57 +01:00
Emmanuel Viennet 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
Cléo Baras df20372abb Reformattage code avec Black 2024-01-16 15:51:22 +01:00
Emmanuel Viennet 0cafc0b184 Ajoute timepicker partout. Utilise pour evaluation_edit. Fix #829 2024-01-16 13:56:35 +01:00
Emmanuel Viennet 2c42a1547c Améliore moduleimpl_inscriptions_edit. Closes #843 2024-01-16 13:56:35 +01:00
Emmanuel Viennet 7ce57d28cb Ajoute timepicker partout. Utilise pour evaluation_edit. Fix #829 2024-01-16 12:36:20 +01:00
Emmanuel Viennet 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
Emmanuel Viennet 76bedfb303 Fix: bug synchro apo si 1 seul étudiant 2024-01-16 09:23:19 +01:00
Emmanuel Viennet 0634dbd0aa Cache: delete_pattern 2024-01-16 09:23:19 +01:00
Cléo Baras 01d0f7d651 Erreur si le jury demandé correspond à un DUT ou tout formation non APC 2024-01-16 09:21:02 +01:00
Cléo Baras 6d16927db9 Désactive les moyennes par tag, autres que le tag but 2024-01-16 08:54:20 +01:00
Cléo Baras 2f81ce8ac2 Améliore rendu visuel min/moy/max dans excel : None/None/None -> -/-/- 2024-01-16 06:35:27 +01:00
Cléo Baras 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
Cléo Baras e28bfa34be Entête des méthodes & fonctions 2024-01-16 05:36:27 +01:00
Cléo Baras 86e8803c87 Création branche pe-DUT-to-BUT + changement dut->but dans excel exporté 2024-01-15 19:45:38 +01:00
Emmanuel Viennet 4babffd022 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-01-15 18:58:32 +01:00
Emmanuel Viennet 6461d14883 Fix: bug synchro apo si 1 seul étudiant 2024-01-15 18:57:52 +01:00
Emmanuel Viennet 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
Emmanuel Viennet a90fd6dcd0 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-01-09 19:56:54 +01:00
Emmanuel Viennet ac9b5722cf Option de config globale pour imposer saisie du mail institutionnel dans le formulaire 2024-01-09 19:56:05 +01:00
Emmanuel Viennet 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
Emmanuel Viennet 1e33626b60 EDT: fix pb affichage signalés par PB 2024-01-09 02:03:16 +01:00
Iziram a63ed6c0ef Assiduites : justifier depuis tableau closes #841 2024-01-08 19:06:44 +01:00
Iziram 943604996b Assiduites : fix compte abs negatif + prob cache 2024-01-08 19:01:54 +01:00
Iziram ff92a8f61e Assiduites : saisie différée créneau par défaut fixes #819 2024-01-08 10:49:34 +01:00
Emmanuel Viennet 8e74a143fa EDT: conserve état view 2024-01-06 14:51:48 +01:00
Emmanuel Viennet c8ac59d8da Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into edt 2024-01-06 14:32:53 +01:00
Emmanuel Viennet 4f07da0b41 Fix: édition config. via l'API (routes) 2024-01-06 14:32:06 +01:00
Emmanuel Viennet f2b6e7f253 EDT: affichage enseignants non reconnus + typo 2024-01-06 12:46:22 +01:00
330 changed files with 22372 additions and 21437 deletions

View File

@ -315,12 +315,6 @@ def create_app(config_class=DevConfig):
app.register_error_handler(503, postgresql_server_error)
app.register_error_handler(APIInvalidParams, handle_invalid_usage)
# Add some globals
# previously in Flask-Bootstrap:
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
field, HiddenField
)
from app.auth import bp as auth_bp
app.register_blueprint(auth_bp, url_prefix="/auth")
@ -338,8 +332,15 @@ def create_app(config_class=DevConfig):
from app.api import api_bp
from app.api import api_web_bp
# Jinja2 configuration
# Enable autoescaping of all templates, including .j2
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
# previously in Flask-Bootstrap:
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
field, HiddenField
)
# https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp)

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

@ -3,14 +3,15 @@
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
"""ScoDoc 9 API : Assiduités"""
from datetime import datetime
from flask import g, request
from flask_json import as_json
from flask_login import current_user, login_required
from flask_sqlalchemy.query import Query
from sqlalchemy.orm.exc import ObjectDeletedError
from app import db, log, set_sco_dept
import app.scodoc.sco_assiduites as scass
@ -337,7 +338,7 @@ def assiduites_group(with_query: bool = False):
try:
etuds = [int(etu) for etu in etuds]
except ValueError:
return json_error(404, "Le champs etudids n'est pas correctement formé")
return json_error(404, "Le champ etudids n'est pas correctement formé")
# Vérification que tous les étudiants sont du même département
query = Identite.query.filter(Identite.id.in_(etuds))
@ -858,7 +859,10 @@ def assiduite_edit(assiduite_id: int):
msg=f"assiduite: modif {assiduite_unique}",
)
db.session.commit()
scass.simple_invalidate_cache(assiduite_unique.to_dict())
try:
scass.simple_invalidate_cache(assiduite_unique.to_dict())
except ObjectDeletedError:
return json_error(404, "Assiduité supprimée / inexistante")
return {"OK": True}

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")
@ -377,30 +414,24 @@ def bulletin(
if version == "pdf":
version = "long"
pdf = True
if version not in scu.BULLETINS_VERSIONS_BUT:
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if version not in (
scu.BULLETINS_VERSIONS_BUT
if formsemestre.formation.is_apc()
else scu.BULLETINS_VERSIONS
):
return json_error(404, "version invalide")
# return f"{code_type}={code}, version={version}, pdf={pdf}"
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
if formsemestre.bul_hide_xml and pdf:
return json_error(403, "bulletin non disponible")
# note: la version json est réduite si bul_hide_xml
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre inexistant")
app.set_sco_dept(dept.acronym)
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code, dept_id=dept.id)
else:
return json_error(404, "invalid code_type")
etud = query.first()
if etud is None:
return json_error(404, message="etudiant inexistant")
ok, etud = _get_etud_by_code(code_type, code, dept)
if not ok:
return etud # json error
if version == "butcourt":
if pdf:
@ -543,7 +574,8 @@ def etudiant_create(force=False):
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
r = etud.to_dict_api()
r = etud.to_dict_api(restrict=False) # pas de restriction, on vient de le créer
return r
@ -551,26 +583,15 @@ def etudiant_create(force=False):
@api_web_bp.route("/etudiant/<string:code_type>/<string:code>/edit", methods=["POST"])
@scodoc
@permission_required(Permission.EtudInscrit)
@as_json
def etudiant_edit(
code_type: str = "etudid",
code: str = None,
):
"""Edition des données étudiant (identité, admission, adresses)"""
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code)
else:
return json_error(404, "invalid code_type")
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first()
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
#
args = request.get_json(force=True) # may raise 400 Bad Request
etud.from_dict(args)
@ -590,5 +611,70 @@ def etudiant_edit(
# Note: je ne comprends pas pourquoi un refresh est nécessaire ici
# sans ce refresh, etud.__dict__ est incomplet (pas de 'nom').
db.session.refresh(etud)
r = etud.to_dict_api()
restrict = not current_user.has_permission(Permission.ViewEtudData)
r = etud.to_dict_api(restrict=restrict)
return r
@bp.route("/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"])
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation", methods=["POST"]
)
@scodoc
@permission_required(Permission.EtudInscrit) # il faut en plus ViewEtudData
@as_json
def etudiant_annotation(
code_type: str = "etudid",
code: str = None,
):
"""Ajout d'une annotation sur un étudiant"""
if not current_user.has_permission(Permission.ViewEtudData):
return json_error(403, "non autorisé (manque ViewEtudData)")
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
#
args = request.get_json(force=True) # may raise 400 Bad Request
comment = args.get("comment", None)
if not isinstance(comment, str):
return json_error(404, "invalid comment (expected string)")
if len(comment) > scu.MAX_TEXT_LEN:
return json_error(404, "invalid comment (too large)")
annotation = EtudAnnotation(comment=comment, author=current_user.user_name)
etud.annotations.append(annotation)
db.session.add(etud)
db.session.commit()
log(f"etudiant_annotation/{etud.id}/{annotation.id}")
return annotation.to_dict()
@bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
methods=["POST"],
)
@api_web_bp.route(
"/etudiant/<string:code_type>/<string:code>/annotation/<int:annotation_id>/delete",
methods=["POST"],
)
@login_required
@scodoc
@as_json
@permission_required(Permission.EtudInscrit)
def etudiant_annotation_delete(
code_type: str = "etudid", code: str = None, annotation_id: int = None
):
"""
Suppression d'une annotation
"""
ok, etud = _get_etud_by_code(code_type, code, g.scodoc_dept)
if not ok:
return etud # json error
annotation = EtudAnnotation.query.filter_by(
etudid=etud.id, id=annotation_id
).first()
if annotation is None:
return json_error(404, "annotation not found")
log(f"etudiant_annotation_delete/{etud.id}/{annotation.id}")
db.session.delete(annotation)
db.session.commit()
return "ok"

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

@ -52,7 +52,8 @@ def formations():
@as_json
def formations_ids():
"""
Retourne la liste de toutes les id de formations (tous départements)
Retourne la liste de toutes les id de formations
(tous départements, ou du département indiqué dans la route)
Exemple de résultat : [ 17, 99, 32 ]
"""
@ -328,6 +329,8 @@ def desassoc_ue_niveau(ue_id: int):
ue.niveau_competence = None
db.session.add(ue)
db.session.commit()
# Invalidation du cache
ue.formation.invalidate_cached_sems()
log(f"desassoc_ue_niveau: {ue}")
if g.scodoc_dept:
# "usage web"

View File

@ -11,8 +11,8 @@ from operator import attrgetter, itemgetter
from flask import g, make_response, request
from flask_json import as_json
from flask_login import login_required
from flask_login import current_user, login_required
import sqlalchemy as sa
import app
from app import db
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
@ -38,7 +38,7 @@ from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import ModuleType
import app.scodoc.sco_utils as scu
from app.tables.recap import TableRecap
from app.tables.recap import TableRecap, RowRecap
@bp.route("/formsemestre/<int:formsemestre_id>")
@ -171,6 +171,44 @@ def formsemestres_query():
]
@bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/edit", methods=["POST"])
@scodoc
@permission_required(Permission.EditFormSemestre)
@as_json
def formsemestre_edit(formsemestre_id: int):
"""Modifie les champs d'un formsemestre."""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
args = request.get_json(force=True) # may raise 400 Bad Request
editable_keys = {
"semestre_id",
"titre",
"date_debut",
"date_fin",
"edt_id",
"etat",
"modalite",
"gestion_compensation",
"bul_hide_xml",
"block_moyennes",
"block_moyenne_generale",
"mode_calcul_moyennes",
"gestion_semestrielle",
"bul_bgcolor",
"resp_can_edit",
"resp_can_change_ens",
"ens_can_edit_eval",
"elt_sem_apo",
"elt_annee_apo",
}
formsemestre.from_dict({k: v for (k, v) in args.items() if k in editable_keys})
try:
db.session.commit()
except sa.exc.StatementError as exc:
return json_error(404, f"invalid argument(s): {exc.args[0]}")
return formsemestre.to_dict_api()
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@ -360,7 +398,8 @@ def formsemestre_etudiants(
inscriptions = formsemestre.inscriptions
if long:
etuds = [ins.etud.to_dict_api() for ins in inscriptions]
restrict = not current_user.has_permission(Permission.ViewEtudData)
etuds = [ins.etud.to_dict_api(restrict=restrict) for ins in inscriptions]
else:
etuds = [ins.etud.to_dict_short() for ins in inscriptions]
# Ajout des groupes de chaque étudiants
@ -425,7 +464,7 @@ def etat_evals(formsemestre_id: int):
for modimpl_id in nt.modimpls_results:
modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl_id]
modimpl: ModuleImpl = ModuleImpl.query.get_or_404(modimpl_id)
modimpl_dict = modimpl.to_dict(convert_objects=True)
modimpl_dict = modimpl.to_dict(convert_objects=True, with_module=False)
list_eval = []
for evaluation_id in modimpl_results.evaluations_etat:
@ -467,13 +506,13 @@ def etat_evals(formsemestre_id: int):
date_mediane = notes_sorted[len(notes_sorted) // 2].date
eval_dict["saisie_notes"] = {
"datetime_debut": date_debut.isoformat()
if date_debut is not None
else None,
"datetime_debut": (
date_debut.isoformat() if date_debut is not None else None
),
"datetime_fin": date_fin.isoformat() if date_fin is not None else None,
"datetime_mediane": date_mediane.isoformat()
if date_mediane is not None
else None,
"datetime_mediane": (
date_mediane.isoformat() if date_mediane is not None else None
),
}
list_eval.append(eval_dict)
@ -504,16 +543,30 @@ def formsemestre_resultat(formsemestre_id: int):
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
app.set_sco_dept(formsemestre.departement.acronym)
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
table = TableRecap(
res, convert_values=convert_values, include_evaluations=False, mode_jury=False
)
# Supprime les champs inutiles (mise en forme)
rows = table.to_list()
# Ajoute le groupe de chaque partition:
# Ajoute le groupe de chaque partition,
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
for row in rows:
row["partitions"] = etud_groups.get(row["etudid"], {})
class RowRecapAPI(RowRecap):
"""Pour table avec partitions et sort_key"""
def add_etud_cols(self):
"""Ajoute colonnes étudiant: codes, noms"""
super().add_etud_cols()
self.add_cell("partitions", "partitions", etud_groups.get(self.etud.id, {}))
self.add_cell("sort_key", "sort_key", self.etud.sort_key)
table = TableRecap(
res,
convert_values=convert_values,
include_evaluations=False,
mode_jury=False,
row_class=RowRecapAPI,
)
rows = table.to_list()
# for row in rows:
# row["partitions"] = etud_groups.get(row["etudid"], {})
return rows

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,19 +15,13 @@ from werkzeug.exceptions import NotFound
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app import db, set_sco_dept
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object, tools
from app.decorators import permission_required, scodoc
from app.models import (
Identite,
Justificatif,
Departement,
FormSemestre,
)
from app.models import Identite, Justificatif, Departement, FormSemestre, Scolog
from app.models.assiduites import (
compute_assiduites_justified,
get_formsemestre_from_data,
)
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
@ -53,14 +47,19 @@ def justificatif(justif_id: int = None):
"date_fin": "2022-10-31T10:00+01:00",
"etat": "valide",
"fichier": "archive_id",
"raison": "une raison",
"raison": "une raison", // VIDE si pas le droit
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
"""
return get_model_api_object(Justificatif, justif_id, Identite)
return get_model_api_object(
Justificatif,
justif_id,
Identite,
restrict=not current_user.has_permission(Permission.AbsJustifView),
)
# etudid
@ -133,8 +132,9 @@ def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = Fal
# Mise en forme des données puis retour en JSON
data_set: list[dict] = []
restrict = not current_user.has_permission(Permission.AbsJustifView)
for just in justificatifs_query.all():
data = just.to_dict(format_api=True)
data = just.to_dict(format_api=True, restrict=restrict)
data_set.append(data)
return data_set
@ -151,7 +151,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 +172,15 @@ def justificatifs_dept(dept_id: int = None, with_query: bool = False):
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Mise en forme des données et retour JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
data_set: list[dict] = []
for just in justificatifs_query:
data_set.append(_set_sems(just))
data_set.append(_set_sems(just, restrict=restrict))
return data_set
def _set_sems(justi: Justificatif) -> dict:
def _set_sems(justi: Justificatif, restrict: bool) -> dict:
"""
_set_sems Ajoute le formsemestre associé au justificatif s'il existe
@ -189,7 +193,7 @@ def _set_sems(justi: Justificatif) -> dict:
dict: La représentation de l'assiduité en dictionnaire
"""
# Conversion du justificatif en dictionnaire
data = justi.to_dict(format_api=True)
data = justi.to_dict(format_api=True, restrict=restrict)
# Récupération du formsemestre de l'assiduité
formsemestre: FormSemestre = get_formsemestre_from_data(justi.to_dict())
@ -243,9 +247,10 @@ def justificatifs_formsemestre(formsemestre_id: int, with_query: bool = False):
justificatifs_query: Query = _filter_manager(request, justificatifs_query)
# Retour des justificatifs en JSON
restrict = not current_user.has_permission(Permission.AbsJustifView)
data_set: list[dict] = []
for justi in justificatifs_query.all():
data = justi.to_dict(format_api=True)
data = justi.to_dict(format_api=True, restrict=restrict)
data_set.append(data)
return data_set
@ -294,6 +299,7 @@ def justif_create(etudid: int = None, nip=None, ine=None):
404,
message="étudiant inconnu",
)
set_sco_dept(etud.departement.acronym)
# Récupération des justificatifs à créer
create_list: list[object] = request.get_json(force=True)
@ -303,7 +309,6 @@ def justif_create(etudid: int = None, nip=None, ine=None):
errors: list[dict] = []
success: list[dict] = []
justifs: list[Justificatif] = []
# énumération des justificatifs
for i, data in enumerate(create_list):
@ -315,11 +320,9 @@ def justif_create(etudid: int = None, nip=None, ine=None):
errors.append({"indice": i, "message": obj})
else:
success.append({"indice": i, "message": obj})
justifs.append(justi)
justi.justifier_assiduites()
scass.simple_invalidate_cache(data, etud.id)
# Actualisation des assiduités justifiées en fonction de tous les nouveaux justificatifs
compute_assiduites_justified(etud.etudid, justifs)
return {"errors": errors, "success": success}
@ -488,9 +491,16 @@ def justif_edit(justif_id: int):
return json_error(404, err)
# Mise à jour du justificatif
justificatif_unique.dejustifier_assiduites()
db.session.add(justificatif_unique)
db.session.commit()
Scolog.logdb(
method="edit_justificatif",
etudid=justificatif_unique.etudiant.id,
msg=f"justificatif modif: {justificatif_unique}",
)
# Génération du dictionnaire de retour
# La couverture correspond
# - aux assiduités précédemment justifiées par le justificatif
@ -498,11 +508,7 @@ def justif_edit(justif_id: int):
retour = {
"couverture": {
"avant": avant_ids,
"apres": compute_assiduites_justified(
justificatif_unique.etudid,
[justificatif_unique],
True,
),
"apres": justificatif_unique.justifier_assiduites(),
}
}
# Invalide le cache
@ -579,14 +585,10 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
# On invalide le cache
scass.simple_invalidate_cache(justificatif_unique.to_dict())
# On actualise les assiduités justifiées de l'étudiant concerné
justificatif_unique.dejustifier_assiduites()
# On supprime le justificatif
db.session.delete(justificatif_unique)
# On actualise les assiduités justifiées de l'étudiant concerné
compute_assiduites_justified(
justificatif_unique.etudid,
Justificatif.query.filter_by(etudid=justificatif_unique.etudid).all(),
True,
)
return (200, "OK")
@ -687,7 +689,6 @@ def justif_export(justif_id: int | None = None, filename: str | None = None):
@as_json
@permission_required(Permission.AbsChange)
def justif_remove(justif_id: int = None):
# XXX TODO pas de test unitaire
"""
Supression d'un fichier ou d'une archive
{

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):
@ -599,8 +603,19 @@ class Role(db.Model):
"""Create default roles if missing, then, if reset_permissions,
reset their permissions to default values.
"""
Role.reset_roles_permissions(
SCO_ROLES_DEFAULTS, reset_permissions=reset_permissions
)
@staticmethod
def reset_roles_permissions(roles_perms: dict[str, tuple], reset_permissions=True):
"""Ajoute les permissions aux roles
roles_perms : { "role_name" : (permission, ...) }
reset_permissions : si vrai efface permissions déja existantes
Si le role n'existe pas, il est (re) créé.
"""
default_role = "Observateur"
for role_name, permissions in SCO_ROLES_DEFAULTS.items():
for role_name, permissions in roles_perms.items():
role = Role.query.filter_by(name=role_name).first()
if role is None:
role = Role(name=role_name)

View File

@ -21,7 +21,7 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
return ""
ref_comp = ue.formation.referentiel_competence
if ref_comp is None:
return f"""<div class="ue_advanced">
return f"""<div class="scobox ue_advanced">
<div class="warning">Pas de référentiel de compétence associé à cette formation !</div>
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)
@ -31,19 +31,28 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
H = [
"""
<div class="ue_advanced">
<h3>Parcours du BUT</h3>
<div class="scobox ue_advanced">
<div class="scobox-title">Parcours du BUT</div>
"""
]
# Choix des parcours
ue_pids = [p.id for p in ue.parcours]
H.append("""<form id="choix_parcours">""")
H.append(
"""
<div class="help">
Cocher tous les parcours dans lesquels cette UE est utilisée,
même si vous n'offrez pas ce parcours dans votre département.
Sans quoi, les UEs de Tronc Commun ne seront pas reconnues.
Ne cocher aucun parcours est équivalent à tous les cocher.
</div>
<form id="choix_parcours" style="margin-top: 12px;">
"""
)
ects_differents = {
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours
} != {None}
for parcour in ref_comp.parcours:
ects_parcour = ue.get_ects(parcour)
ects_parcour_txt = (
f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else ""
)

View File

@ -9,12 +9,14 @@
import collections
import datetime
import pandas as pd
import numpy as np
from flask import g, has_request_context, url_for
from app import db
from app.comp.moy_mod import ModuleImplResults
from app.comp.res_but import ResultatsSemestreBUT
from app.models import Evaluation, FormSemestre, Identite
from app.models import Evaluation, FormSemestre, Identite, ModuleImpl
from app.models.groups import GroupDescr
from app.models.ues import UniteEns
from app.scodoc import sco_bulletins, sco_utils as scu
@ -104,9 +106,11 @@ class BulletinBUT:
"competence": None, # XXX TODO lien avec référentiel
"moyenne": None,
# Le bonus sport appliqué sur cette UE
"bonus": fmt_note(res.bonus_ues[ue.id][etud.id])
if res.bonus_ues is not None and ue.id in res.bonus_ues
else fmt_note(0.0),
"bonus": (
fmt_note(res.bonus_ues[ue.id][etud.id])
if res.bonus_ues is not None and ue.id in res.bonus_ues
else fmt_note(0.0)
),
"malus": fmt_note(res.malus[ue.id][etud.id]),
"capitalise": None, # "AAAA-MM-JJ" TODO #sco93
"ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
@ -181,14 +185,16 @@ class BulletinBUT:
"is_external": ue_capitalisee.is_external,
"date_capitalisation": ue_capitalisee.event_date,
"formsemestre_id": ue_capitalisee.formsemestre_id,
"bul_orig_url": url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
formsemestre_id=ue_capitalisee.formsemestre_id,
)
if ue_capitalisee.formsemestre_id
else None,
"bul_orig_url": (
url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
formsemestre_id=ue_capitalisee.formsemestre_id,
)
if ue_capitalisee.formsemestre_id
else None
),
"ressources": {}, # sans détail en BUT
"saes": {},
}
@ -225,15 +231,17 @@ class BulletinBUT:
if res.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit
d[modimpl.module.code] = {
"id": modimpl.id,
"titre": modimpl.module.titre,
"titre": modimpl.module.titre_str(),
"code_apogee": modimpl.module.code_apogee,
"url": url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
if has_request_context()
else "na",
"url": (
url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
if has_request_context()
else "na"
),
"moyenne": {
# # moyenne indicative de module: moyenne des UE,
# # ignorant celles sans notes (nan)
@ -242,68 +250,115 @@ class BulletinBUT:
# "max": fmt_note(moyennes_etuds.max()),
# "moy": fmt_note(moyennes_etuds.mean()),
},
"evaluations": [
self.etud_eval_results(etud, e)
for e in modimpl.evaluations
if (e.visibulletin or version == "long")
and (e.id in modimpl_results.evaluations_etat)
and (
modimpl_results.evaluations_etat[e.id].is_complete
or self.prefs["bul_show_all_evals"]
"evaluations": (
self.etud_list_modimpl_evaluations(
etud, modimpl, modimpl_results, version
)
]
if version != "short"
else [],
if version != "short"
else []
),
}
return d
def etud_eval_results(self, etud, e: Evaluation) -> dict:
def etud_list_modimpl_evaluations(
self,
etud: Identite,
modimpl: ModuleImpl,
modimpl_results: ModuleImplResults,
version: str,
) -> list[dict]:
"""Liste des résultats aux évaluations de ce modimpl à montrer pour cet étudiant"""
evaluation: Evaluation
eval_results = []
for evaluation in modimpl.evaluations:
if (
(evaluation.visibulletin or version == "long")
and (evaluation.id in modimpl_results.evaluations_etat)
and (
modimpl_results.evaluations_etat[evaluation.id].is_complete
or self.prefs["bul_show_all_evals"]
)
):
eval_notes = self.res.modimpls_results[modimpl.id].evals_notes[
evaluation.id
]
if (evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE) or (
not np.isnan(eval_notes[etud.id])
):
eval_results.append(
self.etud_eval_results(etud, evaluation, eval_notes)
)
return eval_results
def etud_eval_results(
self, etud: Identite, evaluation: Evaluation, eval_notes: pd.DataFrame
) -> dict:
"dict resultats d'un étudiant à une évaluation"
# eval_notes est une pd.Series avec toutes les notes des étudiants inscrits
eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id]
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
modimpls_evals_poids = self.res.modimpls_evals_poids[evaluation.moduleimpl_id]
try:
etud_ues_ids = self.res.etud_ues_ids(etud.id)
poids = {
ue.acronyme: modimpls_evals_poids[ue.id][e.id]
ue.acronyme: modimpls_evals_poids[ue.id][evaluation.id]
for ue in self.res.ues
if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
}
except KeyError:
poids = collections.defaultdict(lambda: 0.0)
d = {
"id": e.id,
"coef": fmt_note(e.coefficient)
if e.evaluation_type == scu.EVALUATION_NORMALE
else None,
"date_debut": e.date_debut.isoformat() if e.date_debut else None,
"date_fin": e.date_fin.isoformat() if e.date_fin else None,
"description": e.description,
"evaluation_type": e.evaluation_type,
"note": {
"value": fmt_note(
eval_notes[etud.id],
note_max=e.note_max,
),
"min": fmt_note(notes_ok.min(), note_max=e.note_max),
"max": fmt_note(notes_ok.max(), note_max=e.note_max),
"moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
},
"id": evaluation.id,
"coef": (
fmt_note(evaluation.coefficient)
if evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE
else None
),
"date_debut": (
evaluation.date_debut.isoformat() if evaluation.date_debut else None
),
"date_fin": (
evaluation.date_fin.isoformat() if evaluation.date_fin else None
),
"description": evaluation.description,
"evaluation_type": evaluation.evaluation_type,
"note": (
{
"value": fmt_note(
eval_notes[etud.id],
note_max=evaluation.note_max,
),
"min": fmt_note(notes_ok.min(), note_max=evaluation.note_max),
"max": fmt_note(notes_ok.max(), note_max=evaluation.note_max),
"moy": fmt_note(notes_ok.mean(), note_max=evaluation.note_max),
}
if not evaluation.is_blocked()
else {}
),
"poids": poids,
"url": url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
)
if has_request_context()
else "na",
"url": (
url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=evaluation.id,
)
if has_request_context()
else "na"
),
# deprecated (supprimer avant #sco9.7)
"date": e.date_debut.isoformat() if e.date_debut else None,
"heure_debut": e.date_debut.time().isoformat("minutes")
if e.date_debut
else None,
"heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None,
"date": (
evaluation.date_debut.isoformat() if evaluation.date_debut else None
),
"heure_debut": (
evaluation.date_debut.time().isoformat("minutes")
if evaluation.date_debut
else None
),
"heure_fin": (
evaluation.date_fin.time().isoformat("minutes")
if evaluation.date_fin
else None
),
}
return d
@ -343,25 +398,18 @@ class BulletinBUT:
"short" : ne descend pas plus bas que les modules.
- Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai
(bulletins non publiés).
(bulletins non publiés sur la passerelle).
"""
if version not in scu.BULLETINS_VERSIONS_BUT:
raise ScoValueError("bulletin_etud: version de bulletin demandée invalide")
res = self.res
formsemestre = res.formsemestre
etat_inscription = etud.inscription_etat(formsemestre.id)
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
published = (not formsemestre.bul_hide_xml) or force_publishing
if formsemestre.formation.referentiel_competence is None:
etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
else:
etud_ues_ids = res.etud_ues_ids(etud.id)
d = {
"version": "0",
"type": "BUT",
"date": datetime.datetime.utcnow().isoformat() + "Z",
"publie": not formsemestre.bul_hide_xml,
"etat_inscription": etud.inscription_etat(formsemestre.id),
"etudiant": etud.to_dict_bul(),
"formation": {
"id": formsemestre.formation.id,
@ -370,15 +418,21 @@ class BulletinBUT:
"titre": formsemestre.formation.titre,
},
"formsemestre_id": formsemestre.id,
"etat_inscription": etat_inscription,
"options": sco_preferences.bulletin_option_affichage(
formsemestre, self.prefs
),
}
if not published:
published = (not formsemestre.bul_hide_xml) or force_publishing
if not published or d["etat_inscription"] is False:
return d
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
nb_inscrits = self.res.get_inscriptions_counts()[scu.INSCRIT]
if formsemestre.formation.referentiel_competence is None:
etud_ues_ids = {ue.id for ue in res.ues if res.modimpls_in_ue(ue, etud.id)}
else:
etud_ues_ids = res.etud_ues_ids(etud.id)
nbabsnj, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
etud_groups = sco_groups.get_etud_formsemestre_groups(
etud, formsemestre, only_to_show=True
)
@ -393,7 +447,7 @@ class BulletinBUT:
}
if self.prefs["bul_show_abs"]:
semestre_infos["absences"] = {
"injustifie": nbabs - nbabsjust,
"injustifie": nbabsnj,
"total": nbabs,
"metrique": {
"H.": "Heure(s)",
@ -410,7 +464,7 @@ class BulletinBUT:
semestre_infos.update(
sco_bulletins_json.dict_decision_jury(etud, formsemestre)
)
if etat_inscription == scu.INSCRIT:
if d["etat_inscription"] == scu.INSCRIT:
# moyenne des moyennes générales du semestre
semestre_infos["notes"] = {
"value": fmt_note(res.etud_moy_gen[etud.id]),
@ -499,10 +553,8 @@ class BulletinBUT:
d["etud"]["etat_civil"] = etud.etat_civil
d.update(self.res.sem)
etud_etat = self.res.get_etud_etat(etud.id)
d["filigranne"] = sco_bulletins_pdf.get_filigranne(
etud_etat,
self.prefs,
decision_sem=d["semestre"].get("decision"),
d["filigranne"] = sco_bulletins_pdf.get_filigranne_apc(
etud_etat, self.prefs, etud.id, res=self.res
)
if etud_etat == scu.DEMISSION:
d["demission"] = "(Démission)"
@ -512,7 +564,7 @@ class BulletinBUT:
d["demission"] = ""
# --- Absences
d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id)
_, d["nbabsjust"], d["nbabs"] = self.res.formsemestre.get_abs_count(etud.id)
# --- Decision Jury
infos, _ = sco_bulletins.etud_descr_situation_semestre(
@ -527,9 +579,9 @@ class BulletinBUT:
d.update(infos)
# --- Rangs
d[
"rang_nt"
] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
d["rang_nt"] = (
f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
)
d["rang_txt"] = "Rang " + d["rang_nt"]
d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))

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"]
@ -117,6 +119,12 @@ def _build_bulletin_but_infos(
refcomp = formsemestre.formation.referentiel_competence
if refcomp is None:
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
warn_html = cursus_but.formsemestre_warning_apc_setup(
formsemestre, bulletins_sem.res
)
if warn_html:
raise ScoValueError("<b>Formation mal configurée pour le BUT</b>" + warn_html)
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud
)
@ -131,6 +139,7 @@ def _build_bulletin_but_infos(
"decision_ues": decision_ues,
"ects_total": ects_total,
"etud": etud,
"filigranne": filigranne,
"formsemestre": formsemestre,
"logo": logo,
"prefs": bulletins_sem.prefs,

View File

@ -31,6 +31,7 @@ from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc.sco_logos import Logo
from app.scodoc.sco_pdf import PDFLOCK, SU
from app.scodoc.sco_preferences import SemPreferences
from app.scodoc import sco_utils as scu
def make_bulletin_but_court_pdf(
@ -48,6 +49,7 @@ def make_bulletin_but_court_pdf(
ects_total: float = 0.0,
etud: Identite = None,
formsemestre: FormSemestre = None,
filigranne=""
logo: Logo = None,
prefs: SemPreferences = None,
title: str = "",
@ -86,6 +88,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
decision_ues: dict = None,
ects_total: float = 0.0,
etud: Identite = None,
filigranne="",
formsemestre: FormSemestre = None,
logo: Logo = None,
prefs: SemPreferences = None,
@ -95,7 +98,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
] = None,
ues_acronyms: list[str] = None,
):
super().__init__(bul, authuser=current_user)
super().__init__(bul, authuser=current_user, filigranne=filigranne)
self.bul = bul
self.cursus = cursus
self.decision_ues = decision_ues
@ -192,7 +195,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
"""Génère la partie "titre" du bulletin de notes.
Renvoie une liste d'objets platypus
"""
# comme les bulletins standard, mais avec notre préférence
# comme les bulletins standards, mais avec notre préférence
return super().bul_title_pdf(preference_field=preference_field)
def bul_part_below(self, fmt="pdf") -> list:
@ -341,9 +344,11 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
for mod in self.bul[mod_type]:
row = [mod, bul[mod_type][mod]["titre"]]
row += [
bul["ues"][ue][mod_type][mod]["moyenne"]
if mod in bul["ues"][ue][mod_type]
else ""
(
bul["ues"][ue][mod_type][mod]["moyenne"]
if mod in bul["ues"][ue][mod_type]
else ""
)
for ue in self.ues_acronyms
]
rows.append(row)
@ -404,6 +409,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
def boite_identite(self) -> list:
"Les informations sur l'identité et l'inscription de l'étudiant"
parcour = self.formsemestre.etuds_inscriptions[self.etud.id].parcour
return [
Paragraph(
SU(f"""{self.etud.nomprenom}"""),
@ -414,7 +421,8 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
f"""
<b>{self.bul["demission"]}</b><br/>
Formation: {self.formsemestre.titre_num()}<br/>
Année scolaire: {self.formsemestre.annee_scolaire_str()}<br/>
{'Parcours ' + parcour.code + '<br/>' if parcour else ''}
Année universitaire: {self.formsemestre.annee_scolaire_str()}<br/>
"""
),
style=self.style_base,
@ -518,7 +526,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
if self.bul["semestre"].get("decision_annee", None):
txt += f"""
Décision saisie le {
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime("%d/%m/%Y")
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime(scu.DATE_FMT)
}, année BUT{self.bul["semestre"]["decision_annee"]["ordre"]}
<b>{self.bul["semestre"]["decision_annee"]["code"]}</b>.
<br/>

View File

@ -24,7 +24,7 @@ from reportlab.lib.colors import blue
from reportlab.lib.units import cm, mm
from reportlab.platypus import Paragraph, Spacer
from app.models import ScoDocSiteConfig
from app.models import Evaluation, ScoDocSiteConfig
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc import gen_tables
from app.scodoc.codes_cursus import UE_SPORT
@ -269,7 +269,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
date_capitalisation = ue.get("date_capitalisation")
if date_capitalisation:
fields_bmr.append(
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
f"""Capitalisée le {date_capitalisation.strftime(scu.DATE_FMT)}"""
)
t = {
"titre": " - ".join(fields_bmr),
@ -422,7 +422,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
def evaluations_rows(self, rows, evaluations: list[dict], ue_acros=()):
"lignes des évaluations"
for e in evaluations:
coef = e["coef"] if e["evaluation_type"] == scu.EVALUATION_NORMALE else "*"
coef = (
e["coef"]
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
else "*"
)
t = {
"titre": f"{e['description'] or ''}",
"moyenne": e["note"]["value"],
@ -431,7 +435,10 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
),
"coef": coef,
"_coef_pdf": Paragraph(
f"<para align=right fontSize={self.small_fontsize}><i>{coef}</i></para>"
f"""<para align=right fontSize={self.small_fontsize}><i>{
coef if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
else "bonus"
}</i></para>"""
),
"_pdf_style": [
(

View File

@ -38,14 +38,11 @@ import datetime
from xml.etree import ElementTree
from xml.etree.ElementTree import Element
from app import log
from app import db, log
from app.but import bulletin_but
from app.models import BulAppreciations, FormSemestre, Identite
from app.models import BulAppreciations, FormSemestre, Identite, UniteEns
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud
from app.scodoc import sco_photos
from app.scodoc import sco_preferences
from app.scodoc import sco_xml
@ -202,12 +199,12 @@ def bulletin_but_xml_compat(
if e.visibulletin or version == "long":
x_eval = Element(
"evaluation",
date_debut=e.date_debut.isoformat()
if e.date_debut
else "",
date_fin=e.date_fin.isoformat()
if e.date_debut
else "",
date_debut=(
e.date_debut.isoformat() if e.date_debut else ""
),
date_fin=(
e.date_fin.isoformat() if e.date_debut else ""
),
coefficient=str(e.coefficient),
# pas les poids en XML compat
evaluation_type=str(e.evaluation_type),
@ -215,9 +212,9 @@ def bulletin_but_xml_compat(
# notes envoyées sur 20, ceci juste pour garder trace:
note_max_origin=str(e.note_max),
# --- deprecated
jour=e.date_debut.isoformat()
if e.date_debut
else "",
jour=(
e.date_debut.isoformat() if e.date_debut else ""
),
heure_debut=e.heure_debut(),
heure_fin=e.heure_fin(),
)
@ -244,7 +241,7 @@ def bulletin_but_xml_compat(
# --- Absences
if sco_preferences.get_preference("bul_show_abs", formsemestre_id):
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
_, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
doc.append(Element("absences", nbabs=str(nbabs), nbabsjust=str(nbabsjust)))
# -------- LA SUITE EST COPIEE SANS MODIF DE sco_bulletins_xml.py ---------
@ -294,17 +291,18 @@ def bulletin_but_xml_compat(
"decisions_ue"
]: # and sco_preferences.get_preference( 'bul_show_uevalid', formsemestre_id): always publish (car utile pour export Apogee)
for ue_id in decision["decisions_ue"].keys():
ue = sco_edit_ue.ue_list({"ue_id": ue_id})[0]
doc.append(
Element(
"decision_ue",
ue_id=str(ue["ue_id"]),
numero=quote_xml_attr(ue["numero"]),
acronyme=quote_xml_attr(ue["acronyme"]),
titre=quote_xml_attr(ue["titre"]),
code=decision["decisions_ue"][ue_id]["code"],
ue = db.session.get(UniteEns, ue_id)
if ue:
doc.append(
Element(
"decision_ue",
ue_id=str(ue.id),
numero=quote_xml_attr(ue.numero),
acronyme=quote_xml_attr(ue.acronyme),
titre=quote_xml_attr(ue.titre or ""),
code=decision["decisions_ue"][ue_id]["code"],
)
)
)
for aut in decision["autorisations"]:
doc.append(

92
app/but/change_refcomp.py Normal file
View File

@ -0,0 +1,92 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Code expérimental: si deux référentiel sont presques identiques
(mêmes compétences, niveaux, parcours)
essaie de changer une formation de référentiel.
"""
from app import clear_scodoc_cache, db
from app.models import (
ApcParcours,
ApcReferentielCompetences,
ApcValidationRCUE,
Formation,
FormSemestreInscription,
UniteEns,
)
from app.scodoc.sco_exceptions import ScoValueError
def formation_change_referentiel(
formation: Formation, new_ref: ApcReferentielCompetences
):
"""Try to change ref."""
if not formation.referentiel_competence:
raise ScoValueError("formation non associée à un référentiel")
if not isinstance(new_ref, ApcReferentielCompetences):
raise ScoValueError("nouveau référentiel invalide")
r = formation.referentiel_competence.map_to_other_referentiel(new_ref)
if isinstance(r, str):
raise ScoValueError(f"référentiels incompatibles: {r}")
parcours_map, competences_map, niveaux_map = r
formation.referentiel_competence = new_ref
db.session.add(formation)
# UEs - Niveaux et UEs - parcours
for ue in formation.ues:
if ue.niveau_competence:
ue.niveau_competence_id = niveaux_map[ue.niveau_competence_id]
db.session.add(ue)
if ue.parcours:
new_list = [ApcParcours.query.get(parcours_map[p.id]) for p in ue.parcours]
ue.parcours.clear()
ue.parcours.extend(new_list)
db.session.add(ue)
# Modules / parcours et app_critiques
for module in formation.modules:
if module.parcours:
new_list = [
ApcParcours.query.get(parcours_map[p.id]) for p in module.parcours
]
module.parcours.clear()
module.parcours.extend(new_list)
db.session.add(module)
if module.app_critiques: # efface les apprentissages critiques
module.app_critiques.clear()
db.session.add(module)
# ApcValidationRCUE
for valid_rcue in ApcValidationRCUE.query.join(
UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id
).filter_by(formation_id=formation.id):
if valid_rcue.parcour:
valid_rcue.parcour_id = parcours_map[valid_rcue.parcour.id]
db.session.add(valid_rcue)
for valid_rcue in ApcValidationRCUE.query.join(
UniteEns, UniteEns.id == ApcValidationRCUE.ue2_id
).filter_by(formation_id=formation.id):
if valid_rcue.parcour:
valid_rcue.parcour_id = parcours_map[valid_rcue.parcour.id]
db.session.add(valid_rcue)
# FormSemestre / parcours_formsemestre
for formsemestre in formation.formsemestres:
new_list = [
ApcParcours.query.get(parcours_map[p.id]) for p in formsemestre.parcours
]
formsemestre.parcours.clear()
formsemestre.parcours.extend(new_list)
db.session.add(formsemestre)
# FormSemestreInscription.parcour_id
for inscr in FormSemestreInscription.query.filter_by(
formsemestre_id=formsemestre.id
).filter(FormSemestreInscription.parcour_id != None):
if inscr.parcour_id is not None:
inscr.parcour_id = parcours_map[inscr.parcour_id]
#
db.session.commit()
clear_scodoc_cache()

View File

@ -23,29 +23,21 @@ from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
)
from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
)
from app.models.ues import UEParcours
from app.models.but_validations import ApcValidationRCUE
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc import sco_cursus_dut
@ -119,8 +111,15 @@ class EtudCursusBUT:
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
validation_rcue: ApcValidationRCUE
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if niveau is None:
raise ScoValueError(
"""UE d'un RCUE non associée à un niveau de compétence.
Vérifiez la formation et les associations de ses UEs.
"""
)
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
@ -436,15 +435,38 @@ def formsemestre_warning_apc_setup(
"""
if not formsemestre.formation.is_apc():
return ""
url_formation = url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formsemestre.formation.id,
semestre_idx=formsemestre.semestre_id,
)
if formsemestre.formation.referentiel_competence is None:
return f"""<div class="formsemestre_status_warning">
La <a class="stdlink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
}">formation n'est pas associée à un référentiel de compétence.</a>
La <a class="stdlink" href="{url_formation}">formation
n'est pas associée à un référentiel de compétence.</a>
</div>
"""
# Vérifie les niveaux de chaque parcours
H = []
# Le semestre n'a pas de parcours, mais les UE ont des parcours ?
if not formsemestre.parcours:
nb_ues_sans_parcours = len(
formsemestre.formation.query_ues_parcour(None)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.all()
)
nb_ues_tot = (
UniteEns.query.filter_by(formation=formsemestre.formation, type=UE_STANDARD)
.filter(UniteEns.semestre_idx == formsemestre.semestre_id)
.count()
)
if nb_ues_sans_parcours != nb_ues_tot:
H.append(
"""Le semestre n'est associé à aucun parcours,
mais les UEs de la formation ont des parcours
"""
)
# Vérifie les niveaux de chaque parcours
for parcour in formsemestre.parcours or [None]:
annee = (formsemestre.semestre_id + 1) // 2
niveaux_ids = {
@ -469,7 +491,8 @@ def formsemestre_warning_apc_setup(
if not H:
return ""
return f"""<div class="formsemestre_status_warning">
Problème dans la configuration de la formation:
Problème dans la
<a class="stdlink" href="{url_formation}">configuration de la formation</a>:
<ul>
<li>{ '</li><li>'.join(H) }</li>
</ul>
@ -482,6 +505,79 @@ def formsemestre_warning_apc_setup(
"""
def formation_semestre_niveaux_warning(formation: Formation, semestre_idx: int) -> str:
"""Vérifie que tous les niveaux de compétences de cette année de formation
ont bien des UEs.
Afin de ne pas générer trop de messages, on ne considère que les parcours
du référentiel de compétences pour lesquels au moins une UE a été associée.
Renvoie fragment de html
"""
annee = (semestre_idx - 1) // 2 + 1 # année BUT
ref_comp: ApcReferentielCompetences = formation.referentiel_competence
if not ref_comp:
return "" # détecté ailleurs...
niveaux_sans_ue_by_parcour = {} # { parcour.code : [ ue ... ] }
parcours_ids = {
uep.parcours_id
for uep in UEParcours.query.join(UniteEns).filter_by(
formation_id=formation.id, type=UE_STANDARD
)
}
for parcour in ref_comp.parcours:
if parcour.id not in parcours_ids:
continue # saute parcours associés à aucune UE (tous semestres)
niveaux_sans_ue = []
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
# print(f"\n# Parcours {parcour.code} : {len(niveaux)} niveaux")
for niveau in niveaux:
ues = [ue for ue in formation.ues if ue.niveau_competence_id == niveau.id]
if not ues:
niveaux_sans_ue.append(niveau)
# print( niveau.competence.titre + " " + str(niveau.ordre) + "\t" + str(ue) )
if niveaux_sans_ue:
niveaux_sans_ue_by_parcour[parcour.code] = niveaux_sans_ue
#
H = []
for parcour_code, niveaux in niveaux_sans_ue_by_parcour.items():
H.append(
f"""<li>Parcours {parcour_code} : {
len(niveaux)} niveaux sans UEs&nbsp;:
<span class="niveau-nom"><span>
{ '</span>, <span>'.join( f'{niveau.competence.titre} {niveau.ordre}'
for niveau in niveaux
)
}
</span>
</li>
"""
)
# Combien de compétences de tronc commun ?
_, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
nb_niveaux_tc = len(niveaux_by_parcours["TC"])
nb_ues_tc = len(
formation.query_ues_parcour(None)
.filter(UniteEns.semestre_idx == semestre_idx)
.all()
)
if nb_niveaux_tc != nb_ues_tc:
H.append(
f"""<li>{nb_niveaux_tc} niveaux de compétences de tronc commun,
mais {nb_ues_tc} UEs de tronc commun ! (c'est normal si
vous avez des UEs différenciées par parcours)</li>"""
)
if H:
return f"""<div class="formation_semestre_niveaux_warning">
<div>Problèmes détectés à corriger :</div>
<ul>
{"".join(H)}
</ul>
</div>
"""
return "" # no problem detected
def ue_associee_au_niveau_du_parcours(
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
) -> UniteEns:

View File

@ -10,9 +10,11 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import SelectField, SubmitField
from wtforms.validators import DataRequired
class FormationRefCompForm(FlaskForm):
"Choix d'un référentiel"
referentiel_competence = SelectField(
"Choisir parmi les référentiels déjà chargés :"
)
@ -21,6 +23,7 @@ class FormationRefCompForm(FlaskForm):
class RefCompLoadForm(FlaskForm):
"Upload d'un référentiel"
referentiel_standard = SelectField(
"Choisir un référentiel de compétences officiel BUT"
)
@ -47,3 +50,12 @@ class RefCompLoadForm(FlaskForm):
)
return False
return True
class FormationChangeRefCompForm(FlaskForm):
"choix d'un nouveau ref. comp. pour une formation"
object_select = SelectField(
"Choisir le nouveau référentiel", validators=[DataRequired()]
)
submit = SubmitField("Changer le référentiel de la formation")
cancel = SubmitField("Annuler")

View File

@ -23,9 +23,12 @@ from app.models.but_refcomp import (
from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError
def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
def orebut_import_refcomp(
xml_data: str, dept_id: int, orig_filename=None
) -> ApcReferentielCompetences:
"""Importation XML Orébut
peut lever TypeError ou ScoFormatError
L'objet créé est ajouté et commité.
Résultat: instance de ApcReferentielCompetences
"""
# Vérifie que le même fichier n'a pas déjà été chargé:
@ -41,7 +44,7 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
try:
root = ElementTree.XML(xml_data)
except ElementTree.ParseError as exc:
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}")
raise ScoFormatError(f"fichier XML Orébut invalide (2): {exc.args}") from exc
if root.tag != "referentiel_competence":
raise ScoFormatError("élément racine 'referentiel_competence' manquant")
args = ApcReferentielCompetences.attr_from_xml(root.attrib)
@ -60,7 +63,8 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None):
# ne devrait plus se produire car pas d'unicité de l'id: donc inutile
db.session.rollback()
raise ScoValueError(
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({competence.attrib["id"]})
f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({
competence.attrib["id"]})
"""
) from exc
ref.competences.append(c)

View File

@ -77,7 +77,7 @@ from app.models.but_refcomp import (
ApcNiveau,
ApcParcours,
)
from app.models import Scolog, ScolarAutorisationInscription
from app.models import Evaluation, ModuleImpl, Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
@ -260,11 +260,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
else []
)
# ---- Niveaux et RCUEs
niveaux_by_parcours = (
formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
self.annee_but, [self.parcour] if self.parcour else None
)[1]
)
niveaux_by_parcours = formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
self.annee_but, [self.parcour] if self.parcour else None
)[
1
]
self.niveaux_competences = niveaux_by_parcours["TC"] + (
niveaux_by_parcours[self.parcour.id] if self.parcour else []
)
@ -358,13 +358,17 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# self.codes = [] # pas de décision annuelle sur semestres impairs
elif self.inscription_etat != scu.INSCRIT:
self.codes = [
sco_codes.DEM
if self.inscription_etat == scu.DEMISSION
else sco_codes.DEF,
(
sco_codes.DEM
if self.inscription_etat == scu.DEMISSION
else sco_codes.DEF
),
# propose aussi d'autres codes, au cas où...
sco_codes.DEM
if self.inscription_etat != scu.DEMISSION
else sco_codes.DEF,
(
sco_codes.DEM
if self.inscription_etat != scu.DEMISSION
else sco_codes.DEF
),
sco_codes.ABAN,
sco_codes.ABL,
sco_codes.EXCLU,
@ -380,14 +384,24 @@ class DecisionsProposeesAnnee(DecisionsProposees):
sco_codes.ADJ,
] + self.codes
explanation += f" et {self.nb_rcues_under_8} < 8"
else:
self.codes = [
sco_codes.RED,
sco_codes.NAR,
sco_codes.PAS1NCI,
sco_codes.ADJ,
sco_codes.PASD, # voir #488 (discutable, conventions locales)
] + self.codes
else: # autres cas: non admis, non passage, non dem, pas la moitié des rcue:
if formsemestre.semestre_id % 2 and self.formsemestre_pair is None:
# Si jury sur un seul semestre impair, ne propose pas redoublement
# et efface décision éventuellement existante
codes = [None]
else:
codes = []
self.codes = (
codes
+ [
sco_codes.RED,
sco_codes.NAR,
sco_codes.PAS1NCI,
sco_codes.ADJ,
sco_codes.PASD, # voir #488 (discutable, conventions locales)
]
+ self.codes
)
explanation += f""" et {self.nb_rcues_under_8
} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
@ -399,15 +413,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# Si validée par niveau supérieur:
if self.code_valide == sco_codes.ADSUP:
self.codes.insert(0, sco_codes.ADSUP)
self.explanation = f"<div>{explanation}</div>"
self.explanation = f'<div class="deca-expl">{explanation}</div>'
messages = self.descr_pb_coherence()
if messages:
self.explanation += (
'<div class="warning">'
+ '</div><div class="warning">'.join(messages)
'<div class="warning warning-info">'
+ '</div><div class="warning warning-info">'.join(messages)
+ "</div>"
)
self.codes = [self.codes[0]] + sorted(self.codes[1:])
self.codes = [self.codes[0]] + sorted((c or "") for c in self.codes[1:])
def passage_de_droit_en_but3(self) -> tuple[bool, str]:
"""Vérifie si les conditions supplémentaires de passage BUT2 vers BUT3 sont satisfaites"""
@ -514,19 +528,21 @@ class DecisionsProposeesAnnee(DecisionsProposees):
"""Les deux formsemestres auquel est inscrit l'étudiant (ni DEM ni DEF)
du niveau auquel appartient formsemestre.
-> S_impair, S_pair
-> S_impair, S_pair (de la même année scolaire)
Si l'origine est impair, S_impair est l'origine et S_pair est None
Si l'origine est paire, S_pair est l'origine, et S_impair l'antérieur
suivi par cet étudiant (ou None).
Note: si l'option "block_moyennes" est activée, ne prend pas en compte le semestre.
"""
if not formsemestre.formation.is_apc(): # garde fou
return None, None
if formsemestre.semestre_id % 2:
idx_autre = formsemestre.semestre_id + 1
idx_autre = formsemestre.semestre_id + 1 # impair, autre = suivant
else:
idx_autre = formsemestre.semestre_id - 1
idx_autre = formsemestre.semestre_id - 1 # pair: autre = précédent
# Cherche l'autre semestre de la même année scolaire:
autre_formsemestre = None
@ -539,6 +555,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
inscr.formsemestre.formation.referentiel_competence
== formsemestre.formation.referentiel_competence
)
# Non bloqué
and not inscr.formsemestre.block_moyennes
# L'autre semestre
and (inscr.formsemestre.semestre_id == idx_autre)
# de la même année scolaire
@ -581,11 +599,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# Ordonne par numéro d'UE
niv_rcue = sorted(
self.rcue_by_niveau.items(),
key=lambda x: x[1].ue_1.numero
if x[1].ue_1
else x[1].ue_2.numero
if x[1].ue_2
else 0,
key=lambda x: (
x[1].ue_1.numero if x[1].ue_1 else x[1].ue_2.numero if x[1].ue_2 else 0
),
)
return {
niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
@ -610,6 +626,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def next_semestre_ids(self, code: str) -> set[int]:
"""Les indices des semestres dans lequels l'étudiant est autorisé
à poursuivre après le semestre courant.
code: code jury sur année BUT
"""
# La poursuite d'études dans un semestre pair d'une même année
# est de droit pour tout étudiant.
@ -653,6 +670,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
Si les code_rcue et le code_annee ne sont pas fournis,
et qu'il n'y en a pas déjà, enregistre ceux par défaut.
Si le code_annee est None, efface le code déjà enregistré.
"""
log("jury_but.DecisionsProposeesAnnee.record_form")
code_annee = self.codes[0] # si pas dans le form, valeur par defaut
@ -697,6 +716,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def record(self, code: str, mark_recorded: bool = True) -> bool:
"""Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
Si l'étudiant est DEM ou DEF, ne fait rien.
Si le code est None, efface le code déjà enregistré.
Si mark_recorded est vrai, positionne self.recorded
"""
if self.inscription_etat != scu.INSCRIT:
@ -746,7 +766,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
return True
def record_autorisation_inscription(self, code: str):
"""Autorisation d'inscription dans semestre suivant"""
"""Autorisation d'inscription dans semestre suivant.
code: code jury sur année BUT
"""
if self.autorisations_recorded:
return
if self.inscription_etat != scu.INSCRIT:
@ -774,16 +796,33 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if self.formsemestre_pair is not None:
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
def has_notes_en_attente(self) -> bool:
"Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
res = (
def _get_current_res(self) -> ResultatsSemestreBUT:
"Les res. du semestre d'origine du deca"
return (
self.res_pair
if self.formsemestre_pair
and (self.formsemestre.id == self.formsemestre_pair.id)
else self.res_impair
)
def has_notes_en_attente(self) -> bool:
"Vrai si l'étudiant a au moins une note en attente dans le semestre origine de ce deca"
res = self._get_current_res()
return res and self.etud.id in res.get_etudids_attente()
def get_modimpls_attente(self) -> list[ModuleImpl]:
"Liste des ModuleImpl dans lesquels l'étudiant à au moins une note en ATTente"
res = self._get_current_res()
modimpls_results = [
modimpl_result
for modimpl_result in res.modimpls_results.values()
if self.etud.id in modimpl_result.etudids_attente
]
modimpls = [
db.session.get(ModuleImpl, mr.moduleimpl_id) for mr in modimpls_results
]
return sorted(modimpls, key=lambda mi: (mi.module.numero, mi.module.code))
def record_all(self, only_validantes: bool = False) -> bool:
"""Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
et sont donc en mode "automatique".
@ -796,9 +835,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
Return: True si au moins un code modifié et enregistré.
"""
modif = False
# Vérification notes en attente dans formsemestre origine
if only_validantes and self.has_notes_en_attente():
return False
if only_validantes:
if self.has_notes_en_attente():
# notes en attente dans formsemestre origine
return False
if Evaluation.get_evaluations_blocked_for_etud(
self.formsemestre, self.etud
):
# évaluation(s) qui seront débloquées dans le futur
return False
# Toujours valider dans l'ordre UE, RCUE, Année
annee_scolaire = self.formsemestre.annee_scolaire()
@ -969,19 +1014,23 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if dec_ue.code_valide not in CODES_UE_VALIDES:
if (
dec_ue.ue_status
and dec_ue.ue_status["was_capitalized"]
and dec_ue.ue_status["is_capitalized"]
):
messages.append(
f"Information: l'UE {ue.acronyme} capitalisée est utilisée pour un RCUE cette année"
)
else:
messages.append(
f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !"
f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est (probablement une validation antérieure)"
)
else:
messages.append(
f"L'UE {ue.acronyme} n'a pas décision (???)"
)
# Voyons si on est dispensé de cette ue ?
res = self.res_impair if ue.semestre_idx % 2 else self.res_pair
if res and (self.etud.id, ue.id) in res.dispense_ues:
messages.append(f"Pas (ré)inscrit à l'UE {ue.acronyme}")
return messages
def valide_diplome(self) -> bool:
@ -1468,9 +1517,11 @@ class DecisionsProposeesUE(DecisionsProposees):
self.validation = None # cache toute validation
self.explanation = "non inscrit (dem. ou déf.)"
self.codes = [
sco_codes.DEM
if res.get_etud_etat(etud.id) == scu.DEMISSION
else sco_codes.DEF
(
sco_codes.DEM
if res.get_etud_etat(etud.id) == scu.DEMISSION
else sco_codes.DEF
)
]
return
@ -1484,7 +1535,7 @@ class DecisionsProposeesUE(DecisionsProposees):
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide
} codes={self.codes} explanation={self.explanation}>"""
} codes={self.codes} explanation="{self.explanation}">"""
def compute_codes(self):
"""Calcul des .codes attribuables et de l'explanation associée"""

View File

@ -55,11 +55,21 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
else:
line_sep = "\n"
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep)
if fmt.startswith("xls"):
titles.update(
{
"etudid": "etudid",
"code_nip": "nip",
"code_ine": "ine",
"ects_but": "Total ECTS BUT",
"civilite": "Civ.",
"nom": "Nom",
"prenom": "Prénom",
}
)
# Style excel... passages à la ligne sur \n
xls_style_base = sco_excel.excel_make_style()
xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top")
tab = GenTable(
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
caption=title,
@ -116,7 +126,7 @@ def pvjury_table_but(
"pas de référentiel de compétences associé à la formation de ce semestre !"
)
titles = {
"nom": "Code" if anonymous else "Nom",
"nom_pv": "Code" if anonymous else "Nom",
"cursus": "Cursus",
"ects": "ECTS",
"ues": "UE validées",
@ -144,33 +154,47 @@ def pvjury_table_but(
except ScoValueError:
deca = None
ects_but_valides = but_ects_valides(etud, referentiel_competence_id)
row = {
"nom": etud.code_ine or etud.code_nip or etud.id
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
else etud.etat_civil_pv(
line_sep=line_sep, with_paragraph=with_paragraph_nom
"nom_pv": (
etud.code_ine or etud.code_nip or etud.id
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
else etud.etat_civil_pv(
line_sep=line_sep, with_paragraph=with_paragraph_nom
)
),
"_nom_order": etud.sort_key,
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_target": url_for(
"scolar.ficheEtud",
"_nom_pv_order": etud.sort_key,
"_nom_pv_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_pv_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_pv_target": url_for(
"scolar.fiche_etud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
),
"cursus": _descr_cursus_but(etud),
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {but_ects_valides(etud, referentiel_competence_id ):g}""",
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {ects_but_valides:g}""",
"_ects_xls": deca.ects_annee(),
"ects_but": ects_but_valides,
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
if deca
else "-",
"niveaux": (
deca.descr_niveaux_validation(line_sep=line_sep) if deca else "-"
),
"decision_but": deca.code_valide if deca else "",
"devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else "",
"devenir": (
", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else ""
),
# pour exports excel seulement:
"civilite": etud.civilite_etat_civil_str,
"nom": etud.nom,
"prenom": etud.prenom_etat_civil or etud.prenom or "",
"etudid": etud.id,
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
}
if deca.valide_diplome() or not only_diplome:
rows.append(row)
rows.sort(key=lambda x: x["_nom_order"])
rows.sort(key=lambda x: x["_nom_pv_order"])
return rows, titles

View File

@ -16,8 +16,8 @@ from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True
) -> int:
formsemestre: FormSemestre, only_adm: bool = True, dry_run=False
) -> tuple[int, list[jury_but.DecisionsProposeesAnnee]]:
"""Calcul automatique des décisions de jury sur une "année" BUT.
- N'enregistre jamais de décisions de l'année scolaire précédente, même
@ -27,16 +27,22 @@ def formsemestre_validation_auto_but(
En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
(mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
Returns:
- En mode normal, (nombre d'étudiants pour lesquels on a enregistré au moins un code, []])
- En mode dry_run, (0, list[DecisionsProposeesAnnee])
"""
if not formsemestre.formation.is_apc():
raise ScoValueError("fonction réservée aux formations BUT")
nb_etud_modif = 0
decas = []
with sco_cache.DeferredSemCacheManager():
for etudid in formsemestre.etuds_inscriptions:
etud = Identite.get_etud(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
nb_etud_modif += deca.record_all(only_validantes=only_adm)
if not dry_run:
nb_etud_modif += deca.record_all(only_validantes=only_adm)
else:
decas.append(deca)
db.session.commit()
ScolarNews.add(
@ -49,4 +55,4 @@ def formsemestre_validation_auto_but(
formsemestre_id=formsemestre.id,
),
)
return nb_etud_modif
return nb_etud_modif, decas

View File

@ -21,8 +21,6 @@ from app.but.jury_but import (
DecisionsProposeesRCUE,
DecisionsProposeesUE,
)
from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
from app.models import (
ApcNiveau,
FormSemestre,
@ -33,11 +31,8 @@ from app.models import (
ScolarFormSemestreValidation,
ScolarNews,
)
from app.models.config import ScoDocSiteConfig
from app.scodoc import html_sco_header
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
@ -97,7 +92,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
<span class="avertissement_redoublement">{formsemestre_2.annee_scolaire_str()
if formsemestre_2 else ""}</span>
</div>
<div class="titre">RCUE</div>
<div class="titre" title="Décisions sur RCUEs enregistrées sur l'ensemble du cursus">RCUE</div>
"""
)
for dec_rcue in deca.get_decisions_rcues_annee():
@ -109,23 +104,32 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
</div>"""
)
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2
# Les UEs à afficher,
# qui
ues_ro = [
# Les UEs à afficher : on regarde si read only et si dispense (non ré-inscription à l'UE)
# tuples (UniteEns, read_only, dispense)
ues_ro_dispense = [
(
ue_impair,
rcue.ue_cur_impair is None,
deca.res_impair
and ue_impair
and (deca.etud.id, ue_impair.id) in deca.res_impair.dispense_ues,
),
(
ue_pair,
rcue.ue_cur_pair is None,
deca.res_pair
and ue_pair
and (deca.etud.id, ue_pair.id) in deca.res_pair.dispense_ues,
),
]
# Ordonne selon les dates des 2 semestres considérés:
if reverse_semestre:
ues_ro[0], ues_ro[1] = ues_ro[1], ues_ro[0]
ues_ro_dispense[0], ues_ro_dispense[1] = (
ues_ro_dispense[1],
ues_ro_dispense[0],
)
# Colonnes d'UE:
for ue, ue_read_only in ues_ro:
for ue, ue_read_only, ue_dispense in ues_ro_dispense:
if ue:
H.append(
_gen_but_niveau_ue(
@ -134,6 +138,7 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
disabled=read_only or ue_read_only,
annee_prec=ue_read_only,
niveau_id=ue.niveau_competence.id,
ue_dispense=ue_dispense,
)
)
else:
@ -188,21 +193,30 @@ def _gen_but_niveau_ue(
disabled: bool = False,
annee_prec: bool = False,
niveau_id: int = None,
ue_dispense: bool = False,
) -> str:
if dec_ue.ue_status and dec_ue.ue_status["is_capitalized"]:
moy_ue_str = f"""<span class="ue_cap">{
scu.fmt_note(dec_ue.moy_ue_with_cap)}</span>"""
scoplement = f"""<div class="scoplement">
<div>
<b>UE {ue.acronyme} capitalisée </b>
<span>le {dec_ue.ue_status["event_date"].strftime("%d/%m/%Y")}
</span>
</div>
<div>UE en cours
if ue_dispense:
etat_en_cours = """Non (ré)inscrit à cette UE"""
else:
etat_en_cours = f"""UE en cours
{ "sans notes" if np.isnan(dec_ue.moy_ue)
else
("avec moyenne <b>" + scu.fmt_note(dec_ue.moy_ue) + "</b>")
}
"""
scoplement = f"""<div class="scoplement">
<div>
<b>UE {ue.acronyme} capitalisée </b>
<span>le {dec_ue.ue_status["event_date"].strftime(scu.DATE_FMT)}
</span>
</div>
<div>
{ etat_en_cours }
</div>
</div>
"""
@ -214,7 +228,7 @@ def _gen_but_niveau_ue(
<div>
<b>UE {ue.acronyme} antérieure </b>
<span>validée {dec_ue.validation.code}
le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
le {dec_ue.validation.event_date.strftime(scu.DATE_FMT)}
</span>
</div>
<div>Non reprise dans l'année en cours</div>
@ -232,9 +246,7 @@ def _gen_but_niveau_ue(
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
if dec_ue.code_valide:
date_str = (
f"""enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
"""
f"""enregistré le {dec_ue.validation.event_date.strftime(scu.DATEATIME_FMT)}"""
if dec_ue.validation and dec_ue.validation.event_date
else ""
)
@ -244,7 +256,13 @@ def _gen_but_niveau_ue(
</div>
"""
else:
scoplement = ""
if dec_ue.ue_status and dec_ue.ue_status["was_capitalized"]:
scoplement = """<div class="scoplement">
UE déjà capitalisée avec résultat moins favorable.
</div>
"""
else:
scoplement = ""
ue_class = "" # 'recorded' if dec_ue.code_valide is not None else ''
if dec_ue.code_valide is not None and dec_ue.codes:
@ -256,7 +274,7 @@ def _gen_but_niveau_ue(
return f"""<div class="but_niveau_ue {ue_class}
{'annee_prec' if annee_prec else ''}
">
<div title="{ue.titre}">{ue.acronyme}</div>
<div title="{ue.titre or ''}">{ue.acronyme}</div>
<div class="but_note with_scoplement">
<div>{moy_ue_str}</div>
{scoplement}
@ -331,250 +349,6 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
"""
def jury_but_semestriel(
formsemestre: FormSemestre,
etud: Identite,
read_only: bool,
navigation_div: str = "",
) -> str:
"""Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)."""
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res)
inscription_etat = etud.inscription_etat(formsemestre.id)
semestre_terminal = (
formsemestre.semestre_id >= formsemestre.formation.get_cursus().NB_SEM
)
autorisations_passage = ScolarAutorisationInscription.query.filter_by(
etudid=etud.id,
origin_formsemestre_id=formsemestre.id,
).all()
# Par défaut: autorisé à passer dans le semestre suivant si sem. impair,
# ou si décision déjà enregistrée:
est_autorise_a_passer = (formsemestre.semestre_id % 2) or (
formsemestre.semestre_id + 1
) in (a.semestre_id for a in autorisations_passage)
decisions_ues = {
ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat)
for ue in ues
}
for dec_ue in decisions_ues.values():
dec_ue.compute_codes()
if request.method == "POST":
if not read_only:
for key in request.form:
code = request.form[key]
# Codes d'UE
code_match = re.match(r"^code_ue_(\d+)$", key)
if code_match:
ue_id = int(code_match.group(1))
dec_ue = decisions_ues.get(ue_id)
if not dec_ue:
raise ScoValueError(f"UE invalide ue_id={ue_id}")
dec_ue.record(code)
db.session.commit()
flash("codes enregistrés")
if not semestre_terminal:
if request.form.get("autorisation_passage"):
if not formsemestre.semestre_id + 1 in (
a.semestre_id for a in autorisations_passage
):
ScolarAutorisationInscription.delete_autorisation_etud(
etud.id, formsemestre.id
)
ScolarAutorisationInscription.autorise_etud(
etud.id,
formsemestre.formation.formation_code,
formsemestre.id,
formsemestre.semestre_id + 1,
)
db.session.commit()
flash(
f"""autorisation de passage en S{formsemestre.semestre_id + 1
} enregistrée"""
)
else:
if est_autorise_a_passer:
ScolarAutorisationInscription.delete_autorisation_etud(
etud.id, formsemestre.id
)
db.session.commit()
flash(
f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée"
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=formsemestre.id,
text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
)
return flask.redirect(
url_for(
"notes.formsemestre_validation_but",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
etudid=etud.id,
)
)
# GET
if formsemestre.semestre_id % 2 == 0:
warning = f"""<div class="warning">
Cet étudiant de S{formsemestre.semestre_id} ne peut pas passer
en jury BUT annuel car il lui manque le semestre précédent.
</div>"""
else:
warning = ""
H = [
html_sco_header.sco_header(
page_title=f"Validation BUT S{formsemestre.semestre_id}",
formsemestre_id=formsemestre.id,
etudid=etud.id,
cssstyles=("css/jury_but.css",),
javascripts=("js/jury_but.js",),
),
f"""
<div class="jury_but">
<div>
<div class="bull_head">
<div>
<div class="titre_parcours">Jury BUT S{formsemestre.id}
- Parcours {(parcour.libelle if parcour else False) or "non spécifié"}
</div>
<div class="nom_etud">{etud.nomprenom}</div>
</div>
<div class="bull_photo"><a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
</div>
</div>
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
{warning}
</div>
<form method="post" class="jury_but_box" id="jury_but">
""",
]
erase_span = ""
if not read_only:
# Requête toutes les validations (pas seulement celles du deca courant),
# au cas où: changement d'architecture, saisie en mode classique, ...
validations = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
).all()
if validations:
erase_span = f"""<a href="{
url_for("notes.formsemestre_jury_but_erase",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
etudid=etud.id, only_one_sem=1)
}" class="stdlink">effacer les décisions enregistrées</a>"""
else:
erase_span = (
"Cet étudiant n'a aucune décision enregistrée pour ce semestre."
)
H.append(
f"""
<div class="but_section_annee">
</div>
<div><b>Unités d'enseignement de S{formsemestre.semestre_id}:</b></div>
"""
)
if not ues:
H.append(
"""<div class="warning">Aucune UE ! Vérifiez votre programme de
formation, et l'association UEs / Niveaux de compétences</div>"""
)
else:
H.append(
"""
<div class="but_annee">
<div class="titre"></div>
<div class="titre"></div>
<div class="titre"></div>
<div class="titre"></div>
"""
)
for ue in ues:
dec_ue = decisions_ues[ue.id]
H.append("""<div class="but_niveau_titre"><div></div></div>""")
H.append(
_gen_but_niveau_ue(
ue,
dec_ue,
disabled=read_only,
)
)
H.append(
"""<div style=""></div>
<div class=""></div>"""
)
H.append("</div>") # but_annee
div_autorisations_passage = (
f"""
<div class="but_autorisations_passage">
<span>Autorisé à passer en&nbsp;:</span>
{ ", ".join( ["S" + str(a.semestre_id or '') for a in autorisations_passage ] )}
</div>
"""
if autorisations_passage
else """<div class="but_autorisations_passage but_explanation">pas d'autorisations de passage enregistrées.</div>"""
)
H.append(div_autorisations_passage)
if read_only:
H.append(
f"""<div class="but_explanation">
{"Vous n'avez pas la permission de modifier ces décisions."
if formsemestre.etat
else "Semestre verrouillé."}
Les champs entourés en vert sont enregistrés.
</div>
"""
)
else:
if formsemestre.semestre_id < formsemestre.formation.get_cursus().NB_SEM:
H.append(
f"""
<div class="but_settings">
<input type="checkbox" name="autorisation_passage" value="1" {
"checked" if est_autorise_a_passer else ""}>
<em>autoriser à passer dans le semestre S{formsemestre.semestre_id+1}</em>
</input>
</div>
"""
)
else:
H.append("""<div class="help">dernier semestre de la formation.</div>""")
H.append(
f"""
<div class="but_buttons">
<span><input type="submit" value="Enregistrer ces décisions"></span>
<span>{erase_span}</span>
</div>
"""
)
H.append(navigation_div)
H.append("</div>")
H.append(
render_template(
"but/documentation_codes_jury.j2",
nom_univ=f"""Export {sco_preferences.get_preference("InstituteName")
or sco_preferences.get_preference("UnivName")
or "Apogée"}""",
codes=ScoDocSiteConfig.get_codes_apo_dict(),
)
)
return "\n".join(H)
# -------------
def infos_fiche_etud_html(etudid: int) -> str:
"""Section html pour fiche etudiant

View File

@ -75,7 +75,7 @@ class RegroupementCoherentUE:
else None
)
# Autres validations pour l'UE paire
# Autres validations pour les UEs paire/impaire
self.validation_ue_best_pair = best_autre_ue_validation(
etud.id,
niveau.id,
@ -101,14 +101,24 @@ class RegroupementCoherentUE:
"résultats formsemestre de l'UE si elle est courante, None sinon"
self.ue_status_impair = None
if self.ue_cur_impair:
# UE courante
ue_status = res_impair.get_etud_ue_status(etud.id, self.ue_cur_impair.id)
self.moy_ue_1 = ue_status["moy"] if ue_status else None # avec capitalisée
self.ue_1 = self.ue_cur_impair
self.res_impair = res_impair
self.ue_status_impair = ue_status
elif self.validation_ue_best_impair:
# UE capitalisée
self.moy_ue_1 = self.validation_ue_best_impair.moy_ue
self.ue_1 = self.validation_ue_best_impair.ue
if (
res_impair
and self.validation_ue_best_impair
and self.validation_ue_best_impair.ue
):
self.ue_status_impair = res_impair.get_etud_ue_status(
etud.id, self.validation_ue_best_impair.ue.id
)
else:
self.moy_ue_1, self.ue_1 = None, None
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0

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

@ -23,6 +23,7 @@ from app.models import (
)
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
class ValidationsSemestre(ResultatsCache):
@ -84,7 +85,7 @@ class ValidationsSemestre(ResultatsCache):
"code": decision.code,
"assidu": decision.assidu,
"compense_formsemestre_id": decision.compense_formsemestre_id,
"event_date": decision.event_date.strftime("%d/%m/%Y"),
"event_date": decision.event_date.strftime(scu.DATE_FMT),
}
self.decisions_jury = decisions_jury
@ -107,7 +108,7 @@ class ValidationsSemestre(ResultatsCache):
decisions_jury_ues[decision.etudid][decision.ue.id] = {
"code": decision.code,
"ects": ects, # 0. si UE non validée
"event_date": decision.event_date.strftime("%d/%m/%Y"),
"event_date": decision.event_date.strftime(scu.DATE_FMT),
}
self.decisions_jury_ues = decisions_jury_ues

View File

@ -35,7 +35,6 @@ moyenne générale d'une UE.
"""
import dataclasses
from dataclasses import dataclass
import numpy as np
import pandas as pd
import sqlalchemy as sa
@ -56,6 +55,7 @@ class EvaluationEtat:
evaluation_id: int
nb_attente: int
nb_notes: int # nb notes d'étudiants inscrits au semestre et au modimpl
is_complete: bool
def to_dict(self):
@ -72,7 +72,15 @@ class ModuleImplResults:
les caches sont gérés par ResultatsSemestre.
"""
def __init__(self, moduleimpl: ModuleImpl):
def __init__(
self, moduleimpl: ModuleImpl, etudids: list[int], etudids_actifs: set[int]
):
"""
Args:
- etudids : liste des etudids, qui donne l'index du dataframe
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
"""
self.moduleimpl_id = moduleimpl.id
self.module_id = moduleimpl.module.id
self.etudids = None
@ -105,14 +113,21 @@ class ModuleImplResults:
"""
self.evals_etudids_sans_note = {}
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
self.load_notes()
self.load_notes(etudids, etudids_actifs)
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module vient de la session2"""
self.etuds_use_rattrapage = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module utilise la note de rattrapage"""
def load_notes(self): # ré-écriture de df_load_modimpl_notes
def load_notes(
self, etudids: list[int], etudids_actifs: set[int]
): # ré-écriture de df_load_modimpl_notes
"""Charge toutes les notes de toutes les évaluations du module.
Args:
- etudids : liste des etudids, qui donne l'index du dataframe
(doit être tous les étudiants inscrits au semestre incluant les DEM et DEF)
- etudids_actifs l'ensemble des étudiants inscrits au semestre, non DEM/DEF.
Dataframe evals_notes
colonnes: le nom de la colonne est l'evaluation_id (int)
index (lignes): etudid (int)
@ -135,12 +150,12 @@ class ModuleImplResults:
qui ont des notes ATT.
"""
moduleimpl = db.session.get(ModuleImpl, self.moduleimpl_id)
self.etudids = self._etudids()
self.etudids = etudids
# --- Calcul nombre d'inscrits pour déterminer les évaluations "completes":
# on prend les inscrits au module ET au semestre (donc sans démissionnaires)
inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection(
moduleimpl.formsemestre.etudids_actifs
etudids_actifs
)
self.nb_inscrits_module = len(inscrits_module)
@ -148,19 +163,21 @@ class ModuleImplResults:
evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
self.evaluations_completes = []
self.evaluations_completes_dict = {}
self.etudids_attente = set() # empty
for evaluation in moduleimpl.evaluations:
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi tous les inscrits (non dem) au semestre ont une note
# ou évaluation déclarée "à prise en compte immédiate"
# Les évaluations de rattrapage et 2eme session sont toujours complètes
# is_complete ssi
# tous les inscrits (non dem) au module ont une note
# ou évaluation déclarée "à prise en compte immédiate"
# ou rattrapage, 2eme session, bonus
# ET pas bloquée par date (is_blocked)
is_blocked = evaluation.is_blocked()
etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem.
is_complete = (
(evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE)
or (evaluation.evaluation_type == scu.EVALUATION_SESSION2)
(evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
or (evaluation.publish_incomplete)
or (not etudids_sans_note)
)
) and not is_blocked
self.evaluations_completes.append(is_complete)
self.evaluations_completes_dict[evaluation.id] = is_complete
self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note
@ -168,25 +185,39 @@ class ModuleImplResults:
# NULL en base => ABS (= -999)
eval_df.fillna(scu.NOTES_ABSENCE, inplace=True)
# Ce merge ne garde que les étudiants inscrits au module
# et met à NULL les notes non présentes
# et met à NULL (NaN) les notes non présentes
# (notes non saisies ou etuds non inscrits au module):
evals_notes = evals_notes.merge(
eval_df, how="left", left_index=True, right_index=True
)
# Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)]
eval_etudids_attente = set(
eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
].index
)
# Nombre de notes (non vides, incluant ATT etc) des inscrits:
nb_notes = eval_notes_inscr.notna().sum()
if is_blocked:
eval_etudids_attente = set()
else:
# Etudiants avec notes en attente:
# = ceux avec note ATT
eval_etudids_attente = set(
eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
].index
)
if evaluation.publish_incomplete:
# et en "immédiat", tous ceux sans note
eval_etudids_attente |= etudids_sans_note
# Synthèse pour état du module:
self.etudids_attente |= eval_etudids_attente
self.evaluations_etat[evaluation.id] = EvaluationEtat(
evaluation_id=evaluation.id,
nb_attente=len(eval_etudids_attente),
nb_notes=int(nb_notes),
is_complete=is_complete,
)
# au moins une note en ATT dans ce modimpl:
# au moins une note en attente (ATT ou manquante en mode "immédiat") dans ce modimpl:
self.en_attente = bool(self.etudids_attente)
# Force columns names to integers (evaluation ids)
@ -219,30 +250,20 @@ class ModuleImplResults:
eval_df[str(evaluation.id)] = pd.to_numeric(eval_df[str(evaluation.id)])
return eval_df
def _etudids(self):
"""L'index du dataframe est la liste de tous les étudiants inscrits au semestre
(incluant les DEM et DEF)
"""
return [
inscr.etudid
for inscr in db.session.get(
ModuleImpl, self.moduleimpl_id
).formsemestre.inscriptions
]
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
def get_evaluations_coefs(self, modimpl: ModuleImpl) -> np.array:
"""Coefficients des évaluations.
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
sont zéro.
Les coefs des évals incomplètes, rattrapage, session 2, bonus sont forcés à zéro.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
e.coefficient
if e.evaluation_type == scu.EVALUATION_NORMALE
else 0.0
for e in moduleimpl.evaluations
(
e.coefficient
if e.evaluation_type == Evaluation.EVALUATION_NORMALE
else 0.0
)
for e in modimpl.evaluations
],
dtype=float,
)
@ -266,7 +287,7 @@ class ModuleImplResults:
) / [e.note_max / 20.0 for e in moduleimpl.evaluations]
def get_eval_notes_dict(self, evaluation_id: int) -> dict:
"""Notes d'une évaulation, brutes, sous forme d'un dict
"""Notes d'une évaluation, brutes, sous forme d'un dict
{ etudid : valeur }
avec les valeurs float, ou "ABS" ou EXC
"""
@ -275,7 +296,7 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items()
}
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl):
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None:
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
Rattrapage: la moyenne du module est la meilleure note entre moyenne
des autres évals et la note eval rattrapage.
@ -283,25 +304,41 @@ class ModuleImplResults:
eval_list = [
e
for e in moduleimpl.evaluations
if e.evaluation_type == scu.EVALUATION_RATTRAPAGE
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
]
if eval_list:
return eval_list[0]
return None
def get_evaluation_session2(self, moduleimpl: ModuleImpl):
def get_evaluation_session2(self, moduleimpl: ModuleImpl) -> Evaluation | None:
"""L'évaluation de deuxième session de ce module, ou None s'il n'en a pas.
Session 2: remplace la note de moyenne des autres évals.
"""
eval_list = [
e
for e in moduleimpl.evaluations
if e.evaluation_type == scu.EVALUATION_SESSION2
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
]
if eval_list:
return eval_list[0]
return None
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations bonus de ce module, ou liste vide s'il n'en a pas."""
return [
e
for e in modimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_BONUS
]
def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]:
"""Les indices des évaluations bonus"""
return [
i
for (i, e) in enumerate(modimpl.evaluations)
if e.evaluation_type == Evaluation.EVALUATION_BONUS
]
class ModuleImplResultsAPC(ModuleImplResults):
"Calcul des moyennes de modules à la mode BUT"
@ -346,7 +383,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
# et dans dans evals_poids_etuds
# (rappel: la comparaison est toujours false face à un NaN)
# shape: (nb_etuds, nb_evals, nb_ues)
poids_stacked = np.stack([evals_poids] * nb_etuds)
poids_stacked = np.stack([evals_poids] * nb_etuds) # nb_etuds, nb_evals, nb_ues
evals_poids_etuds = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked,
@ -354,10 +391,20 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
# Calcule la moyenne pondérée sur les notes disponibles:
evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2)
# evals_notes_stacked shape: nb_etuds, nb_evals, nb_ues
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum(
evals_poids_etuds * evals_notes_stacked, axis=1
) / np.sum(evals_poids_etuds, axis=1)
# etuds_moy_module shape: nb_etuds x nb_ues
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_poids_df,
evals_notes_stacked,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
@ -406,6 +453,30 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
return self.etuds_moy_module
def apply_bonus(
self,
etuds_moy_module: pd.DataFrame,
modimpl: ModuleImpl,
evals_poids_df: pd.DataFrame,
evals_notes_stacked: np.ndarray,
):
"""Ajoute les points des évaluations bonus.
Il peut y avoir un nb quelconque d'évaluations bonus.
Les points sont directement ajoutés (ils peuvent être négatifs).
"""
evals_bonus = self.get_evaluations_bonus(modimpl)
if not evals_bonus:
return etuds_moy_module
poids_stacked = np.stack([evals_poids_df.values] * len(etuds_moy_module))
for evaluation in evals_bonus:
eval_idx = evals_poids_df.index.get_loc(evaluation.id)
etuds_moy_module += (
evals_notes_stacked[:, eval_idx, :] * poids_stacked[:, eval_idx, :]
)
# Clip dans [0,20]
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
return etuds_moy_module
def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
"""Charge poids des évaluations d'un module et retourne un dataframe
@ -522,6 +593,13 @@ class ModuleImplResultsClassic(ModuleImplResults):
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_notes_20,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
@ -561,3 +639,22 @@ class ModuleImplResultsClassic(ModuleImplResults):
)
return self.etuds_moy_module
def apply_bonus(
self,
etuds_moy_module: np.ndarray,
modimpl: ModuleImpl,
evals_notes_20: np.ndarray,
):
"""Ajoute les points des évaluations bonus.
Il peut y avoir un nb quelconque d'évaluations bonus.
Les points sont directement ajoutés (ils peuvent être négatifs).
"""
evals_bonus_idx = self.get_evaluations_bonus_idx(modimpl)
if not evals_bonus_idx:
return etuds_moy_module
for eval_idx in evals_bonus_idx:
etuds_moy_module += evals_notes_20[:, eval_idx]
# Clip dans [0,20]
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
return etuds_moy_module

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

@ -99,9 +99,11 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
# sur toutes les UE)
default_poids = {
mod.id: 1.0
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
else 0.0
mod.id: (
1.0
if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT)
else 0.0
)
for mod in modules
}
@ -148,10 +150,12 @@ def df_load_modimpl_coefs(
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
# sur toutes les UE)
default_poids = {
modimpl.id: 1.0
if (modimpl.module.module_type == ModuleType.STANDARD)
and (modimpl.module.ue.type == UE_SPORT)
else 0.0
modimpl.id: (
1.0
if (modimpl.module.module_type == ModuleType.STANDARD)
and (modimpl.module.ue.type == UE_SPORT)
else 0.0
)
for modimpl in formsemestre.modimpls_sorted
}
@ -200,8 +204,9 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
modimpls_results = {}
modimpls_evals_poids = {}
modimpls_notes = []
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsAPC(modimpl)
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
etuds_moy_module = mod_results.compute_module_moy(evals_poids)
modimpls_results[modimpl.id] = mod_results

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,
@ -256,8 +256,9 @@ def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]
"""
modimpls_results = {}
modimpls_notes = []
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsClassic(modimpl)
mod_results = moy_mod.ModuleImplResultsClassic(modimpl, etudids, etudids_actifs)
etuds_moy_module = mod_results.compute_module_moy()
modimpls_results[modimpl.id] = mod_results
modimpls_notes.append(etuds_moy_module)

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,86 @@ class ResultatsSemestre(ResultatsCache):
*[mr.etudids_attente for mr in self.modimpls_results.values()]
)
# # Etat des évaluations
# # (se substitue à do_evaluation_etat, sans les moyennes par groupes)
# def get_evaluations_etats(evaluation_id: int) -> dict:
# """Renvoie dict avec les clés:
# last_modif
# nb_evals_completes
# nb_evals_en_cours
# nb_evals_vides
# attente
# """
# Etat des évaluations
def get_evaluation_etat(self, evaluation: Evaluation) -> dict:
"""État d'une évaluation
{
"coefficient" : float, # 0 si None
"description" : str, # de l'évaluation, "" si None
"etat" {
"blocked" : bool, # vrai si prise en compte bloquée
"evalcomplete" : bool,
"last_modif" : datetime.datetime | None, # saisie de note la plus récente
"nb_notes" : int, # nb notes d'étudiants inscrits
"nb_attente" : int, # nb de notes en ATTente (même si bloquée)
},
"evaluation_id" : int,
"jour" : datetime.datetime, # e.date_debut or datetime.datetime(1900, 1, 1)
"publish_incomplete" : bool,
}
"""
mod_results = self.modimpls_results.get(evaluation.moduleimpl_id)
if mod_results is None:
raise ScoTemporaryError() # argh !
etat = mod_results.evaluations_etat.get(evaluation.id)
if etat is None:
raise ScoTemporaryError() # argh !
# Date de dernière saisie de note
cursor = db.session.execute(
sa.text(
"SELECT MAX(date) FROM notes_notes WHERE evaluation_id = :evaluation_id"
),
{"evaluation_id": evaluation.id},
)
date_modif = cursor.one_or_none()
last_modif = date_modif[0] if date_modif else None
return {
"coefficient": evaluation.coefficient,
"description": evaluation.description,
"etat": {
"blocked": evaluation.is_blocked(),
"evalcomplete": etat.is_complete,
"nb_attente": etat.nb_attente,
"nb_notes": etat.nb_notes,
"last_modif": last_modif,
},
"evaluation_id": evaluation.id,
"jour": evaluation.date_debut or datetime.datetime(1900, 1, 1),
"publish_incomplete": evaluation.publish_incomplete,
}
def get_mod_evaluation_etat_list(self, modimpl: ModuleImpl) -> list[dict]:
"""Liste des états des évaluations de ce module
[ evaluation_etat, ... ] (voir get_evaluation_etat)
trié par (numero desc, date_debut desc)
"""
# nouvelle version 2024-02-02
return list(
reversed(
[
self.get_evaluation_etat(evaluation)
for evaluation in modimpl.evaluations
]
)
)
# modernisation de get_mod_evaluation_etat_list
# utilisé par:
# sco_evaluations.do_evaluation_etat_in_mod
# e["etat"]["evalcomplete"]
# e["etat"]["nb_notes"]
# e["etat"]["last_modif"]
#
# sco_formsemestre_status.formsemestre_description_table
# "jour" (qui est e.date_debut or datetime.date(1900, 1, 1))
# "description"
# "coefficient"
# e["etat"]["evalcomplete"]
# publish_incomplete
#
# sco_formsemestre_status.formsemestre_tableau_modules
# e["etat"]["nb_notes"]
#
# --- JURY...
def get_formsemestre_validations(self) -> ValidationsSemestre:
@ -360,11 +436,28 @@ class ResultatsSemestre(ResultatsCache):
ue_cap_dict["compense_formsemestre_id"] = None
return ue_cap_dict
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict:
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict | None:
"""L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre.
Result: dict, ou None si l'UE n'existe pas ou n'est pas dans ce semestre.
{
"is_capitalized": # vrai si la version capitalisée est celle prise en compte (meilleure)
"was_capitalized":# si elle a été capitalisée (meilleure ou pas)
"is_external": # si UE externe
"coef_ue": 0.0,
"cur_moy_ue": 0.0, # moyenne de l'UE courante
"moy": 0.0, # moyenne prise en compte
"event_date": # date de la capiltalisation éventuelle (ou None)
"ue": ue_dict, # l'UE, comme un dict
"formsemestre_id": None,
"capitalized_ue_id": None, # l'id de l'UE capitalisée, ou None
"ects_pot": 0.0, # deprecated (les ECTS liés à cette UE)
"ects": 0.0, # les ECTS acquis grace à cette UE
"ects_ue": # les ECTS liés à cette UE
}
"""
ue: UniteEns = db.session.get(UniteEns, ue_id)
if not ue:
return None
ue_dict = ue.to_dict()
if ue.type == UE_SPORT:
@ -383,7 +476,7 @@ class ResultatsSemestre(ResultatsCache):
"ects": 0.0,
"ects_ue": ue.ects,
}
if not ue_id in self.etud_moy_ue:
if not ue_id in self.etud_moy_ue or not etudid in self.etud_moy_ue[ue_id]:
return None
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
@ -440,11 +533,13 @@ class ResultatsSemestre(ResultatsCache):
"is_external": ue_cap["is_external"] if is_capitalized else ue.is_external,
"coef_ue": coef_ue,
"ects_pot": ue.ects or 0.0,
"ects": self.validations.decisions_jury_ues.get(etudid, {})
.get(ue.id, {})
.get("ects", 0.0)
if self.validations.decisions_jury_ues
else 0.0,
"ects": (
self.validations.decisions_jury_ues.get(etudid, {})
.get(ue.id, {})
.get("ects", 0.0)
if self.validations.decisions_jury_ues
else 0.0
),
"ects_ue": ue.ects,
"cur_moy_ue": cur_moy_ue,
"moy": moy_ue,

View File

@ -58,7 +58,6 @@ class NotesTableCompat(ResultatsSemestre):
self.moy_moy = "NA"
self.moy_gen_rangs_by_group = {} # { group_id : (Series, Series) }
self.ue_rangs_by_group = {} # { ue_id : {group_id : (Series, Series)}}
self.expr_diagnostics = ""
self.parcours = self.formsemestre.formation.get_cursus()
self._modimpls_dict_by_ue = {} # local cache
@ -217,9 +216,9 @@ class NotesTableCompat(ResultatsSemestre):
# Rangs / UEs:
for ue in ues:
group_moys_ue = self.etud_moy_ue[ue.id][group_members]
self.ue_rangs_by_group.setdefault(ue.id, {})[
group.id
] = moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
self.ue_rangs_by_group.setdefault(ue.id, {})[group.id] = (
moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
)
def get_etud_rang(self, etudid: int) -> str:
"""Le rang (classement) de l'étudiant dans le semestre.
@ -423,30 +422,37 @@ class NotesTableCompat(ResultatsSemestre):
)
return evaluations
def get_evaluations_etats(self) -> list[dict]:
"""Liste de toutes les évaluations du semestre
[ {...evaluation et son etat...} ]"""
# TODO: à moderniser (voir dans ResultatsSemestre)
# utilisé par
# do_evaluation_etat_in_sem
def get_evaluations_etats(self) -> dict[int, dict]:
""" "état" de chaque évaluation du semestre
{
evaluation_id : {
"evalcomplete" : bool,
"last_modif" : datetime | None
"nb_notes" : int,
}, ...
}
"""
# utilisé par do_evaluation_etat_in_sem
evaluations_etats = {}
for modimpl in self.formsemestre.modimpls_sorted:
for evaluation in modimpl.evaluations:
evaluation_etat = self.get_evaluation_etat(evaluation)
evaluations_etats[evaluation.id] = evaluation_etat["etat"]
return evaluations_etats
from app.scodoc import sco_evaluations
if not hasattr(self, "_evaluations_etats"):
self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem(
self.formsemestre.id
)
return self._evaluations_etats
def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
"""Liste des états des évaluations de ce module"""
# XXX TODO à moderniser: lent, recharge des données que l'on a déjà...
return [
e
for e in self.get_evaluations_etats()
if e["moduleimpl_id"] == moduleimpl_id
]
# ancienne version < 2024-02-02
# def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]:
# """Liste des états des évaluations de ce module
# ordonnée selon (numero desc, date_debut desc)
# """
# # à moderniser: lent, recharge des données que l'on a déjà...
# # remplacemé par ResultatsSemestre.get_mod_evaluation_etat_list
# #
# return [
# e
# for e in self.get_evaluations_etats()
# if e["moduleimpl_id"] == moduleimpl_id
# ]
def get_moduleimpls_attente(self):
"""Liste des modimpls du semestre ayant des notes en attente"""

View File

@ -5,6 +5,7 @@
# See LICENSE
##############################################################################
import datetime
from threading import Thread
from flask import current_app, g
@ -83,9 +84,12 @@ Adresses d'origine:
\n\n"""
+ msg.body
)
now = datetime.datetime.now()
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S") + ".{:03d}".format(
now.microsecond // 1000
)
current_app.logger.info(
f"""email sent to{
f"""[{formatted_time}] email sent to{
' (mode test)' if email_test_mode_address else ''
}: {msg.recipients}
from sender {msg.sender}

View File

@ -59,3 +59,4 @@ def check_taxe_now(taxes):
from app.entreprises import routes
from app.entreprises.activate import activate_module

View File

@ -0,0 +1,31 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Activation du module entreprises
L'affichage du module est contrôlé par la config ScoDocConfig.enable_entreprises
Au moment de l'activation, il est en général utile de proposer de configurer les
permissions de rôles standards: AdminEntreprise UtilisateurEntreprise ObservateurEntreprise
Voir associations dans sco_roles_default
"""
from app.auth.models import Role
from app.models import ScoDocSiteConfig
from app.scodoc.sco_roles_default import SCO_ROLES_ENTREPRISES_DEFAULT
def activate_module(
enable: bool = True, set_default_roles_permission: bool = False
) -> bool:
"""Active le module et en option donne les permissions aux rôles standards.
True si l'état d'activation a changé.
"""
change = ScoDocSiteConfig.enable_entreprises(enable)
if enable and set_default_roles_permission:
Role.reset_roles_permissions(SCO_ROLES_ENTREPRISES_DEFAULT)
return change

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,12 +110,23 @@ class AjoutAssiOrJustForm(FlaskForm):
"id": "entry_date",
},
)
entry_time = StringField(
"Heure dépôt",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_fin",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'une assiduité pour un étudiant"
description = TextAreaField(
"Description",
render_kw={
@ -136,10 +148,12 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"Module",
choices={}, # will be populated dynamically
)
est_just = BooleanField("Justifiée")
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'un justificatif pour un étudiant"
raison = TextAreaField(
"Raison",
render_kw={
@ -161,3 +175,36 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
validators=[DataRequired(message="This field is required.")],
)
fichiers = MultipleFileField(label="Ajouter des fichiers")
class ChoixDateForm(FlaskForm):
"""
Formulaire de choix de date
(utilisé par la page de choix de date
si la date courante n'est pas dans le semestre)
"""
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)
self.ok = True
self.error_messages: list[str] = [] # used to report our errors
def set_error(self, err_msg, field=None):
"Set error message both in form and field"
self.ok = False
self.error_messages.append(err_msg)
if field:
field.errors.append(err_msg)
date = StringField(
"Date",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "date",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

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

@ -0,0 +1,17 @@
"""
Formulaire activation module entreprises
"""
from flask_wtf import FlaskForm
from wtforms.fields.simple import BooleanField, SubmitField
from app.models import ScoDocSiteConfig
class ActivateEntreprisesForm(FlaskForm):
"Formulaire activation module entreprises"
set_default_roles_permission = BooleanField(
"(re)mettre les rôles 'Entreprise' à leurs valeurs par défaut"
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

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

@ -48,13 +48,15 @@ class BonusConfigurationForm(FlaskForm):
for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list()
],
)
submit_bonus = SubmitField("Valider")
submit_bonus = SubmitField("Enregistrer ce bonus")
cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True})
class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée"
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
disable_passerelle = BooleanField( # disable car par défaut activée
"""cacher les fonctions liées à une passerelle de publication des résultats vers les étudiants ("œil"). N'affecte pas l'API, juste la présentation."""
)
month_debut_annee_scolaire = SelectField(
label="Mois de début des années scolaires",
description="""Date pivot. En France métropolitaine, août.
@ -77,10 +79,13 @@ class ScoDocConfigurationForm(FlaskForm):
Attention: si ce champ peut aussi être défini dans chaque département.""",
validators=[Optional(), Email()],
)
user_require_email_institutionnel = BooleanField(
"imposer la saisie du mail institutionnel dans le formulaire de création utilisateur"
)
disable_bul_pdf = BooleanField(
"interdire les exports des bulletins en PDF (déconseillé)"
)
submit_scodoc = SubmitField("Valider")
submit_scodoc = SubmitField("Enregistrer ces paramètres")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -95,10 +100,12 @@ def configuration():
form_scodoc = ScoDocConfigurationForm(
data={
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
"disable_passerelle": ScoDocSiteConfig.is_passerelle_disabled(),
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
"disable_bul_pdf": ScoDocSiteConfig.is_bul_pdf_disabled(),
"user_require_email_institutionnel": ScoDocSiteConfig.is_user_require_email_institutionnel_enabled(),
}
)
if request.method == "POST" and (
@ -119,12 +126,12 @@ def configuration():
flash("Fonction bonus inchangée.")
return redirect(url_for("scodoc.index"))
elif form_scodoc.submit_scodoc.data and form_scodoc.validate():
if ScoDocSiteConfig.enable_entreprises(
enabled=form_scodoc.data["enable_entreprises"]
if ScoDocSiteConfig.disable_passerelle(
disabled=form_scodoc.data["disable_passerelle"]
):
flash(
"Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
"Fonction passerelle "
+ ("cachées" if form_scodoc.data["disable_passerelle"] else "montrées")
)
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
int(form_scodoc.data["month_debut_annee_scolaire"])
@ -151,10 +158,23 @@ def configuration():
"Exports PDF "
+ ("désactivés" if form_scodoc.data["disable_bul_pdf"] else "réactivés")
)
if ScoDocSiteConfig.set(
"user_require_email_institutionnel",
"on" if form_scodoc.data["user_require_email_institutionnel"] else "",
):
flash(
(
"impose"
if form_scodoc.data["user_require_email_institutionnel"]
else "n'impose pas"
)
+ " la saisie du mail institutionnel des utilisateurs"
)
return redirect(url_for("scodoc.index"))
return render_template(
"configuration.j2",
is_entreprises_enabled=ScoDocSiteConfig.is_entreprises_enabled(),
form_bonus=form_bonus,
form_scodoc=form_scodoc,
scu=scu,

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

@ -0,0 +1,65 @@
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire options génération table poursuite études (PE)
"""
from flask_wtf import FlaskForm
from wtforms import BooleanField, HiddenField, SubmitField
class ParametrageClasseurPE(FlaskForm):
"Formulaire paramétrage génération classeur PE"
# cohorte_restreinte = BooleanField(
# "Restreindre aux étudiants inscrits dans le semestre (sans interclassement de promotion) (à venir)"
# )
moyennes_tags = BooleanField(
"Générer les moyennes sur les tags de modules personnalisés (cf. programme de formation)",
default=True,
render_kw={"checked": ""},
)
moyennes_ue_res_sae = BooleanField(
"Générer les moyennes des ressources et des SAEs",
default=True,
render_kw={"checked": ""},
)
moyennes_ues_rcues = BooleanField(
"Générer les moyennes par RCUEs (compétences) et leurs synthèses HTML étudiant par étudiant",
default=True,
render_kw={"checked": ""},
)
min_max_moy = BooleanField("Afficher les colonnes min/max/moy")
# synthese_individuelle_etud = BooleanField(
# "Générer (suppose les RCUES)"
# )
publipostage = BooleanField(
"Nomme les moyennes pour publipostage",
# default=False,
# render_kw={"checked": ""},
)
submit = SubmitField("Générer les classeurs poursuites d'études")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

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

@ -1,7 +1,8 @@
# -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs)
"""
"""Gestion de l'assiduité (assiduités + justificatifs)"""
from datetime import datetime
from flask_login import current_user
from flask_sqlalchemy.query import Query
@ -20,6 +21,7 @@ from app.scodoc import sco_abs_notification
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
@ -87,8 +89,12 @@ class Assiduite(ScoDocModel):
lazy="select",
)
def to_dict(self, format_api=True) -> dict:
"""Retourne la représentation json de l'assiduité"""
# Argument "restrict" obligatoire car on override la fonction "to_dict" de ScoDocModel
# pylint: disable-next=unused-argument
def to_dict(self, format_api=True, restrict: bool | None = None) -> dict:
"""Retourne la représentation json de l'assiduité
restrict n'est pas utilisé ici.
"""
etat = self.etat
user: User | None = None
if format_api:
@ -108,9 +114,9 @@ class Assiduite(ScoDocModel):
"entry_date": self.entry_date,
"user_id": None if user is None else user.id, # l'uid
"user_name": None if user is None else user.user_name, # le login
"user_nom_complet": None
if user is None
else user.get_nomcomplet(), # "Marie Dupont"
"user_nom_complet": (
None if user is None else user.get_nomcomplet()
), # "Marie Dupont"
"est_just": self.est_just,
"external_data": self.external_data,
}
@ -249,53 +255,63 @@ class Assiduite(ScoDocModel):
sco_abs_notification.abs_notify(etud.id, nouv_assiduite.date_debut)
return nouv_assiduite
def set_moduleimpl(self, moduleimpl_id: int | str) -> bool:
"""TODO"""
# je ne comprend pas cette fonction WIP
# moduleimpl_id peut être == "autre", ce qui plante
# ci-dessous un fix temporaire en attendant explication de @iziram
if moduleimpl_id is None:
raise ScoValueError("invalid moduleimpl_id")
try:
moduleimpl_id_int = int(moduleimpl_id)
except ValueError as exc:
raise ScoValueError("invalid moduleimpl_id") from exc
# /fix
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id_int)
if moduleimpl is not None:
# Vérification de l'inscription de l'étudiant
if moduleimpl.est_inscrit(self.etudiant):
self.moduleimpl_id = moduleimpl.id
else:
raise ScoValueError("L'étudiant n'est pas inscrit au module")
elif isinstance(moduleimpl_id, str):
def set_moduleimpl(self, moduleimpl_id: int | str):
"""Mise à jour du moduleimpl_id
Les valeurs du champ "moduleimpl_id" possibles sont :
- <int> (un id classique)
- <str> ("autre" ou "<id>")
- "" (pas de moduleimpl_id)
Si la valeur est "autre" il faut:
- mettre à None assiduité.moduleimpl_id
- mettre à jour assiduite.external_data["module"] = "autre"
En fonction de la configuration du semestre (option force_module) la valeur "" peut-être
considérée comme invalide.
- Il faudra donc vérifier que ce n'est pas le cas avant de mettre à jour l'assiduité
"""
moduleimpl: ModuleImpl = None
if moduleimpl_id == "autre":
# Configuration de external_data pour Module Autre
# Si self.external_data None alors on créé un dictionnaire {"module": "autre"}
# Sinon on met à jour external_data["module"] à "autre"
if self.external_data is None:
self.external_data = {"module": moduleimpl_id}
self.external_data = {"module": "autre"}
else:
self.external_data["module"] = moduleimpl_id
self.external_data["module"] = "autre"
# Dans tous les cas une fois fait, assiduite.moduleimpl_id doit être None
self.moduleimpl_id = None
# Ici pas de vérification du force module car on l'a mis dans "external_data"
return
if moduleimpl_id != "":
try:
moduleimpl_id = int(moduleimpl_id)
except ValueError as exc:
raise ScoValueError("Module non reconnu") from exc
moduleimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id)
# ici moduleimpl est None si non spécifié
# Vérification ModuleImpl not None (raise ScoValueError)
if moduleimpl is None:
self._check_force_module()
# Ici uniquement si on est autorisé à ne pas avoir de module
self.moduleimpl_id = None
return
# Vérification Inscription ModuleImpl (raise ScoValueError)
if moduleimpl.est_inscrit(self.etudiant):
self.moduleimpl_id = moduleimpl.id
else:
# Vérification si module forcé
formsemestre: FormSemestre = get_formsemestre_from_data(
{
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
}
)
force: bool
if formsemestre:
force = is_assiduites_module_forced(formsemestre_id=formsemestre.id)
else:
force = is_assiduites_module_forced(dept_id=self.etudiant.dept_id)
if force:
raise ScoValueError("Module non renseigné")
return True
raise ScoValueError("L'étudiant n'est pas inscrit au module")
def supprime(self):
"Supprime l'assiduité. Log et commit."
# Obligatoire car import circulaire sinon
# pylint: disable-next=import-outside-toplevel
from app.scodoc import sco_assiduites as scass
if g.scodoc_dept is None and self.etudiant.dept_id is not None:
@ -321,13 +337,19 @@ class Assiduite(ScoDocModel):
"""
return get_formsemestre_from_data(self.to_dict())
def get_module(self, traduire: bool = False) -> int | str:
"TODO"
def get_module(self, traduire: bool = False) -> Module | str:
"""
Retourne le module associé à l'assiduité
Si traduire est vrai, retourne le titre du module précédé du code
Sinon rentourne l'objet Module ou None
"""
if self.moduleimpl_id is not None:
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
mod: Module = Module.query.get(modimpl.module_id)
if traduire:
modimpl: ModuleImpl = ModuleImpl.query.get(self.moduleimpl_id)
mod: Module = Module.query.get(modimpl.module_id)
return f"{mod.code} {mod.titre}"
return mod
elif self.external_data is not None and "module" in self.external_data:
return (
@ -338,6 +360,41 @@ class Assiduite(ScoDocModel):
return "Non spécifié" if traduire else None
def get_saisie(self) -> str:
"""
retourne le texte "saisie le <date> par <User>"
"""
date: str = self.entry_date.strftime(scu.DATEATIME_FMT)
utilisateur: str = ""
if self.user is not None:
self.user: User
utilisateur = f"par {self.user.get_prenomnom()}"
return f"saisie le {date} {utilisateur}"
def _check_force_module(self):
"""Vérification si module forcé:
Si le module est requis, raise ScoValueError
sinon ne fait rien.
"""
# cherche le formsemestre affecté pour utiliser ses préférences
formsemestre: FormSemestre = get_formsemestre_from_data(
{
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
}
)
formsemestre_id = formsemestre.id if formsemestre else None
# si pas de formsemestre, utilisera les prefs globales du département
dept_id = self.etudiant.dept_id
force = is_assiduites_module_forced(
formsemestre_id=formsemestre_id, dept_id=dept_id
)
if force:
raise ScoValueError("Module non renseigné")
class Justificatif(ScoDocModel):
"""
@ -372,7 +429,7 @@ class Justificatif(ScoDocModel):
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
"date de création de l'élément: date de saisie"
# pourrait devenir date de dépot au secrétariat, si différente
# pourrait devenir date de dépôt au secrétariat, si différente
user_id = db.Column(
db.Integer,
@ -391,6 +448,14 @@ class Justificatif(ScoDocModel):
etudiant = db.relationship(
"Identite", back_populates="justificatifs", lazy="joined"
)
# En revanche, user est rarement accédé:
user = db.relationship(
"User",
backref=db.backref(
"justificatifs", lazy="select", order_by="Justificatif.entry_date"
),
lazy="select",
)
external_data = db.Column(db.JSON, nullable=True)
@ -402,20 +467,16 @@ class Justificatif(ScoDocModel):
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
return query.first_or_404()
def to_dict(self, format_api: bool = False) -> dict:
"""transformation de l'objet en dictionnaire sérialisable"""
def to_dict(self, format_api: bool = False, restrict: bool = False) -> dict:
"""L'objet en dictionnaire sérialisable.
Si restrict, ne donne par la raison et les fichiers et external_data
"""
etat = self.etat
username = self.user_id
user: User = self.user if self.user_id is not None else None
if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name
if self.user_id is not None:
user: User = db.session.get(User, self.user_id)
if user is None:
username = "Non renseigné"
else:
username = user.get_prenomnom()
data = {
"justif_id": self.justif_id,
@ -424,11 +485,13 @@ class Justificatif(ScoDocModel):
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"raison": self.raison,
"fichier": self.fichier,
"raison": None if restrict else self.raison,
"fichier": None if restrict else self.fichier,
"entry_date": self.entry_date,
"user_id": username,
"external_data": self.external_data,
"user_id": None if user is None else user.id, # l'uid
"user_name": None if user is None else user.user_name, # le login
"user_nom_complet": None if user is None else user.get_nomcomplet(),
"external_data": None if restrict else self.external_data,
}
return data
@ -463,6 +526,8 @@ class Justificatif(ScoDocModel):
def create_justificatif(
cls,
etudiant: Identite,
# On a besoin des arguments mais on utilise "locals" pour les récupérer
# pylint: disable=unused-argument
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
@ -486,8 +551,10 @@ class Justificatif(ScoDocModel):
def supprime(self):
"Supprime le justificatif. Log et commit."
# Obligatoire car import circulaire sinon
# pylint: disable-next=import-outside-toplevel
from app.scodoc import sco_assiduites as scass
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
# Récupération de l'archive du justificatif
archive_name: str = self.fichier
@ -514,11 +581,7 @@ class Justificatif(ScoDocModel):
db.session.delete(self)
db.session.commit()
# On actualise les assiduités justifiées de l'étudiant concerné
compute_assiduites_justified(
self.etudid,
Justificatif.query.filter_by(etudid=self.etudid).all(),
True,
)
self.dejustifier_assiduites()
def get_fichiers(self) -> tuple[list[str], int]:
"""Renvoie la liste des noms de fichiers justicatifs
@ -540,6 +603,82 @@ class Justificatif(ScoDocModel):
accessible_filenames.append(filename[0])
return accessible_filenames, len(filenames)
def justifier_assiduites(
self,
) -> list[int]:
"""Justifie les assiduités sur la période de validité du justificatif"""
log(f"justifier_assiduites: {self}")
assiduites_justifiees: list[int] = []
if self.etat != EtatJustificatif.VALIDE:
return []
# On récupère les assiduités de l'étudiant sur la période donnée
assiduites: Query = self.etudiant.assiduites.filter(
Assiduite.date_debut >= self.date_debut,
Assiduite.date_fin <= self.date_fin,
Assiduite.etat != EtatAssiduite.PRESENT,
)
# Pour chaque assiduité, on la justifie
for assi in assiduites:
assi.est_just = True
assiduites_justifiees.append(assi.assiduite_id)
db.session.add(assi)
db.session.commit()
return assiduites_justifiees
def dejustifier_assiduites(self) -> list[int]:
"""
Déjustifie les assiduités sur la période du justificatif
"""
assiduites_dejustifiees: list[int] = []
# On récupère les assiduités de l'étudiant sur la période donnée
assiduites: Query = self.etudiant.assiduites.filter(
Assiduite.date_debut >= self.date_debut,
Assiduite.date_fin <= self.date_fin,
Assiduite.etat != EtatAssiduite.PRESENT,
)
assi: Assiduite
for assi in assiduites:
# On récupère les justificatifs qui justifient l'assiduité `assi`
assi_justifs: list[int] = get_justifs_from_date(
self.etudiant.etudid,
assi.date_debut,
assi.date_fin,
long=False,
valid=True,
)
# Si il n'y a pas d'autre justificatif valide, on déjustifie l'assiduité
if len(assi_justifs) == 0 or (
len(assi_justifs) == 1 and assi_justifs[0] == self.justif_id
):
assi.est_just = False
assiduites_dejustifiees.append(assi.assiduite_id)
db.session.add(assi)
db.session.commit()
return assiduites_dejustifiees
def get_assiduites(self) -> Query:
"""
get_assiduites Récupère les assiduités qui sont concernées par le justificatif
(Concernée Justifiée, mais qui sont sur la même période)
Ne prends pas en compte les Présences
Returns:
Query: Les assiduités concernées
"""
assiduites_query = Assiduite.query.filter(
Assiduite.etudid == self.etudid,
Assiduite.date_debut >= self.date_debut,
Assiduite.date_fin <= self.date_fin,
Assiduite.etat != EtatAssiduite.PRESENT,
)
return assiduites_query
def is_period_conflicting(
date_debut: datetime,
@ -563,66 +702,6 @@ def is_period_conflicting(
return count > 0
def compute_assiduites_justified(
etudid: int, justificatifs: list[Justificatif] = None, reset: bool = False
) -> list[int]:
"""
Args:
etudid (int): l'identifiant de l'étudiant
justificatifs (list[Justificatif]): La liste des justificatifs qui seront utilisés
reset (bool, optional): remet à false les assiduites non justifiés. Defaults to False.
Returns:
list[int]: la liste des assiduités qui ont été justifiées.
"""
# Si on ne donne pas de justificatifs on prendra par défaut tous les justificatifs de l'étudiant
if justificatifs is None:
justificatifs: list[Justificatif] = Justificatif.query.filter_by(
etudid=etudid
).all()
# On ne prend que les justificatifs valides
justificatifs = [j for j in justificatifs if j.etat == EtatJustificatif.VALIDE]
# On récupère les assiduités de l'étudiant
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
assiduites_justifiees: list[int] = []
for assi in assiduites:
# On ne justifie pas les Présences
if assi.etat == EtatAssiduite.PRESENT:
continue
# On récupère les justificatifs qui justifient l'assiduité `assi`
assi_justificatifs = Justificatif.query.filter(
Justificatif.etudid == assi.etudid,
Justificatif.date_debut <= assi.date_debut,
Justificatif.date_fin >= assi.date_fin,
Justificatif.etat == EtatJustificatif.VALIDE,
).all()
# Si au moins un justificatif possède une période qui couvre l'assiduité
if any(
assi.date_debut >= j.date_debut and assi.date_fin <= j.date_fin
for j in justificatifs + assi_justificatifs
):
# On justifie l'assiduité
# On ajoute l'id de l'assiduité à la liste des assiduités justifiées
assi.est_just = True
assiduites_justifiees.append(assi.assiduite_id)
db.session.add(assi)
elif reset:
# Si le paramètre reset est Vrai alors les assiduités non justifiées
# sont remise en "non justifiée"
assi.est_just = False
db.session.add(assi)
# On valide la session
db.session.commit()
# On renvoie la liste des assiduite_id des assiduités justifiées
return assiduites_justifiees
def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]:
"""
get_assiduites_justif Récupération des justificatifs d'une assiduité

View File

@ -8,16 +8,19 @@
from datetime import datetime
import functools
from operator import attrgetter
import yaml
from flask import g
from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper
import sqlalchemy
from app import db
from app import db, log
from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
REFCOMP_EQUIVALENCE_FILENAME = "ressources/referentiels/equivalences.yaml"
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
@ -104,6 +107,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
def get_title(self) -> str:
"Titre affichable"
# utilise type_titre (B.U.T.), spécialité, version
return f"{self.type_titre} {self.specialite} {self.get_version()}"
def get_version(self) -> str:
"La version, normalement sous forme de date iso yyy-mm-dd"
if not self.version_orebut:
@ -124,9 +132,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"type_departement": self.type_departement,
"type_titre": self.type_titre,
"version_orebut": self.version_orebut,
"scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z"
if self.scodoc_date_loaded
else "",
"scodoc_date_loaded": (
self.scodoc_date_loaded.isoformat() + "Z"
if self.scodoc_date_loaded
else ""
),
"scodoc_orig_filename": self.scodoc_orig_filename,
"competences": {
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
@ -234,6 +244,92 @@ class ApcReferentielCompetences(db.Model, XMLModel):
return parcours_info
def equivalents(self) -> set["ApcReferentielCompetences"]:
"""Ensemble des référentiels du même département
qui peuvent être considérés comme "équivalents", au sens
une formation de ce référentiel pourrait changer vers un équivalent,
en ignorant les apprentissages critiques.
Pour cela, il faut avoir le même type, etc et les mêmes compétences,
niveaux et parcours (voir map_to_other_referentiel).
"""
candidats = ApcReferentielCompetences.query.filter_by(
dept_id=self.dept_id
).filter(ApcReferentielCompetences.id != self.id)
return {
referentiel
for referentiel in candidats
if not isinstance(self.map_to_other_referentiel(referentiel), str)
}
def map_to_other_referentiel(
self, other: "ApcReferentielCompetences"
) -> str | tuple[dict[int, int], dict[int, int], dict[int, int]]:
"""Build mapping between this referentiel and ref2.
If successful, returns 3 dicts mapping self ids to other ids.
Else return a string, error message.
"""
if self.type_structure != other.type_structure:
return "type_structure mismatch"
if self.type_departement != other.type_departement:
return "type_departement mismatch"
# Table d'équivalences entre refs:
equiv = self._load_config_equivalences()
# mêmes parcours ?
eq_parcours = equiv.get("parcours", {})
parcours_by_code_1 = {eq_parcours.get(p.code, p.code): p for p in self.parcours}
parcours_by_code_2 = {
eq_parcours.get(p.code, p.code): p for p in other.parcours
}
if parcours_by_code_1.keys() != parcours_by_code_2.keys():
return "parcours mismatch"
parcours_map = {
parcours_by_code_1[eq_parcours.get(code, code)]
.id: parcours_by_code_2[eq_parcours.get(code, code)]
.id
for code in parcours_by_code_1
}
# mêmes compétences ?
competence_by_code_1 = {c.titre: c for c in self.competences}
competence_by_code_2 = {c.titre: c for c in other.competences}
if competence_by_code_1.keys() != competence_by_code_2.keys():
return "competences mismatch"
competences_map = {
competence_by_code_1[titre].id: competence_by_code_2[titre].id
for titre in competence_by_code_1
}
# mêmes niveaux (dans chaque compétence) ?
niveaux_map = {}
for titre in competence_by_code_1:
c1 = competence_by_code_1[titre]
c2 = competence_by_code_2[titre]
niveau_by_attr_1 = {(n.annee, n.ordre, n.libelle): n for n in c1.niveaux}
niveau_by_attr_2 = {(n.annee, n.ordre, n.libelle): n for n in c2.niveaux}
if niveau_by_attr_1.keys() != niveau_by_attr_2.keys():
return f"niveaux mismatch in comp. '{titre}'"
niveaux_map.update(
{
niveau_by_attr_1[a].id: niveau_by_attr_2[a].id
for a in niveau_by_attr_1
}
)
return parcours_map, competences_map, niveaux_map
def _load_config_equivalences(self) -> dict:
"""Load config file ressources/referentiels/equivalences.yaml
used to define equivalences between distinct referentiels
"""
try:
with open(REFCOMP_EQUIVALENCE_FILENAME, encoding="utf-8") as f:
doc = yaml.safe_load(f.read())
except FileNotFoundError:
log(f"_load_config_equivalences: {REFCOMP_EQUIVALENCE_FILENAME} not found")
return {}
except yaml.parser.ParserError as exc:
raise ScoValueError(
f"erreur dans le fichier {REFCOMP_EQUIVALENCE_FILENAME}"
) from exc
return doc.get(self.specialite, {})
class ApcCompetence(db.Model, XMLModel):
"Compétence"
@ -374,9 +470,11 @@ class ApcNiveau(db.Model, XMLModel):
"libelle": self.libelle,
"annee": self.annee,
"ordre": self.ordre,
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {},
"app_critiques": (
{x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {}
),
}
def to_dict_bul(self):
@ -464,9 +562,9 @@ class ApcNiveau(db.Model, XMLModel):
return []
if competence is None:
parcour_niveaux: list[
ApcParcoursNiveauCompetence
] = annee_parcour.niveaux_competences
parcour_niveaux: list[ApcParcoursNiveauCompetence] = (
annee_parcour.niveaux_competences
)
niveaux: list[ApcNiveau] = [
pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
for pn in parcour_niveaux

View File

@ -9,6 +9,8 @@ from app.models.but_refcomp import ApcNiveau
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
class ApcValidationRCUE(db.Model):
@ -62,24 +64,25 @@ class ApcValidationRCUE(db.Model):
def __str__(self):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
self.code} enregistrée le {self.date.strftime(scu.DATE_FMT)}"""
def html(self) -> str:
"description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b>
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}</em>"""
<em>enregistrée le {self.date.strftime(scu.DATEATIME_FMT)}</em>"""
def annee(self) -> str:
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
niveau = self.niveau()
return niveau.annee if niveau else None
def niveau(self) -> ApcNiveau:
def niveau(self) -> ApcNiveau | None:
"""Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE
return self.ue2.niveau_competence
# à défaut (si l'UE a été désacciée entre temps), la première
# et à défaut, renvoie None
return self.ue2.niveau_competence or self.ue1.niveau_competence
def to_dict(self):
"as a dict"
@ -161,7 +164,7 @@ class ApcValidationAnnee(db.Model):
def html(self) -> str:
"Affichage html"
date_str = (
f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
f"""le {self.date.strftime(scu.DATEATIME_FMT)}"""
if self.date
else "(sans date)"
)
@ -218,15 +221,18 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
decisions["descr_decisions_rcue"] = ""
decisions["descr_decisions_niveaux"] = ""
# --- Année: prend la validation pour l'année scolaire et l'ordre de ce semestre
annee_but = (formsemestre.semestre_id + 1) // 2
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
ordre=annee_but,
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
if sco_preferences.get_preference("bul_but_code_annuel", formsemestre.id):
annee_but = (formsemestre.semestre_id + 1) // 2
validation = ApcValidationAnnee.query.filter_by(
etudid=etud.id,
annee_scolaire=formsemestre.annee_scolaire(),
ordre=annee_but,
referentiel_competence_id=formsemestre.formation.referentiel_competence_id,
).first()
if validation:
decisions["decision_annee"] = validation.to_dict_bul()
else:
decisions["decision_annee"] = None
else:
decisions["decision_annee"] = None
return decisions

View File

@ -92,9 +92,11 @@ class ScoDocSiteConfig(db.Model):
"INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
"enable_entreprises": bool,
"disable_passerelle": bool, # remplace pref. bul_display_publication
"month_debut_annee_scolaire": int,
"month_debut_periode2": int,
"disable_bul_pdf": bool,
"user_require_email_institutionnel": bool,
# CAS
"cas_enable": bool,
"cas_server": str,
@ -243,6 +245,20 @@ class ScoDocSiteConfig(db.Model):
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
return cfg is not None and cfg.value
@classmethod
def is_passerelle_disabled(cls):
"""True si on doit cacher les fonctions passerelle ("oeil")."""
cfg = ScoDocSiteConfig.query.filter_by(name="disable_passerelle").first()
return cfg is not None and cfg.value
@classmethod
def is_user_require_email_institutionnel_enabled(cls) -> bool:
"""True si impose saisie email_institutionnel"""
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 +266,19 @@ class ScoDocSiteConfig(db.Model):
return cfg is not None and cfg.value
@classmethod
def enable_entreprises(cls, enabled=True) -> bool:
def enable_entreprises(cls, enabled: bool = True) -> bool:
"""Active (ou déactive) le module entreprises. True si changement."""
if enabled != ScoDocSiteConfig.is_entreprises_enabled():
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
if cfg is None:
cfg = ScoDocSiteConfig(
name="enable_entreprises", value="on" if enabled else ""
)
else:
cfg.value = "on" if enabled else ""
db.session.add(cfg)
db.session.commit()
return True
return False
return cls.set("enable_entreprises", "on" if enabled else "")
@classmethod
def disable_passerelle(cls, disabled: bool = True) -> bool:
"""Désactive (ou active) les fonctions liées à la présence d'une passerelle. True si changement."""
return cls.set("disable_passerelle", "on" if disabled else "")
@classmethod
def disable_bul_pdf(cls, enabled=True) -> bool:
"""Interedit (ou autorise) les exports PDF. True si changement."""
if enabled != ScoDocSiteConfig.is_bul_pdf_disabled():
cfg = ScoDocSiteConfig.query.filter_by(name="disable_bul_pdf").first()
if cfg is None:
cfg = ScoDocSiteConfig(
name="disable_bul_pdf", value="on" if enabled else ""
)
else:
cfg.value = "on" if enabled else ""
db.session.add(cfg)
db.session.commit()
return True
return False
"""Interdit (ou autorise) les exports PDF. True si changement."""
return cls.set("disable_bul_pdf", "on" if enabled else "")
@classmethod
def get(cls, name: str, default: str = "") -> str:
@ -298,9 +297,10 @@ class ScoDocSiteConfig(db.Model):
if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=value_str)
else:
cfg.value = str(value or "")
cfg.value = value_str
current_app.logger.info(
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}{
'...' if len(cfg.value)>32 else ''}'"""
)
db.session.add(cfg)
db.session.commit()
@ -309,7 +309,7 @@ class ScoDocSiteConfig(db.Model):
@classmethod
def _get_int_field(cls, name: str, default=None) -> int:
"""Valeur d'un champs integer"""
"""Valeur d'un champ integer"""
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if (cfg is None) or cfg.value is None:
return default
@ -323,7 +323,7 @@ class ScoDocSiteConfig(db.Model):
default=None,
range_values: tuple = (),
) -> bool:
"""Set champs integer. True si changement."""
"""Set champ integer. True si changement."""
if value != cls._get_int_field(name, default=default):
if not isinstance(value, int) or (
range_values and (value < range_values[0]) or (value > range_values[1])

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", "nationalite"}
def __repr__(self):
return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
@ -176,7 +184,7 @@ class Identite(models.ScoDocModel):
def url_fiche(self) -> str:
"url de la fiche étudiant"
return url_for(
"scolar.ficheEtud", scodoc_dept=self.departement.acronym, etudid=self.id
"scolar.fiche_etud", scodoc_dept=self.departement.acronym, etudid=self.id
)
@classmethod
@ -230,6 +238,15 @@ class Identite(models.ScoDocModel):
log(f"Identite.create {etud}")
return etud
def from_dict(self, args, **kwargs) -> bool:
"""Check arguments, then modify.
Add to session but don't commit.
True if modification.
"""
check_etud_duplicate_code(args, "code_nip")
check_etud_duplicate_code(args, "code_ine")
return super().from_dict(args, **kwargs)
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded."""
@ -280,7 +297,7 @@ class Identite(models.ScoDocModel):
else:
return self.nom
@cached_property
@property
def nomprenom(self, reverse=False) -> str:
"""Civilité/nom/prenom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité.
@ -317,16 +334,14 @@ class Identite(models.ScoDocModel):
return f"{(self.nom_usuel or self.nom or '?').upper()} {(self.prenom or '')[:2].capitalize()}."
@cached_property
def sort_key(self) -> tuple:
def sort_key(self) -> str:
"clé pour tris par ordre alphabétique"
# Note: scodoc7 utilisait sco_etud.etud_sort_key, à mettre à jour
# si on modifie cette méthode.
return (
scu.sanitize_string(
self.nom_usuel or self.nom or "", remove_spaces=False
).lower(),
scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(),
)
return scu.sanitize_string(
(self.nom_usuel or self.nom or "") + ";" + (self.prenom or ""),
remove_spaces=False,
).lower()
def get_first_email(self, field="email") -> str:
"Le mail associé à la première adresse de l'étudiant, ou None"
@ -418,7 +433,7 @@ class Identite(models.ScoDocModel):
return args_dict
def to_dict_short(self) -> dict:
"""Les champs essentiels"""
"""Les champs essentiels (aucune donnée perso protégée)"""
return {
"id": self.id,
"civilite": self.civilite,
@ -433,9 +448,11 @@ class Identite(models.ScoDocModel):
"prenom_etat_civil": self.prenom_etat_civil,
}
def to_dict_scodoc7(self) -> dict:
def to_dict_scodoc7(self, restrict=False, with_inscriptions=False) -> dict:
"""Représentation dictionnaire,
compatible ScoDoc7 mais sans infos admission
compatible ScoDoc7 mais sans infos admission.
Si restrict, cache les infos "personnelles" si pas permission ViewEtudData
Si with_inscriptions, inclut les champs "inscription"
"""
e_dict = self.__dict__.copy() # dict(self.__dict__)
e_dict.pop("_sa_instance_state", None)
@ -446,7 +463,9 @@ class Identite(models.ScoDocModel):
e_dict["nomprenom"] = self.nomprenom
adresse = self.adresses.first()
if adresse:
e_dict.update(adresse.to_dict())
e_dict.update(adresse.to_dict(restrict=restrict))
if with_inscriptions:
e_dict.update(self.inscription_descr())
return {k: v or "" for k, v in e_dict.items()} # convert_null_outputs_to_empty
def to_dict_bul(self, include_urls=True):
@ -461,9 +480,11 @@ class Identite(models.ScoDocModel):
"civilite": self.civilite,
"code_ine": self.code_ine or "",
"code_nip": self.code_nip or "",
"date_naissance": self.date_naissance.strftime("%d/%m/%Y")
if self.date_naissance
else "",
"date_naissance": (
self.date_naissance.strftime(scu.DATE_FMT)
if self.date_naissance
else ""
),
"dept_acronym": self.departement.acronym,
"dept_id": self.dept_id,
"dept_naissance": self.dept_naissance or "",
@ -481,7 +502,7 @@ class Identite(models.ScoDocModel):
if include_urls and has_request_context():
# test request context so we can use this func in tests under the flask shell
d["fiche_url"] = url_for(
"scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=self.id
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=self.id
)
d["photo_url"] = sco_photos.get_etud_photo_url(self.id)
adresse = self.adresses.first()
@ -490,22 +511,37 @@ class Identite(models.ScoDocModel):
d["id"] = self.id # a été écrasé par l'id de adresse
return d
def to_dict_api(self) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission."""
def to_dict_api(self, restrict=False, with_annotations=False) -> dict:
"""Représentation dictionnaire pour export API, avec adresses et admission.
Si restrict, supprime les infos "personnelles" (boursier)
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
admission = self.admission
e["admission"] = admission.to_dict() if admission is not None else None
e["adresses"] = [adr.to_dict() for adr in self.adresses]
e["adresses"] = [adr.to_dict(restrict=restrict) for adr in self.adresses]
e["dept_acronym"] = self.departement.acronym
e.pop("departement", None)
e["sort_key"] = self.sort_key
if with_annotations:
e["annotations"] = (
[
annot.to_dict()
for annot in EtudAnnotation.query.filter_by(
etudid=self.id
).order_by(desc(EtudAnnotation.date))
]
if not restrict
else []
)
if restrict:
# Met à None les attributs protégés:
for attr in self.protected_attrs:
e[attr] = None
return e
def inscriptions(self) -> list["FormSemestreInscription"]:
"Liste des inscriptions à des formsemestres, triée, la plus récente en tête"
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter(
@ -531,8 +567,6 @@ class Identite(models.ScoDocModel):
(il est rare qu'il y en ai plus d'une, mais c'est possible).
Triées par date de début de semestre décroissante (le plus récent en premier).
"""
from app.models.formsemestre import FormSemestre, FormSemestreInscription
return (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter(
@ -555,7 +589,9 @@ class Identite(models.ScoDocModel):
return r[0] if r else None
def inscription_descr(self) -> dict:
"""Description de l'état d'inscription"""
"""Description de l'état d'inscription
avec champs compatibles templates ScoDoc7
"""
inscription_courante = self.inscription_courante()
if inscription_courante:
titre_sem = inscription_courante.formsemestre.titre_mois()
@ -566,7 +602,7 @@ class Identite(models.ScoDocModel):
else:
inscr_txt = "Inscrit en"
return {
result = {
"etat_in_cursem": inscription_courante.etat,
"inscription_courante": inscription_courante,
"inscription": titre_sem,
@ -589,15 +625,20 @@ class Identite(models.ScoDocModel):
inscription = "ancien"
situation = "ancien élève"
else:
inscription = ("non inscrit",)
inscription = "non inscrit"
situation = inscription
return {
result = {
"etat_in_cursem": "?",
"inscription_courante": None,
"inscription": inscription,
"inscription_str": inscription,
"situation": situation,
}
# aliases pour compat templates ScoDoc7
result["etatincursem"] = result["etat_in_cursem"]
result["inscriptionstr"] = result["inscription_str"]
return result
def inscription_etat(self, formsemestre_id: int) -> str:
"""État de l'inscription de cet étudiant au semestre:
@ -694,7 +735,7 @@ class Identite(models.ScoDocModel):
"""
if with_paragraph:
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.etat_civil
@ -718,6 +759,58 @@ class Identite(models.ScoDocModel):
)
def check_etud_duplicate_code(args, code_name, edit=True):
"""Vérifie que le code n'est pas dupliqué.
Raises ScoGenError si problème.
"""
etudid = args.get("etudid", None)
if not args.get(code_name, None):
return
etuds = Identite.query.filter_by(
**{code_name: str(args[code_name]), "dept_id": g.scodoc_dept_id}
).all()
duplicate = False
if edit:
duplicate = (len(etuds) > 1) or ((len(etuds) == 1) and etuds[0].id != etudid)
else:
duplicate = len(etuds) > 0
if duplicate:
listh = [] # liste des doubles
for etud in etuds:
listh.append(f"Autre étudiant: {etud.html_link_fiche()}")
if etudid:
submit_label = "retour à la fiche étudiant"
dest_endpoint = "scolar.fiche_etud"
parameters = {"etudid": etudid}
else:
if "tf_submitted" in args:
del args["tf_submitted"]
submit_label = "Continuer"
dest_endpoint = "scolar.etudident_create_form"
parameters = args
else:
submit_label = "Annuler"
dest_endpoint = "notes.index_html"
parameters = {}
err_page = f"""<h3><h3>Code étudiant ({code_name}) dupliqué !</h3>
<p class="help">Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir
ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur.
</p>
<ul><li>
{ '</li><li>'.join(listh) }
</li></ul>
<p>
<a href="{ url_for(dest_endpoint, scodoc_dept=g.scodoc_dept, **parameters) }
">{submit_label}</a>
</p>
"""
log(f"*** error: code {code_name} duplique: {args[code_name]}")
raise ScoGenError(err_page)
def make_etud_args(
etudid=None, code_nip=None, use_request=True, raise_exc=False, abort_404=True
) -> dict:
@ -825,12 +918,25 @@ class Adresse(models.ScoDocModel):
)
description = db.Column(db.Text)
def to_dict(self, convert_nulls_to_str=False):
"""Représentation dictionnaire,"""
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {
"emailperso",
"domicile",
"codepostaldomicile",
"villedomicile",
"telephone",
"telephonemobile",
"fax",
}
def to_dict(self, convert_nulls_to_str=False, restrict=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if convert_nulls_to_str:
return {k: e[k] or "" for k in e}
e = {k: v or "" for k, v in e.items()}
if restrict:
e = {k: v for (k, v) in e.items() if k not in self.protected_attrs}
return e
@ -885,12 +991,16 @@ class Admission(models.ScoDocModel):
# classement (1..Ngr) par le jury dans le groupe APB
apb_classement_gr = db.Column(db.Integer)
# Tous les champs sont "protégés" par ViewEtudData (RGPD)
# sauf:
not_protected_attrs = {"bac", "specialite", "anne_bac"}
def get_bac(self) -> Baccalaureat:
"Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères."
return Baccalaureat(self.bac, specialite=self.specialite)
def to_dict(self, no_nulls=False):
"""Représentation dictionnaire,"""
def to_dict(self, no_nulls=False, restrict=False):
"""Représentation dictionnaire. Si restrict, filtre les champs protégés (RGPD)."""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
if no_nulls:
@ -905,6 +1015,8 @@ class Admission(models.ScoDocModel):
d[key] = 0
elif isinstance(col_type, sqlalchemy.Boolean):
d[key] = False
if restrict:
d = {k: v for (k, v) in d.items() if k in self.not_protected_attrs}
return d
@classmethod
@ -972,11 +1084,16 @@ class EtudAnnotation(db.Model):
id = db.Column(db.Integer, primary_key=True)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
etudid = db.Column(db.Integer) # sans contrainte (compat ScoDoc 7))
etudid = db.Column(db.Integer, db.ForeignKey(Identite.id))
author = db.Column(db.Text) # le pseudo (user_name), was zope_authenticated_user
comment = db.Column(db.Text)
def to_dict(self):
"""Représentation dictionnaire."""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
return e
from app.models.formsemestre import FormSemestre
from app.models.modules import Module
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription

View File

@ -5,11 +5,12 @@
import datetime
from operator import attrgetter
from flask import g, url_for
from flask import abort, g, url_for
from flask_login import current_user
import sqlalchemy as sa
from app import db, log
from app import models
from app.models.etudiants import Identite
from app.models.events import ScolarNews
from app.models.notes import NotesNotes
@ -23,10 +24,8 @@ MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
NOON = datetime.time(12, 00)
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
VALID_EVALUATION_TYPES = {0, 1, 2}
class Evaluation(db.Model):
class Evaluation(models.ScoDocModel):
"""Evaluation (contrôle, examen, ...)"""
__tablename__ = "notes_evaluation"
@ -38,9 +37,9 @@ class Evaluation(db.Model):
)
date_debut = db.Column(db.DateTime(timezone=True), nullable=True)
date_fin = db.Column(db.DateTime(timezone=True), nullable=True)
description = db.Column(db.Text)
note_max = db.Column(db.Float)
coefficient = db.Column(db.Float)
description = db.Column(db.Text, nullable=False)
note_max = db.Column(db.Float, nullable=False)
coefficient = db.Column(db.Float, nullable=False)
visibulletin = db.Column(
db.Boolean, nullable=False, default=True, server_default="true"
)
@ -48,15 +47,30 @@ class Evaluation(db.Model):
publish_incomplete = db.Column(
db.Boolean, nullable=False, default=False, server_default="false"
)
# type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session"
"prise en compte immédiate"
evaluation_type = db.Column(
db.Integer, nullable=False, default=0, server_default="0"
)
"type d'evaluation: 0 normale, 1 rattrapage, 2 2eme session, 3 bonus"
blocked_until = db.Column(db.DateTime(timezone=True), nullable=True)
"date de prise en compte"
BLOCKED_FOREVER = datetime.datetime(2666, 12, 31, tzinfo=scu.TIME_ZONE)
# ordre de presentation (par défaut, le plus petit numero
# est la plus ancienne eval):
numero = db.Column(db.Integer, nullable=False, default=0)
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
EVALUATION_NORMALE = 0 # valeurs stockées en base, ne pas changer !
EVALUATION_RATTRAPAGE = 1
EVALUATION_SESSION2 = 2
EVALUATION_BONUS = 3
VALID_EVALUATION_TYPES = {
EVALUATION_NORMALE,
EVALUATION_RATTRAPAGE,
EVALUATION_SESSION2,
EVALUATION_BONUS,
}
def __repr__(self):
return f"""<Evaluation {self.id} {
self.date_debut.isoformat() if self.date_debut else ''} "{
@ -70,15 +84,17 @@ class Evaluation(db.Model):
date_fin: datetime.datetime = None,
description=None,
note_max=None,
blocked_until=None,
coefficient=None,
visibulletin=None,
publish_incomplete=None,
evaluation_type=None,
numero=None,
**kw, # ceci pour absorber les éventuel arguments excedentaires
):
) -> "Evaluation":
"""Create an evaluation. Check permission and all arguments.
Ne crée pas les poids vers les UEs.
Add to session, do not commit.
"""
if not moduleimpl.can_edit_evaluation(current_user):
raise AccessDenied(
@ -87,13 +103,15 @@ class Evaluation(db.Model):
args = locals()
del args["cls"]
del args["kw"]
check_convert_evaluation_args(moduleimpl, args)
check_and_convert_evaluation_args(args, moduleimpl)
# Check numeros
Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True)
if not "numero" in args or args["numero"] is None:
args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"])
#
evaluation = Evaluation(**args)
db.session.add(evaluation)
db.session.flush()
sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id)
url = url_for(
"notes.moduleimpl_status",
@ -184,18 +202,24 @@ class Evaluation(db.Model):
# ScoDoc7 output_formators
e_dict["evaluation_id"] = self.id
e_dict["date_debut"] = self.date_debut.isoformat() if self.date_debut else None
e_dict["date_fin"] = self.date_debut.isoformat() if self.date_fin else None
e_dict["date_fin"] = self.date_fin.isoformat() if self.date_fin else None
e_dict["numero"] = self.numero or 0
e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
# Deprecated
e_dict["jour"] = self.date_debut.strftime("%d/%m/%Y") if self.date_debut else ""
e_dict["jour"] = (
self.date_debut.strftime(scu.DATE_FMT) if self.date_debut else ""
)
return evaluation_enrich_dict(self, e_dict)
def to_dict_api(self) -> dict:
"Représentation dict pour API JSON"
return {
"blocked": self.is_blocked(),
"blocked_until": (
self.blocked_until.isoformat() if self.blocked_until else ""
),
"coefficient": self.coefficient,
"date_debut": self.date_debut.isoformat() if self.date_debut else "",
"date_fin": self.date_fin.isoformat() if self.date_fin else "",
@ -210,9 +234,9 @@ class Evaluation(db.Model):
"visibulletin": self.visibulletin,
# Deprecated (supprimer avant #sco9.7)
"date": self.date_debut.date().isoformat() if self.date_debut else "",
"heure_debut": self.date_debut.time().isoformat()
if self.date_debut
else "",
"heure_debut": (
self.date_debut.time().isoformat() if self.date_debut else ""
),
"heure_fin": self.date_fin.time().isoformat() if self.date_fin else "",
}
@ -232,14 +256,24 @@ class Evaluation(db.Model):
return e_dict
def from_dict(self, data):
"""Set evaluation attributes from given dict values."""
check_convert_evaluation_args(self.moduleimpl, data)
if data.get("numero") is None:
data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1
for k in self.__dict__:
if k != "_sa_instance_state" and k != "id" and k in data:
setattr(self, k, data[k])
@classmethod
def get_evaluation(
cls, evaluation_id: int | str, dept_id: int = None
) -> "Evaluation":
"""Evaluation ou 404, cherche uniquement dans le département spécifié ou le courant."""
from app.models import FormSemestre, ModuleImpl
if not isinstance(evaluation_id, int):
try:
evaluation_id = int(evaluation_id)
except (TypeError, ValueError):
abort(404, "evaluation_id invalide")
if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
query = cls.query.filter_by(id=evaluation_id)
if dept_id is not None:
query = query.join(ModuleImpl).join(FormSemestre).filter_by(dept_id=dept_id)
return query.first_or_404()
@classmethod
def get_max_numero(cls, moduleimpl_id: int) -> int:
@ -265,7 +299,9 @@ class Evaluation(db.Model):
evaluations = moduleimpl.evaluations.order_by(
Evaluation.date_debut, Evaluation.numero
).all()
all_numbered = all(e.numero is not None for e in evaluations)
numeros_distincts = {e.numero for e in evaluations if e.numero is not None}
# pas de None, pas de dupliqués
all_numbered = len(numeros_distincts) == len(evaluations)
if all_numbered and only_if_unumbered:
return # all ok
@ -281,10 +317,10 @@ class Evaluation(db.Model):
def descr_heure(self) -> str:
"Description de la plage horaire pour affichages ('de 13h00 à 14h00')"
if self.date_debut and (not self.date_fin or self.date_fin == self.date_debut):
return f"""à {self.date_debut.strftime("%Hh%M")}"""
return f"""à {self.date_debut.strftime(scu.TIME_FMT)}"""
elif self.date_debut and self.date_fin:
return f"""de {self.date_debut.strftime("%Hh%M")
} à {self.date_fin.strftime("%Hh%M")}"""
return f"""de {self.date_debut.strftime(scu.TIME_FMT)
} à {self.date_fin.strftime(scu.TIME_FMT)}"""
else:
return ""
@ -311,7 +347,7 @@ class Evaluation(db.Model):
def _h(dt: datetime.datetime) -> str:
if dt.minute:
return dt.strftime("%Hh%M")
return dt.strftime(scu.TIME_FMT)
return f"{dt.hour}h"
if self.date_fin is None:
@ -337,19 +373,6 @@ class Evaluation(db.Model):
Chaine vide si non renseignée."""
return self.date_fin.time().isoformat("minutes") if self.date_fin else ""
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain commit
"""
d = dict(self.__dict__)
d.pop("id") # get rid of id
d.pop("_sa_instance_state") # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k)
copy = self.__class__(**d)
db.session.add(copy)
return copy
def is_matin(self) -> bool:
"Evaluation commençant le matin (faux si pas de date)"
if not self.date_debut:
@ -362,6 +385,14 @@ class Evaluation(db.Model):
return False
return self.date_debut.time() >= NOON
def is_blocked(self, now=None) -> bool:
"True si prise en compte bloquée"
if self.blocked_until is None:
return False
if now is None:
now = datetime.datetime.now(scu.TIME_ZONE)
return self.blocked_until > now
def set_default_poids(self) -> bool:
"""Initialize les poids vers les UE à leurs valeurs par défaut
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
@ -428,8 +459,8 @@ class Evaluation(db.Model):
def get_ue_poids_str(self) -> str:
"""string describing poids, for excel cells and pdfs
Note: si les poids ne sont pas initialisés (poids par défaut),
ils ne sont pas affichés.
Note: les poids nuls ou non initialisés (poids par défaut),
ne sont pas affichés.
"""
# restreint aux UE du semestre dans lequel est cette évaluation
# au cas où le module ait changé de semestre et qu'il reste des poids
@ -440,7 +471,7 @@ class Evaluation(db.Model):
for p in sorted(
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme)
)
if evaluation_semestre_idx == p.ue.semestre_idx
if evaluation_semestre_idx == p.ue.semestre_idx and (p.poids or 0) > 0
]
)
@ -450,6 +481,29 @@ class Evaluation(db.Model):
"""
return NotesNotes.query.filter_by(etudid=etud.id, evaluation_id=self.id).first()
@classmethod
def get_evaluations_blocked_for_etud(
cls, formsemestre, etud: Identite
) -> list["Evaluation"]:
"""Liste des évaluations de ce semestre avec note pour cet étudiant et date blocage
et date blocage < FOREVER.
Si non vide, une note apparaitra dans le futur pour cet étudiant: il faut
donc interdire la saisie du jury.
"""
now = datetime.datetime.now(scu.TIME_ZONE)
return (
Evaluation.query.filter(
Evaluation.blocked_until != None, # pylint: disable=C0121
Evaluation.blocked_until >= now,
)
.join(ModuleImpl)
.filter_by(formsemestre_id=formsemestre.id)
.join(ModuleImplInscription)
.filter_by(etudid=etud.id)
.join(NotesNotes)
.all()
)
class EvaluationUEPoids(db.Model):
"""Poids des évaluations (BUT)
@ -487,8 +541,8 @@ class EvaluationUEPoids(db.Model):
def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
"""add or convert some fields in an evaluation dict"""
# For ScoDoc7 compat
e_dict["heure_debut"] = e.date_debut.strftime("%Hh%M") if e.date_debut else ""
e_dict["heure_fin"] = e.date_fin.strftime("%Hh%M") if e.date_fin else ""
e_dict["heure_debut"] = e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else ""
e_dict["heure_fin"] = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else ""
e_dict["jour_iso"] = e.date_debut.isoformat() if e.date_debut else ""
# Calcule durée en minutes
e_dict["descrheure"] = e.descr_heure()
@ -507,7 +561,7 @@ def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
return e_dict
def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
"""Check coefficient, dates and duration, raises exception if invalid.
Convert date and time strings to date and time objects.
@ -522,7 +576,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
# --- evaluation_type
try:
data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0)
if not data["evaluation_type"] in VALID_EVALUATION_TYPES:
if not data["evaluation_type"] in Evaluation.VALID_EVALUATION_TYPES:
raise ScoValueError("invalid evaluation_type value")
except ValueError as exc:
raise ScoValueError("invalid evaluation_type value") from exc
@ -547,7 +601,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
if coef < 0:
raise ScoValueError("invalid coefficient value (must be positive or null)")
data["coefficient"] = coef
# --- date de l'évaluation
# --- date de l'évaluation dans le semestre ?
formsemestre = moduleimpl.formsemestre
date_debut = data.get("date_debut", None)
if date_debut:
@ -562,7 +616,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
):
raise ScoValueError(
f"""La date de début de l'évaluation ({
data["date_debut"].strftime("%d/%m/%Y")
data["date_debut"].strftime(scu.DATE_FMT)
}) n'est pas dans le semestre !""",
dest_url="javascript:history.back();",
)
@ -577,27 +631,19 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
):
raise ScoValueError(
f"""La date de fin de l'évaluation ({
data["date_fin"].strftime("%d/%m/%Y")
data["date_fin"].strftime(scu.DATE_FMT)
}) n'est pas dans le semestre !""",
dest_url="javascript:history.back();",
)
if date_debut and date_fin:
duration = data["date_fin"] - data["date_debut"]
if duration.total_seconds() < 0 or duration > MAX_EVALUATION_DURATION:
raise ScoValueError("Heures de l'évaluation incohérentes !")
# # --- heures
# heure_debut = data.get("heure_debut", None)
# if heure_debut and not isinstance(heure_debut, datetime.time):
# if date_format == "dmy":
# data["heure_debut"] = heure_to_time(heure_debut)
# else: # ISO
# data["heure_debut"] = datetime.time.fromisoformat(heure_debut)
# heure_fin = data.get("heure_fin", None)
# if heure_fin and not isinstance(heure_fin, datetime.time):
# if date_format == "dmy":
# data["heure_fin"] = heure_to_time(heure_fin)
# else: # ISO
# data["heure_fin"] = datetime.time.fromisoformat(heure_fin)
raise ScoValueError(
"Heures de l'évaluation incohérentes !",
dest_url="javascript:history.back();",
)
if "blocked_until" in data:
data["blocked_until"] = data["blocked_until"] or None
def heure_to_time(heure: str) -> datetime.time:
@ -627,3 +673,6 @@ def _moduleimpl_evaluation_insert_before(
db.session.add(e)
db.session.commit()
return n
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription

View File

@ -232,7 +232,9 @@ class ScolarNews(db.Model):
)
# Transforme les URL en URL absolues
base = scu.ScoURL()
base = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)[
: -len("/index_html")
]
txt = re.sub('href=/.*?"', 'href="' + base + "/", txt)
# Transforme les liens HTML en texte brut: '<a href="url">texte</a>' devient 'texte: url'
@ -249,11 +251,12 @@ class ScolarNews(db.Model):
news_list = cls.last_news(n=n)
if not news_list:
return ""
dept_news_url = url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
H = [
f"""<div class="news"><span class="newstitle"><a href="{
url_for("scolar.dept_news", scodoc_dept=g.scodoc_dept)
f"""<div class="scobox news"><div class="scobox-title"><a href="{
dept_news_url
}">Dernières opérations</a>
</span><ul class="newslist">"""
</div><ul class="newslist">"""
]
for news in news_list:
@ -261,16 +264,22 @@ class ScolarNews(db.Model):
f"""<li class="newslist"><span class="newsdate">{news.formatted_date()}</span><span
class="newstext">{news}</span></li>"""
)
H.append(
f"""<li class="newslist">
<span class="newstext"><a href="{dept_news_url}" class="stdlink">...</a>
</span>
</li>"""
)
H.append("</ul>")
H.append("</ul></div>")
# Informations générales
H.append(
f"""<div><a class="discretelink" href="{scu.SCO_ANNONCES_WEBSITE}">
Pour en savoir plus sur ScoDoc voir le site scodoc.org</a>.
f"""<div>
Pour en savoir plus sur ScoDoc voir
<a class="stdlink" href="{scu.SCO_ANNONCES_WEBSITE}">scodoc.org</a>
</div>
"""
)
H.append("</div>")
return "\n".join(H)

View File

@ -1,5 +1,7 @@
"""ScoDoc 9 models : Formations
"""
from flask import abort, g
from flask_sqlalchemy.query import Query
import app
@ -64,6 +66,21 @@ class Formation(db.Model):
"titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
@classmethod
def get_formation(cls, formation_id: int | str, dept_id: int = None) -> "Formation":
"""Formation ou 404, cherche uniquement dans le département spécifié
ou le courant (g.scodoc_dept)"""
if not isinstance(formation_id, int):
try:
formation_id = int(formation_id)
except (TypeError, ValueError):
abort(404, "formation_id invalide")
if g.scodoc_dept:
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
if dept_id is not None:
return cls.query.filter_by(id=formation_id, dept_id=dept_id).first_or_404()
return cls.query.filter_by(id=formation_id).first_or_404()
def to_dict(self, with_refcomp_attrs=False, with_departement=True):
"""As a dict.
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.

View File

@ -25,6 +25,7 @@ from sqlalchemy import func
import app.scodoc.sco_utils as scu
from app import db, log
from app.auth.models import User
from app import models
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import (
ApcParcours,
@ -54,7 +55,7 @@ from app.scodoc.sco_vdi import ApoEtapeVDI
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
class FormSemestre(db.Model):
class FormSemestre(models.ScoDocModel):
"""Mise en oeuvre d'un semestre de formation"""
__tablename__ = "notes_formsemestre"
@ -68,7 +69,7 @@ class FormSemestre(db.Model):
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
titre = db.Column(db.Text(), nullable=False)
date_debut = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False) # jour inclus
edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
"identifiant emplois du temps (unicité non imposée)"
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
@ -84,7 +85,7 @@ class FormSemestre(db.Model):
bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"ne publie pas le bulletin XML ou JSON"
"ne publie pas le bulletin sur l'API"
block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
@ -93,6 +94,10 @@ class FormSemestre(db.Model):
db.Boolean(), nullable=False, default=False, server_default="false"
)
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
mode_calcul_moyennes = db.Column(
db.Integer, nullable=False, default=0, server_default="0"
)
"pour usage futur"
gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
@ -187,7 +192,8 @@ class FormSemestre(db.Model):
def get_formsemestre(
cls, formsemestre_id: int | str, dept_id: int = None
) -> "FormSemestre":
""" "FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant"""
"""FormSemestre ou 404, cherche uniquement dans le département spécifié
ou le courant (g.scodoc_dept)"""
if not isinstance(formsemestre_id, int):
try:
formsemestre_id = int(formsemestre_id)
@ -202,7 +208,7 @@ class FormSemestre(db.Model):
return cls.query.filter_by(id=formsemestre_id).first_or_404()
def sort_key(self) -> tuple:
"""clé pour tris par ordre alphabétique
"""clé pour tris par ordre de date_debut, le plus ancien en tête
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
return (self.date_debut, self.semestre_id)
@ -218,12 +224,12 @@ class FormSemestre(db.Model):
d["formsemestre_id"] = self.id
d["titre_num"] = self.titre_num()
if self.date_debut:
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
d["date_debut_iso"] = self.date_debut.isoformat()
else:
d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin:
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -241,19 +247,20 @@ class FormSemestre(db.Model):
def to_dict_api(self):
"""
Un dict avec les informations sur le semestre destiné à l'api
Un dict avec les informations sur le semestre destinées à l'api
"""
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
d.pop("groups_auto_assignment_data", None)
d["annee_scolaire"] = self.annee_scolaire()
d["bul_hide_xml"] = self.bul_hide_xml
if self.date_debut:
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
d["date_debut_iso"] = self.date_debut.isoformat()
else:
d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin:
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -673,7 +680,7 @@ class FormSemestre(db.Model):
) -> db.Query:
"""Liste (query) ordonnée des formsemestres courants, c'est
à dire contenant la date courant (si None, la date actuelle)"""
date_courante = date_courante or db.func.now()
date_courante = date_courante or db.func.current_date()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
@ -869,9 +876,9 @@ class FormSemestre(db.Model):
descr_sem += " " + self.modalite
return descr_sem
def get_abs_count(self, etudid):
def get_abs_count(self, etudid) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs, nb abs justifiées)
tuple (nb abs non just, nb abs justifiées, nb abs total)
Utilise un cache.
"""
from app.scodoc import sco_assiduites
@ -929,12 +936,16 @@ class FormSemestre(db.Model):
partitions += [p for p in self.partitions if p.partition_name is None]
return partitions
@cached_property
def etudids_actifs(self) -> set:
"Set des etudids inscrits non démissionnaires et non défaillants"
return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT}
def etudids_actifs(self) -> tuple[list[int], set[int]]:
"""Liste les etudids inscrits (incluant DEM et DEF),
qui ser al'index des dataframes de notes
et donne l'ensemble des inscrits non DEM ni DEF.
"""
return [inscr.etudid for inscr in self.inscriptions], {
ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
}
@cached_property
@property
def etuds_inscriptions(self) -> dict:
"""Map { etudid : inscription } (incluant DEM et DEF)"""
return {ins.etud.id: ins for ins in self.inscriptions}

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,17 +1,24 @@
"""ScoDoc 9 models : Modules
"""
from flask import current_app
from flask import current_app, g
from app import db
from app import models
from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
from app.models.but_refcomp import (
ApcParcours,
ApcReferentielCompetences,
app_critiques_modules,
parcours_modules,
)
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import ModuleType
class Module(db.Model):
class Module(models.ScoDocModel):
"""Module"""
__tablename__ = "notes_modules"
@ -76,6 +83,55 @@ class Module(db.Model):
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
returns: dict to store in model's db.
"""
# s'assure que ects etc est non ''
fs_empty_stored_as_nulls = {
"coefficient",
"ects",
"heures_cours",
"heures_td",
"heures_tp",
}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
if key in fs_empty_stored_as_nulls and value == "":
value = None
args_dict[key] = value
return args_dict
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
Add 'id' to excluded."""
# on ne peut pas affecter directement parcours
return super().filter_model_attributes(data, (excluded or set()) | {"parcours"})
@classmethod
def create_from_dict(cls, data: dict) -> "Module":
"""Create from given dict, add parcours"""
mod = super().create_from_dict(data)
for p in data.get("parcours", []) or []:
if isinstance(p, ApcParcours):
parcour: ApcParcours = p
else:
pid = int(p)
query = ApcParcours.query.filter_by(id=pid)
if g.scodoc_dept:
query = query.join(ApcReferentielCompetences).filter_by(
dept_id=g.scodoc_dept_id
)
parcour: ApcParcours = query.first()
if parcour is None:
raise ScoValueError("Parcours invalide")
mod.parcours.append(parcour)
return mod
def clone(self):
"""Create a new copy of this module."""
mod = Module(
@ -310,6 +366,14 @@ class Module(db.Model):
return []
return self.parcours
def add_tag(self, tag: "NotesTag"):
"""Add tag to module. Check if already has it."""
if tag.id in {t.id for t in self.tags}:
return
self.tags.append(tag)
db.session.add(self)
db.session.flush()
class ModuleUECoef(db.Model):
"""Coefficients des modules vers les UE (APC, BUT)
@ -372,6 +436,19 @@ class NotesTag(db.Model):
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
title = db.Column(db.Text(), nullable=False)
@classmethod
def get_or_create(cls, title: str, dept_id: int | None = None) -> "NotesTag":
"""Get tag, or create it if it doesn't yet exists.
If dept_id unspecified, use current dept.
"""
dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
tag = NotesTag.query.filter_by(dept_id=dept_id, title=title).first()
if tag is None:
tag = NotesTag(dept_id=dept_id, title=title)
db.session.add(tag)
db.session.flush()
return tag
# Association tag <-> module
notes_modules_tags = db.Table(

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
@ -390,6 +409,14 @@ class UniteEns(db.Model):
Renvoie (True, "") si ok, sinon (False, error_message)
"""
msg = ""
# Safety check
if self.formation.referentiel_competence is None:
return False, "pas de référentiel de compétence"
# Si tous les parcours, aucun (tronc commun)
if {p.id for p in parcours} == {
p.id for p in self.formation.referentiel_competence.parcours
}:
parcours = []
# Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
prev_niveau = self.niveau_competence
if (
@ -405,6 +432,7 @@ class UniteEns(db.Model):
self.niveau_competence, parcours
)
if not ok:
self.formation.invalidate_cached_sems()
self.niveau_competence = prev_niveau # restore
return False, error_message

View File

@ -72,7 +72,7 @@ class ScolarFormSemestreValidation(db.Model):
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id
} ({self.ue_id}): {self.code}"""
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {
self.event_date.strftime("%d/%m/%Y")}"""
self.event_date.strftime(scu.DATE_FMT)}"""
def delete(self):
"Efface cette validation"
@ -113,20 +113,20 @@ class ScolarFormSemestreValidation(db.Model):
if self.ue.parcours else ""}
{("émise par " + link)}
: <b>{self.code}</b>{moyenne}
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
"""
le {self.event_date.strftime(scu.DATEATIME_FMT)}
"""
else:
return f"""Validation du semestre S{
self.formsemestre.semestre_id if self.formsemestre else "?"}
{self.formsemestre.html_link_status() if self.formsemestre else ""}
: <b>{self.code}</b>
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
le {self.event_date.strftime(scu.DATEATIME_FMT)}
"""
def ects(self) -> float:
"Les ECTS acquis par cette validation. (0 si ce n'est pas une validation d'UE)"
return (
self.ue.ects
self.ue.ects or 0.0
if (self.ue is not None) and (self.code in CODES_UE_VALIDES)
else 0.0
)
@ -175,8 +175,8 @@ class ScolarAutorisationInscription(db.Model):
)
return f"""Autorisation de passage vers <b>S{self.semestre_id}</b> émise par
{link}
le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
"""
le {self.date.strftime(scu.DATEATIME_FMT)}
"""
@classmethod
def autorise_etud(

0
app/pe/moys/__init__.py Normal file
View File

View File

@ -0,0 +1,342 @@
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Thu Sep 8 09:36:33 2016
@author: barasc
"""
import pandas as pd
import numpy as np
from app.models import Identite
from app.pe import pe_affichage
from app.pe.moys import pe_tabletags, pe_moy, pe_moytag, pe_sxtag
from app.pe.rcss import pe_rcs
import app.pe.pe_comp as pe_comp
from app.scodoc.sco_utils import ModuleType
class InterClassTag(pe_tabletags.TableTag):
"""
Interclasse l'ensemble des étudiants diplômés à une année
donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S'), qu'il soit
de type SemX ou RCSemX,
en reportant les moyennes obtenues sur à la version tagguée
du RCS (de type SxTag ou RCSTag).
Sont ensuite calculés les classements (uniquement)
sur les étudiants diplômes.
Args:
nom_rcs: Le nom de l'aggrégat
type_interclassement: Le type d'interclassement (par UE ou par compétences)
etudiants_diplomes: L'identité des étudiants diplômés
rcss: Un dictionnaire {(nom_rcs, fid_final): RCS} donnant soit
les SemX soit les RCSemX recencés par le jury PE
rcstag: Un dictionnaire {(nom_rcs, fid_final): RCSTag} donnant
soit les SxTag (associés aux SemX)
soit les RCSTags (associés au RCSemX) calculés par le jury PE
suivis: Un dictionnaire associé à chaque étudiant son rcss
(de la forme ``{etudid: {nom_rcs: RCS_suivi}}``)
"""
def __init__(
self,
nom_rcs: str,
type_interclassement: str,
etudiants_diplomes: dict[int, Identite],
rcss: dict[(str, int) : pe_rcs.RCS],
rcstags: dict[(str, int) : pe_tabletags.TableTag],
suivis: dict[int:dict],
):
pe_tabletags.TableTag.__init__(self)
self.nom_rcs: str = nom_rcs
"""Le nom du RCS interclassé"""
# Le type d'interclassement
self.type = type_interclassement
pe_affichage.pe_print(
f"*** Interclassement par 🗂️{type_interclassement} pour le RCS ⏯️{nom_rcs}"
)
# Les informations sur les étudiants diplômés
self.etuds: list[Identite] = list(etudiants_diplomes.values())
"""Identités des étudiants diplômés"""
self.add_etuds(self.etuds)
self.diplomes_ids = set(etudiants_diplomes.keys())
"""Etudids des étudiants diplômés"""
# Les RCS de l'aggrégat (SemX ou RCSemX)
self.rcss: dict[(str, int), pe_rcs.RCS] = {}
"""Ensemble des SemX ou des RCSemX associés à l'aggrégat"""
for (nom, fid), rcs in rcss.items():
if nom == nom_rcs:
self.rcss[(nom, fid)] = rcss
# Les données tagguées
self.rcstags: dict[(str, int), pe_tabletags.TableTag] = {}
"""Ensemble des SxTag ou des RCSTags associés à l'aggrégat"""
for rcs_id in self.rcss:
self.rcstags[rcs_id] = rcstags[rcs_id]
# Les RCS (SemX ou RCSemX) suivis par les étudiants du jury,
# en ne gardant que ceux associés aux diplomés
self.suivis: dict[int, pe_rcs.RCS] = {}
"""Association entre chaque étudiant et le SxTag ou RCSTag à prendre
pour l'aggrégat"""
for etudid in self.diplomes_ids:
self.suivis[etudid] = suivis[etudid][nom_rcs]
# Les données sur les tags
self.tags_sorted = self._do_taglist()
"""Liste des tags (triés par ordre alphabétique)"""
aff = pe_affichage.repr_tags(self.tags_sorted)
pe_affichage.pe_print(f"--> Tags : {aff}")
# Les données sur les UEs (si SxTag) ou compétences (si RCSTag)
self.champs_sorted = self._do_ues_ou_competences_list()
"""Les champs (UEs ou compétences) de l'interclassement"""
if self.type == pe_moytag.CODE_MOY_UE:
pe_affichage.pe_print(
f"--> UEs : {pe_affichage.aff_UEs(self.champs_sorted)}"
)
else:
pe_affichage.pe_print(
f"--> Compétences : {pe_affichage.aff_competences(self.champs_sorted)}"
)
# Etudids triés
self.etudids_sorted = sorted(list(self.diplomes_ids))
self.nom = self.get_repr()
"""Représentation textuelle de l'interclassement"""
# Synthétise les moyennes/classements par tag
self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {}
for tag in self.tags_sorted:
# Les moyennes tous modules confondus
notes_gen = self.compute_notes_matrice(tag)
# Les coefficients de la moyenne générale
coeffs = self.compute_coeffs_matrice(tag)
aff = pe_affichage.repr_profil_coeffs(coeffs, with_index=True)
pe_affichage.pe_print(f"--> Moyenne 👜{tag} avec coeffs: {aff} ")
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
tag,
self.type,
notes_gen,
coeffs, # limite les moyennes aux étudiants de la promo
)
def get_repr(self) -> str:
"""Une représentation textuelle"""
return f"{self.nom_rcs} par {self.type}"
def _do_taglist(self):
"""Synthétise les tags à partir des TableTags (SXTag ou RCSTag)
Returns:
Une liste de tags triés par ordre alphabétique
"""
tags = []
for rcstag in self.rcstags.values():
tags.extend(rcstag.tags_sorted)
return sorted(set(tags))
def compute_notes_matrice(self, tag) -> pd.DataFrame:
"""Construit la matrice de notes (etudids x champs) en
reportant les moyennes obtenues par les étudiants
aux semestres de l'aggrégat pour le tag visé.
Les champs peuvent être des acronymes d'UEs ou des compétences.
Args:
tag: Le tag visé
Return:
Le dataFrame (etudids x champs)
reportant les moyennes des étudiants aux champs
"""
# etudids_sorted: Les etudids des étudiants (diplômés) triés
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
# Partant d'un dataframe vierge
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
for rcstag in self.rcstags.values():
# Charge les moyennes au tag d'un RCStag
if tag in rcstag.moyennes_tags:
moytag = rcstag.moyennes_tags[tag]
notes = moytag.matrice_notes_gen # dataframe etudids x ues
# Etudiants/Champs communs entre le RCSTag et les données interclassées
(
etudids_communs,
champs_communs,
) = pe_comp.find_index_and_columns_communs(df, notes)
# Injecte les notes par tag
df.loc[etudids_communs, champs_communs] = notes.loc[
etudids_communs, champs_communs
]
return df
def compute_coeffs_matrice(self, tag) -> pd.DataFrame:
"""Idem que compute_notes_matrices mais pour les coeffs
Args:
tag: Le tag visé
Return:
Le dataFrame (etudids x champs)
reportant les moyennes des étudiants aux champs
"""
# etudids_sorted: Les etudids des étudiants (diplômés) triés
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
# Partant d'un dataframe vierge
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
for rcstag in self.rcstags.values():
if tag in rcstag.moyennes_tags:
# Charge les coeffs au tag d'un RCStag
coeffs: pd.DataFrame = rcstag.moyennes_tags[tag].matrice_coeffs_moy_gen
# Etudiants/Champs communs entre le RCSTag et les données interclassées
(
etudids_communs,
champs_communs,
) = pe_comp.find_index_and_columns_communs(df, coeffs)
# Injecte les coeffs par tag
df.loc[etudids_communs, champs_communs] = coeffs.loc[
etudids_communs, champs_communs
]
return df
def _do_ues_ou_competences_list(self) -> list[str]:
"""Synthétise les champs (UEs ou compétences) sur lesquels
sont calculés les moyennes.
Returns:
Un dictionnaire {'acronyme_ue' : 'compétences'}
"""
dict_champs = []
for rcstag in self.rcstags.values():
if isinstance(rcstag, pe_sxtag.SxTag):
champs = rcstag.acronymes_sorted
else: # pe_rcstag.RCSTag
champs = rcstag.competences_sorted
dict_champs.extend(champs)
return sorted(set(dict_champs))
def has_tags(self):
"""Indique si l'interclassement a des tags (cas d'un
interclassement sur un S5 qui n'a pas eu lieu)
"""
return len(self.tags_sorted) > 0
def _un_rcstag_significatif(self, rcsstags: dict[(str, int):pe_tabletags]):
"""Renvoie un rcstag significatif (ayant des tags et des notes aux tags)
parmi le dictionnaire de rcsstags"""
for rcstag_id, rcstag in rcsstags.items():
moystags: pe_moytag.MoyennesTag = rcstag.moyennes_tags
for tag, moystag in moystags.items():
tags_tries = moystag.get_all_significant_tags()
if tags_tries:
return moystag
return None
def compute_df_synthese_moyennes_tag(
self, tag, aggregat=None, type_colonnes=False, options={"min_max_moy": True}
) -> pd.DataFrame:
"""Construit le dataframe retraçant pour les données des moyennes
pour affichage dans la synthèse du jury PE. (cf. to_df())
Args:
etudids_sorted: Les etudids des étudiants (diplômés) triés
champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
Return:
Le dataFrame (etudids x champs)
reportant les moyennes des étudiants aux champs
"""
if aggregat:
assert (
aggregat == self.nom_rcs
), "L'interclassement ciblé ne correspond pas à l'aggrégat visé"
etudids_sorted = sorted(list(self.diplomes_ids))
if not self.rcstags:
return None
# Partant d'un dataframe vierge
initialisation = False
df = pd.DataFrame()
# Pour chaque rcs (suivi) associe la liste des etudids l'ayant suivi
asso_rcs_etudids = {}
for etudid in etudids_sorted:
rcs = self.suivis[etudid]
if rcs:
if rcs.rcs_id not in asso_rcs_etudids:
asso_rcs_etudids[rcs.rcs_id] = []
asso_rcs_etudids[rcs.rcs_id].append(etudid)
for rcs_id, etudids in asso_rcs_etudids.items():
# Charge ses moyennes au RCSTag suivi
rcstag = self.rcstags[rcs_id] # Le SxTag ou RCSTag
# Charge la moyenne
if tag in rcstag.moyennes_tags:
moytag: pd.DataFrame = rcstag.moyennes_tags[tag]
df_moytag = moytag.to_df(
aggregat=aggregat, cohorte="Groupe", options=options
)
# Modif les colonnes au regard du 1er df_moytag significatif lu
if not initialisation:
df = pd.DataFrame(
np.nan, index=etudids_sorted, columns=df_moytag.columns
)
colonnes = list(df_moytag.columns)
for col in colonnes:
if col.endswith("rang"):
df[col] = df[col].astype(str)
initialisation = True
# Injecte les notes des étudiants
df.loc[etudids, :] = df_moytag.loc[etudids, :]
return df

128
app/pe/moys/pe_moy.py Normal file
View File

@ -0,0 +1,128 @@
import numpy as np
import pandas as pd
from app.comp.moy_sem import comp_ranks_series
from app.pe import pe_affichage
class Moyenne:
COLONNES = [
"note",
"classement",
"rang",
"min",
"max",
"moy",
"nb_etuds",
"nb_inscrits",
]
"""Colonnes du df"""
@classmethod
def get_colonnes_synthese(cls, with_min_max_moy):
if with_min_max_moy:
return ["note", "rang", "min", "max", "moy"]
else:
return ["note", "rang"]
def __init__(self, notes: pd.Series):
"""Classe centralisant la synthèse des moyennes/classements d'une série
de notes :
* des "notes" : la Serie pandas des notes (float),
* des "classements" : la Serie pandas des classements (float),
* des "min" : la note minimum,
* des "max" : la note maximum,
* des "moy" : la moyenne,
* des "nb_inscrits" : le nombre d'étudiants ayant une note,
"""
self.notes = notes
"""Les notes"""
self.etudids = list(notes.index) # calcul à venir
"""Les id des étudiants"""
self.inscrits_ids = notes[notes.notnull()].index.to_list()
"""Les id des étudiants dont la note est non nulle"""
self.df: pd.DataFrame = self.comp_moy_et_stat(self.notes)
"""Le dataframe retraçant les moyennes/classements/statistiques"""
self.synthese = self.to_dict()
"""La synthèse (dictionnaire) des notes/classements/statistiques"""
def comp_moy_et_stat(self, notes: pd.Series) -> dict:
"""Calcule et structure les données nécessaires au PE pour une série
de notes (pouvant être une moyenne d'un tag à une UE ou une moyenne générale
d'un tag) dans un dictionnaire spécifique.
Partant des notes, sont calculés les classements (en ne tenant compte
que des notes non nulles).
Args:
notes: Une série de notes (avec des éventuels NaN)
Returns:
Un dictionnaire stockant les notes, les classements, le min,
le max, la moyenne, le nb de notes (donc d'inscrits)
"""
df = pd.DataFrame(
np.nan,
index=self.etudids,
columns=Moyenne.COLONNES,
)
# Supprime d'éventuelles chaines de caractères dans les notes
notes = pd.to_numeric(notes, errors="coerce")
df["note"] = notes
# Les nb d'étudiants & nb d'inscrits
df["nb_etuds"] = len(self.etudids)
df["nb_etuds"] = df["nb_etuds"].astype(int)
# Les étudiants dont la note n'est pas nulle
inscrits_ids = notes[notes.notnull()].index.to_list()
df.loc[inscrits_ids, "nb_inscrits"] = len(inscrits_ids)
# df["nb_inscrits"] = df["nb_inscrits"].astype(int)
# Le classement des inscrits
notes_non_nulles = notes[inscrits_ids]
(class_str, class_int) = comp_ranks_series(notes_non_nulles)
df.loc[inscrits_ids, "classement"] = class_int
# df["classement"] = df["classement"].astype(int)
# Le rang (classement/nb_inscrit)
df["rang"] = df["rang"].astype(str)
df.loc[inscrits_ids, "rang"] = (
df.loc[inscrits_ids, "classement"].astype(int).astype(str)
+ "/"
+ df.loc[inscrits_ids, "nb_inscrits"].astype(int).astype(str)
)
# Les stat (des inscrits)
df.loc[inscrits_ids, "min"] = notes.min()
df.loc[inscrits_ids, "max"] = notes.max()
df.loc[inscrits_ids, "moy"] = notes.mean()
return df
def get_df_synthese(self, with_min_max_moy=None):
"""Renvoie le df de synthese limité aux colonnes de synthese"""
colonnes_synthese = Moyenne.get_colonnes_synthese(
with_min_max_moy=with_min_max_moy
)
df = self.df[colonnes_synthese].copy()
df["rang"] = df["rang"].replace("nan", "")
return df
def to_dict(self) -> dict:
"""Renvoie un dictionnaire de synthèse des moyennes/classements/statistiques générale (but)"""
synthese = {
"notes": self.df["note"],
"classements": self.df["classement"],
"min": self.df["min"].mean(),
"max": self.df["max"].mean(),
"moy": self.df["moy"].mean(),
"nb_inscrits": self.df["nb_inscrits"].mean(),
}
return synthese
def is_significatif(self) -> bool:
"""Indique si la moyenne est significative (c'est-à-dire à des notes)"""
return self.synthese["nb_inscrits"] > 0

169
app/pe/moys/pe_moytag.py Normal file
View File

@ -0,0 +1,169 @@
import numpy as np
import pandas as pd
from app import comp
from app.comp.moy_sem import comp_ranks_series
from app.pe.moys import pe_moy
from app.scodoc.sco_utils import ModuleType
CODE_MOY_UE = "UEs"
CODE_MOY_COMPETENCES = "Compétences"
CHAMP_GENERAL = "Général" # Nom du champ de la moyenne générale
class MoyennesTag:
def __init__(
self,
tag: str,
type_moyenne: str,
matrice_notes_gen: pd.DataFrame, # etudids x colonnes
matrice_coeffs: pd.DataFrame, # etudids x colonnes
):
"""Classe centralisant la synthèse des moyennes/classements d'une série
d'étudiants à un tag donné, en différenciant les notes
obtenues aux UE et au général (toutes UEs confondues)
Args:
tag: Un tag
matrice_notes_gen: Les moyennes (etudid x acronymes_ues ou etudid x compétences)
aux différentes UEs ou compétences
# notes_gen: Une série de notes (moyenne) sous forme d'un ``pd.Series`` (toutes UEs confondues)
"""
self.tag = tag
"""Le tag associé aux moyennes"""
self.type = type_moyenne
"""Le type de moyennes (par UEs ou par compétences)"""
# Les moyennes par UE/compétences (ressources/SAEs confondues)
self.matrice_notes_gen: pd.DataFrame = matrice_notes_gen
"""Les notes par UEs ou Compétences (DataFrame)"""
self.matrice_coeffs_moy_gen: pd.DataFrame = matrice_coeffs
"""Les coeffs à appliquer pour le calcul des moyennes générales
(toutes UE ou compétences confondues). NaN si étudiant non inscrit"""
self.moyennes_gen: dict[int, pd.DataFrame] = {}
"""Dataframes retraçant les moyennes/classements/statistiques des étudiants aux UEs"""
self.etudids = self.matrice_notes_gen.index
"""Les étudids renseignés dans les moyennes"""
self.champs = self.matrice_notes_gen.columns
"""Les champs (acronymes d'UE ou compétences) renseignés dans les moyennes"""
for col in self.champs: # if ue.type != UE_SPORT:
# Les moyennes tous modules confondus
notes = matrice_notes_gen[col]
self.moyennes_gen[col] = pe_moy.Moyenne(notes)
# Les moyennes générales (toutes UEs confondues)
self.notes_gen = pd.Series(np.nan, index=self.matrice_notes_gen.index)
if self.has_notes():
self.notes_gen = self.compute_moy_gen(
self.matrice_notes_gen, self.matrice_coeffs_moy_gen
)
self.moyenne_gen = pe_moy.Moyenne(self.notes_gen)
"""Dataframe retraçant les moyennes/classements/statistiques général (toutes UESs confondues et modules confondus)"""
def has_notes(self):
"""Détermine si les moyennes (aux UEs ou aux compétences)
ont des notes
Returns:
True si la moytag a des notes, False sinon
"""
notes = self.matrice_notes_gen
nbre_nan = notes.isna().sum().sum()
nbre_notes_potentielles = len(notes.index) * len(notes.columns)
if nbre_nan == nbre_notes_potentielles:
return False
else:
return True
def compute_moy_gen(self, moys: pd.DataFrame, coeffs: pd.DataFrame) -> pd.Series:
"""Calcule la moyenne générale (toutes UE/compétences confondus)
pour le tag considéré, en pondérant les notes obtenues au UE
par les coeff (généralement les crédits ECTS).
Args:
moys: Les moyennes etudids x acronymes_ues/compétences
coeff: Les coeff etudids x ueids/compétences
"""
# Calcule la moyenne générale dans le semestre (pondérée par le ECTS)
try:
moy_gen_tag = comp.moy_sem.compute_sem_moys_apc_using_ects(
moys,
coeffs.fillna(0.0),
# formation_id=self.formsemestre.formation_id,
skip_empty_ues=True,
)
except TypeError as e:
raise TypeError(
"Pb dans le calcul de la moyenne toutes UEs/compétences confondues"
)
return moy_gen_tag
def to_df(
self, aggregat=None, cohorte=None, options={"min_max_moy": True}
) -> pd.DataFrame:
"""Renvoie le df synthétisant l'ensemble des données
connues
Adapte les intitulés des colonnes aux données fournies
(nom d'aggrégat, type de cohorte).
"""
if "min_max_moy" not in options or options["min_max_moy"]:
with_min_max_moy = True
else:
with_min_max_moy = False
etudids_sorted = sorted(self.etudids)
df = pd.DataFrame(index=etudids_sorted)
# Ajout des notes pour tous les champs
champs = list(self.champs)
for champ in champs:
df_champ = self.moyennes_gen[champ].get_df_synthese(
with_min_max_moy=with_min_max_moy
) # le dataframe
# Renomme les colonnes
cols = [
get_colonne_df(aggregat, self.tag, champ, cohorte, critere)
for critere in pe_moy.Moyenne.get_colonnes_synthese(
with_min_max_moy=with_min_max_moy
)
]
df_champ.columns = cols
df = df.join(df_champ)
# Ajoute la moy générale
df_moy_gen = self.moyenne_gen.get_df_synthese(with_min_max_moy=with_min_max_moy)
cols = [
get_colonne_df(aggregat, self.tag, CHAMP_GENERAL, cohorte, critere)
for critere in pe_moy.Moyenne.get_colonnes_synthese(
with_min_max_moy=with_min_max_moy
)
]
df_moy_gen.columns = cols
df = df.join(df_moy_gen)
return df
def get_colonne_df(aggregat, tag, champ, cohorte, critere):
"""Renvoie le tuple (aggregat, tag, champ, cohorte, critere)
utilisé pour désigner les colonnes du df"""
liste_champs = []
if aggregat != None:
liste_champs += [aggregat]
liste_champs += [tag, champ]
if cohorte != None:
liste_champs += [cohorte]
liste_champs += [critere]
return "|".join(liste_champs)

466
app/pe/moys/pe_rcstag.py Normal file
View File

@ -0,0 +1,466 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
from app.models import FormSemestre
from app.pe import pe_affichage
import pandas as pd
import numpy as np
from app.pe.rcss import pe_rcs, pe_rcsemx
import app.pe.moys.pe_sxtag as pe_sxtag
import app.pe.pe_comp as pe_comp
from app.pe.moys import pe_tabletags, pe_moytag
from app.scodoc.sco_utils import ModuleType
class RCSemXTag(pe_tabletags.TableTag):
def __init__(
self,
rcsemx: pe_rcsemx.RCSemX,
sxstags: dict[(str, int) : pe_sxtag.SxTag],
semXs_suivis: dict[int, dict],
):
"""Calcule les moyennes par tag (orientées compétences)
d'un regroupement de SxTag
(RCRCF), pour extraire les classements par tag pour un
groupe d'étudiants donnés. Le groupe d'étudiants est formé par ceux ayant tous
participé au même semestre terminal.
Args:
rcsemx: Le RCSemX (identifié par un nom et l'id de son semestre terminal)
sxstags: Les données sur les SemX taggués
semXs_suivis: Les données indiquant quels SXTags sont à prendre en compte
pour chaque étudiant
"""
pe_tabletags.TableTag.__init__(self)
self.rcs_id: tuple(str, int) = rcsemx.rcs_id
"""Identifiant du RCSemXTag (identique au RCSemX sur lequel il s'appuie)"""
self.rcsemx: pe_rcsemx.RCSemX = rcsemx
"""Le regroupement RCSemX associé au RCSemXTag"""
self.semXs_suivis = semXs_suivis
"""Les semXs suivis par les étudiants"""
self.nom = self.get_repr()
"""Représentation textuelle du RSCtag"""
# Les données du semestre final
self.formsemestre_final: FormSemestre = rcsemx.formsemestre_final
"""Le semestre final"""
self.fid_final: int = rcsemx.formsemestre_final.formsemestre_id
"""Le fid du semestre final"""
# Affichage pour debug
pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}")
# Les données aggrégés (RCRCF + SxTags)
self.semXs_aggreges: dict[(str, int) : pe_rcsemx.RCSemX] = rcsemx.semXs_aggreges
"""Les SemX aggrégés"""
self.sxstags_aggreges = {}
"""Les SxTag associés aux SemX aggrégés"""
try:
for rcf_id in self.semXs_aggreges:
self.sxstags_aggreges[rcf_id] = sxstags[rcf_id]
except:
raise ValueError("Semestres SxTag manquants")
self.sxtags_connus = sxstags # Tous les sxstags connus
# Les étudiants (etuds, états civils & etudis)
sems_dans_aggregat = rcsemx.aggregat
sxtag_final = self.sxstags_aggreges[(sems_dans_aggregat[-1], self.rcs_id[1])]
self.etuds = sxtag_final.etuds
"""Les étudiants (extraits du semestre final)"""
self.add_etuds(self.etuds)
self.etudids_sorted = sorted(self.etudids)
"""Les étudids triés"""
# Les compétences (extraites de tous les Sxtags)
self.acronymes_ues_to_competences = self._do_acronymes_to_competences()
"""L'association acronyme d'UEs -> compétence (extraites des SxTag aggrégés)"""
self.competences_sorted = sorted(
set(self.acronymes_ues_to_competences.values())
)
"""Compétences (triées par nom, extraites des SxTag aggrégés)"""
aff = pe_affichage.repr_comp_et_ues(self.acronymes_ues_to_competences)
pe_affichage.pe_print(f"--> Compétences : {', '.join(self.competences_sorted)}")
# Les tags
self.tags_sorted = self._do_taglist()
"""Tags extraits de tous les SxTag aggrégés"""
aff_tag = ["👜" + tag for tag in self.tags_sorted]
pe_affichage.pe_print(f"--> Tags : {', '.join(aff_tag)}")
# Les moyennes
self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {}
"""Synthétise les moyennes/classements par tag (qu'ils soient personnalisé ou de compétences)"""
for tag in self.tags_sorted:
pe_affichage.pe_print(f"--> Moyennes du tag 👜{tag}")
# Traitement des inscriptions aux semX(tags)
# ******************************************
# Cube d'inscription (etudids_sorted x compétences_sorted x sxstags)
# indiquant quel sxtag est valide pour chaque étudiant
inscr_df, inscr_cube = self.compute_inscriptions_comps_cube(tag)
# Traitement des notes
# ********************
# Cube de notes (etudids_sorted x compétences_sorted x sxstags)
notes_df, notes_cube = self.compute_notes_comps_cube(tag)
# Calcule les moyennes sous forme d'un dataframe en les "aggrégant"
# compétence par compétence
moys_competences = self.compute_notes_competences(notes_cube, inscr_cube)
# Traitement des coeffs pour la moyenne générale
# ***********************************************
# Df des coeffs sur tous les SxTags aggrégés
coeffs_df, coeffs_cube = self.compute_coeffs_comps_cube(tag)
# Synthèse des coefficients à prendre en compte pour la moyenne générale
matrice_coeffs_moy_gen = self.compute_coeffs_competences(
coeffs_cube, inscr_cube, notes_cube
)
# Affichage des coeffs
aff = pe_affichage.repr_profil_coeffs(
matrice_coeffs_moy_gen, with_index=True
)
pe_affichage.pe_print(f" > Moyenne calculée avec pour coeffs : {aff}")
# Mémorise les moyennes et les coeff associés
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
tag,
pe_moytag.CODE_MOY_COMPETENCES,
moys_competences,
matrice_coeffs_moy_gen,
)
def __eq__(self, other):
"""Egalité de 2 RCS taggués sur la base de leur identifiant"""
return self.rcs_id == other.sxtag_id
def get_repr(self, verbose=True) -> str:
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
est basée)"""
if verbose:
return f"{self.__class__.__name__} basé sur " + self.rcsemx.get_repr(
verbose=verbose
)
else:
return f"{self.__class__.__name__} {self.rcs_id}"
def compute_notes_comps_cube(self, tag):
"""Pour un tag donné, construit le cube de notes (etudid x competences x SxTag)
nécessaire au calcul des moyennes,
en remplaçant les données d'UE (obtenus du SxTag) par les compétences
Args:
tag: Le tag visé
"""
# etudids_sorted: list[int],
# competences_sorted: list[str],
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
notes_dfs = {}
for sxtag_id, sxtag in self.sxstags_aggreges.items():
# Partant d'un dataframe vierge
notes_df = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
)
# Charge les notes du semestre tag (copie car changement de nom de colonnes à venir)
if tag in sxtag.moyennes_tags: # si le tag est présent dans le semestre
moys_tag = sxtag.moyennes_tags[tag]
notes = moys_tag.matrice_notes_gen.copy() # dataframe etudids x ues
# Traduction des acronymes d'UE en compétences
acronymes_ues_columns = notes.columns
acronymes_to_comps = [
self.acronymes_ues_to_competences[acro]
for acro in acronymes_ues_columns
]
notes.columns = acronymes_to_comps
# Les étudiants et les compétences communes
(
etudids_communs,
comp_communes,
) = pe_comp.find_index_and_columns_communs(notes_df, notes)
# Recopie des notes et des coeffs
notes_df.loc[etudids_communs, comp_communes] = notes.loc[
etudids_communs, comp_communes
]
# Supprime tout ce qui n'est pas numérique
# for col in notes_df.columns:
# notes_df[col] = pd.to_numeric(notes_df[col], errors="coerce")
# Stocke les dfs
notes_dfs[sxtag_id] = notes_df
"""Réunit les notes sous forme d'un cube etudids x competences x semestres"""
sxtag_x_etudids_x_comps = [
notes_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
]
notes_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1)
return notes_dfs, notes_etudids_x_comps_x_sxtag
def compute_coeffs_comps_cube(self, tag):
"""Pour un tag donné, construit
le cube de coeffs (etudid x competences x SxTag) (traduisant les inscriptions
des étudiants aux UEs en fonction de leur parcours)
qui s'applique aux différents SxTag
en remplaçant les données d'UE (obtenus du SxTag) par les compétences
Args:
tag: Le tag visé
"""
# etudids_sorted: list[int],
# competences_sorted: list[str],
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
coeffs_dfs = {}
for sxtag_id, sxtag in self.sxstags_aggreges.items():
# Partant d'un dataframe vierge
coeffs_df = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
)
if tag in sxtag.moyennes_tags:
moys_tag = sxtag.moyennes_tags[tag]
# Charge les notes et les coeffs du semestre tag
coeffs = moys_tag.matrice_coeffs_moy_gen.copy() # les coeffs
# Traduction des acronymes d'UE en compétences
acronymes_ues_columns = coeffs.columns
acronymes_to_comps = [
self.acronymes_ues_to_competences[acro]
for acro in acronymes_ues_columns
]
coeffs.columns = acronymes_to_comps
# Les étudiants et les compétences communes
etudids_communs, comp_communes = pe_comp.find_index_and_columns_communs(
coeffs_df, coeffs
)
# Recopie des notes et des coeffs
coeffs_df.loc[etudids_communs, comp_communes] = coeffs.loc[
etudids_communs, comp_communes
]
# Stocke les dfs
coeffs_dfs[sxtag_id] = coeffs_df
"""Réunit les coeffs sous forme d'un cube etudids x competences x semestres"""
sxtag_x_etudids_x_comps = [
coeffs_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
]
coeffs_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1)
return coeffs_dfs, coeffs_etudids_x_comps_x_sxtag
def compute_inscriptions_comps_cube(
self,
tag,
):
"""Pour un tag donné, construit
le cube etudid x competences x SxTag traduisant quels sxtags est à prendre
en compte pour chaque étudiant.
Contient des 0 et des 1 pour indiquer la prise en compte.
Args:
tag: Le tag visé
"""
# etudids_sorted: list[int],
# competences_sorted: list[str],
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
# Initialisation
inscriptions_dfs = {}
for sxtag_id, sxtag in self.sxstags_aggreges.items():
# Partant d'un dataframe vierge
inscription_df = pd.DataFrame(
0, index=self.etudids_sorted, columns=self.competences_sorted
)
# Les étudiants dont les résultats au sxtag ont été calculés
etudids_sxtag = sxtag.etudids_sorted
# Les étudiants communs
etudids_communs = sorted(set(self.etudids_sorted) & set(etudids_sxtag))
# Acte l'inscription
inscription_df.loc[etudids_communs, :] = 1
# Stocke les dfs
inscriptions_dfs[sxtag_id] = inscription_df
"""Réunit les inscriptions sous forme d'un cube etudids x competences x semestres"""
sxtag_x_etudids_x_comps = [
inscriptions_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
]
inscriptions_etudids_x_comps_x_sxtag = np.stack(
sxtag_x_etudids_x_comps, axis=-1
)
return inscriptions_dfs, inscriptions_etudids_x_comps_x_sxtag
def _do_taglist(self) -> list[str]:
"""Synthétise les tags à partir des Sxtags aggrégés.
Returns:
Liste de tags triés par ordre alphabétique
"""
tags = []
for frmsem_id in self.sxstags_aggreges:
tags.extend(self.sxstags_aggreges[frmsem_id].tags_sorted)
return sorted(set(tags))
def _do_acronymes_to_competences(self) -> dict[str:str]:
"""Synthétise l'association complète {acronyme_ue: competences}
extraite de toutes les données/associations des SxTags
aggrégés.
Returns:
Un dictionnaire {'acronyme_ue' : 'compétences'}
"""
dict_competences = {}
for sxtag_id, sxtag in self.sxstags_aggreges.items():
dict_competences |= sxtag.acronymes_ues_to_competences
return dict_competences
def compute_notes_competences(self, set_cube: np.array, inscriptions: np.array):
"""Calcule la moyenne par compétences (à un tag donné) sur plusieurs semestres (partant du set_cube).
La moyenne est un nombre (note/20), ou NaN si pas de notes disponibles
*Remarque* : Adaptation de moy_ue.compute_ue_moys_apc au cas des moyennes de tag
par aggrégat de plusieurs semestres.
Args:
set_cube: notes moyennes aux compétences ndarray
(etuds x UEs|compétences x sxtags), des floats avec des NaN
inscriptions: inscrptions aux compétences ndarray
(etuds x UEs|compétences x sxtags), des 0 et des 1
Returns:
Un DataFrame avec pour columns les moyennes par tags,
et pour rows les etudid
"""
# etudids_sorted: liste des étudiants (dim. 0 du cube)
# competences_sorted: list (dim. 1 du cube)
nb_etuds, nb_comps, nb_semestres = set_cube.shape
# assert nb_etuds == len(etudids_sorted)
# assert nb_comps == len(competences_sorted)
# Applique le masque d'inscriptions
set_cube_significatif = set_cube * inscriptions
# Quelles entrées du cube contiennent des notes ?
mask = ~np.isnan(set_cube_significatif)
# Enlève les NaN du cube de notes pour les entrées manquantes
set_cube_no_nan = np.nan_to_num(set_cube_significatif, nan=0.0)
# Les moyennes par tag
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etud_moy_tag = np.sum(set_cube_no_nan, axis=2) / np.sum(mask, axis=2)
# Le dataFrame des notes moyennes
etud_moy_tag_df = pd.DataFrame(
etud_moy_tag,
index=self.etudids_sorted, # les etudids
columns=self.competences_sorted, # les competences
)
etud_moy_tag_df.fillna(np.nan)
return etud_moy_tag_df
def compute_coeffs_competences(
self,
coeff_cube: np.array,
inscriptions: np.array,
set_cube: np.array,
):
"""Calcule les coeffs à utiliser pour la moyenne générale (toutes compétences
confondues), en fonction des inscriptions.
Args:
coeffs_cube: coeffs impliqués dans la moyenne générale (semestres par semestres)
inscriptions: inscriptions aux UES|Compétences ndarray
(etuds x UEs|compétences x sxtags), des 0 ou des 1
set_cube: les notes
Returns:
Un DataFrame de coefficients (etudids_sorted x compétences_sorted)
"""
# etudids_sorted: liste des étudiants (dim. 0 du cube)
# competences_sorted: list (dim. 1 du cube)
nb_etuds, nb_comps, nb_semestres = inscriptions.shape
# assert nb_etuds == len(etudids_sorted)
# assert nb_comps == len(competences_sorted)
# Applique le masque des inscriptions aux coeffs et aux notes
coeffs_significatifs = coeff_cube * inscriptions
# Enlève les NaN du cube de notes pour les entrées manquantes
coeffs_cube_no_nan = np.nan_to_num(coeffs_significatifs, nan=0.0)
# Quelles entrées du cube contiennent des notes ?
mask = ~np.isnan(set_cube)
# Retire les coefficients associés à des données sans notes
coeffs_cube_no_nan = coeffs_cube_no_nan * mask
# Somme les coefficients (correspondant à des notes)
coeff_tag = np.sum(coeffs_cube_no_nan, axis=2)
# Le dataFrame des coeffs
coeffs_df = pd.DataFrame(
coeff_tag, index=self.etudids_sorted, columns=self.competences_sorted
)
# Remet à Nan les coeffs à 0
coeffs_df = coeffs_df.fillna(np.nan)
return coeffs_df

476
app/pe/moys/pe_ressemtag.py Normal file
View File

@ -0,0 +1,476 @@
# -*- pole: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Generfal Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
import pandas as pd
from app import ScoValueError
from app import comp
from app.comp.res_but import ResultatsSemestreBUT
from app.models import FormSemestre, UniteEns
import app.pe.pe_affichage as pe_affichage
import app.pe.pe_etudiant as pe_etudiant
from app.pe.moys import pe_tabletags, pe_moytag
from app.scodoc import sco_tag_module
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.sco_utils import *
class ResSemBUTTag(ResultatsSemestreBUT, pe_tabletags.TableTag):
"""
Un ResSemBUTTag représente les résultats des étudiants à un semestre, en donnant
accès aux moyennes par tag.
Il s'appuie principalement sur un ResultatsSemestreBUT.
"""
def __init__(
self,
formsemestre: FormSemestre,
options={"moyennes_tags": True, "moyennes_ue_res_sae": False},
):
"""
Args:
formsemestre: le ``FormSemestre`` sur lequel il se base
options: Un dictionnaire d'options
"""
ResultatsSemestreBUT.__init__(self, formsemestre)
pe_tabletags.TableTag.__init__(self)
# Le nom du res_semestre taggué
self.nom = self.get_repr(verbose=True)
# Les étudiants (etuds, états civils & etudis) ajouté
self.add_etuds(self.etuds)
self.etudids_sorted = sorted(self.etudids)
"""Les etudids des étudiants du ResultatsSemestreBUT triés"""
pe_affichage.pe_print(
f"*** ResSemBUTTag du {self.nom} => {len(self.etudids_sorted)} étudiants"
)
# Les UEs (et les dispenses d'UE)
self.ues_standards: list[UniteEns] = [
ue for ue in self.ues if ue.type == sco_codes.UE_STANDARD
]
"""Liste des UEs standards du ResultatsSemestreBUT"""
# Les parcours des étudiants à ce semestre
self.parcours = []
"""Parcours auxquels sont inscrits les étudiants"""
for etudid in self.etudids_sorted:
parcour = self.formsemestre.etuds_inscriptions[etudid].parcour
if parcour:
self.parcours += [parcour.libelle]
else:
self.parcours += [None]
# Les UEs en fonction des parcours
self.ues_inscr_parcours_df = self.load_ues_inscr_parcours()
"""Inscription des étudiants aux UEs des parcours"""
# Les acronymes des UEs
self.ues_to_acronymes = {ue.id: ue.acronyme for ue in self.ues_standards}
self.acronymes_sorted = sorted(self.ues_to_acronymes.values())
"""Les acronymes de UE triés par ordre alphabétique"""
# Les compétences associées aux UEs (définies par les acronymes)
self.acronymes_ues_to_competences = {}
"""Association acronyme d'UEs -> compétence"""
for ue in self.ues_standards:
assert ue.niveau_competence, ScoValueError(
"Des UEs ne sont pas rattachées à des compétences"
)
nom = ue.niveau_competence.competence.titre
self.acronymes_ues_to_competences[ue.acronyme] = nom
self.competences_sorted = sorted(
list(set(self.acronymes_ues_to_competences.values()))
)
"""Compétences triées par nom"""
aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences)
pe_affichage.pe_print(f"--> UEs/Compétences : {aff}")
# Les tags personnalisés et auto:
if "moyennes_tags" in options:
tags_dict = self._get_tags_dict(avec_moyennes_tags=options["moyennes_tags"])
else:
tags_dict = self._get_tags_dict()
pe_affichage.pe_print(
f"""--> {pe_affichage.aff_tags_par_categories(tags_dict)}"""
)
self._check_tags(tags_dict)
# Les coefficients pour le calcul de la moyenne générale, donnés par
# acronymes d'UE
self.matrice_coeffs_moy_gen = self._get_matrice_coeffs(
self.ues_inscr_parcours_df, self.ues_standards
)
"""DataFrame indiquant les coeffs des UEs par ordre alphabétique d'acronyme"""
profils_aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen)
pe_affichage.pe_print(
f"--> Moyenne générale calculée avec pour coeffs d'UEs : {profils_aff}"
)
# Les capitalisations (mask etuids x acronyme_ue valant True si capitalisée, False sinon)
self.capitalisations = self._get_capitalisations(self.ues_standards)
"""DataFrame indiquant les UEs capitalisables d'un étudiant (etudids x )"""
# Calcul des moyennes & les classements de chaque étudiant à chaque tag
self.moyennes_tags = {}
"""Moyennes par tags (personnalisés ou 'but')"""
for tag in tags_dict["personnalises"]:
# pe_affichage.pe_print(f" -> Traitement du tag {tag}")
info_tag = tags_dict["personnalises"][tag]
# Les moyennes générales par UEs
moy_ues_tag = self.compute_moy_ues_tag(
self.ues_inscr_parcours_df, info_tag=info_tag, pole=None
)
# Mémorise les moyennes
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
tag,
pe_moytag.CODE_MOY_UE,
moy_ues_tag,
self.matrice_coeffs_moy_gen,
)
# Ajoute les moyennes par UEs + la moyenne générale (but)
moy_gen = self.compute_moy_gen()
self.moyennes_tags["but"] = pe_moytag.MoyennesTag(
"but",
pe_moytag.CODE_MOY_UE,
moy_gen,
self.matrice_coeffs_moy_gen,
)
# Ajoute la moyenne générale par ressources
if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]:
moy_res_gen = self.compute_moy_ues_tag(
self.ues_inscr_parcours_df, info_tag=None, pole=ModuleType.RESSOURCE
)
self.moyennes_tags["ressources"] = pe_moytag.MoyennesTag(
"ressources",
pe_moytag.CODE_MOY_UE,
moy_res_gen,
self.matrice_coeffs_moy_gen,
)
# Ajoute la moyenne générale par saes
if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]:
moy_saes_gen = self.compute_moy_ues_tag(
self.ues_inscr_parcours_df, info_tag=None, pole=ModuleType.SAE
)
self.moyennes_tags["saes"] = pe_moytag.MoyennesTag(
"saes",
pe_moytag.CODE_MOY_UE,
moy_saes_gen,
self.matrice_coeffs_moy_gen,
)
# Tous les tags
self.tags_sorted = self.get_all_significant_tags()
"""Tags (personnalisés+compétences) par ordre alphabétique"""
def get_repr(self, verbose=False) -> str:
"""Nom affiché pour le semestre taggué, de la forme (par ex.):
* S1#69 si verbose est False
* S1 FI 2023 si verbose est True
"""
if not verbose:
return f"{self.formsemestre}#{self.formsemestre.formsemestre_id}"
else:
return pe_etudiant.nom_semestre_etape(self.formsemestre, avec_fid=True)
def _get_matrice_coeffs(
self, ues_inscr_parcours_df: pd.DataFrame, ues_standards: list[UniteEns]
) -> pd.DataFrame:
"""Renvoie un dataFrame donnant les coefficients à appliquer aux UEs
dans le calcul de la moyenne générale (toutes UEs confondues).
Prend en compte l'inscription des étudiants aux UEs en fonction de leur parcours
(cf. ues_inscr_parcours_df).
Args:
ues_inscr_parcours_df: Les inscriptions des étudiants aux UEs
ues_standards: Les UEs standards à prendre en compte
Returns:
Un dataFrame etudids x acronymes_UEs avec les coeffs des UEs
"""
matrice_coeffs_moy_gen = ues_inscr_parcours_df * [
ue.ects for ue in ues_standards # if ue.type != UE_SPORT <= déjà supprimé
]
matrice_coeffs_moy_gen.columns = [
self.ues_to_acronymes[ue.id] for ue in ues_standards
]
# Tri par etudids (dim 0) et par acronymes (dim 1)
matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index()
matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index(axis=1)
return matrice_coeffs_moy_gen
def _get_capitalisations(self, ues_standards) -> pd.DataFrame:
"""Renvoie un dataFrame résumant les UEs capitalisables par les
étudiants, d'après les décisions de jury (sous réserve qu'elles existent).
Args:
ues_standards: Liste des UEs standards (notamment autres que le sport)
Returns:
Un dataFrame etudids x acronymes_UEs dont les valeurs sont ``True`` si l'UE
est capitalisable, ``False`` sinon
"""
capitalisations = pd.DataFrame(
False, index=self.etudids_sorted, columns=self.acronymes_sorted
)
self.get_formsemestre_validations() # charge les validations
res_jury = self.validations
if res_jury:
for etud in self.etuds:
etudid = etud.etudid
decisions = res_jury.decisions_jury_ues.get(etudid, {})
for ue in ues_standards:
if ue.id in decisions and decisions[ue.id]["code"] == sco_codes.ADM:
capitalisations.loc[etudid, ue.acronyme] = True
# Tri par etudis et par accronyme d'UE
capitalisations = capitalisations.sort_index()
capitalisations = capitalisations.sort_index(axis=1)
return capitalisations
def compute_moy_ues_tag(
self,
ues_inscr_parcours_df: pd.DataFrame,
info_tag: dict[int, dict] = None,
pole=None,
) -> pd.DataFrame:
"""Calcule la moyenne par UE des étudiants pour un tag donné,
en ayant connaissance des informations sur le tag et des inscriptions des étudiants aux différentes UEs.
info_tag détermine les modules pris en compte :
* si non `None`, seuls les modules rattachés au tag sont pris en compte
* si `None`, tous les modules (quelque soit leur rattachement au tag) sont pris
en compte (sert au calcul de la moyenne générale par ressource ou SAE)
ues_inscr_parcours_df détermine les UEs pour lesquels le calcul d'une moyenne à un sens.
`pole` détermine les modules pris en compte :
* si `pole` vaut `ModuleType.RESSOURCE`, seules les ressources sont prises
en compte (moyenne de ressources par UEs)
* si `pole` vaut `ModuleType.SAE`, seules les SAEs sont prises en compte
* si `pole` vaut `None` (ou toute autre valeur),
tous les modules sont pris en compte (moyenne d'UEs)
Les informations sur le tag sont un dictionnaire listant les modimpl_id rattachés au tag,
et pour chacun leur éventuel coefficient de **repondération**.
Args:
ues_inscr_parcours_df: L'inscription aux UEs
Returns:
Le dataframe des moyennes du tag par UE
"""
modimpls_sorted = self.formsemestre.modimpls_sorted
# Adaptation du mask de calcul des moyennes au tag visé
modimpls_mask = []
for modimpl in modimpls_sorted:
module = modimpl.module # Le module
mask = module.ue.type == sco_codes.UE_STANDARD # Est-ce une UE stantard ?
if pole == ModuleType.RESSOURCE:
mask &= module.module_type == ModuleType.RESSOURCE
elif pole == ModuleType.SAE:
mask &= module.module_type == ModuleType.SAE
modimpls_mask += [mask]
# Prise en compte du tag
if info_tag:
# Désactive tous les modules qui ne sont pas pris en compte pour ce tag
for i, modimpl in enumerate(modimpls_sorted):
if modimpl.moduleimpl_id not in info_tag:
modimpls_mask[i] = False
# Applique la pondération des coefficients
modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy()
if info_tag:
for modimpl_id in info_tag:
ponderation = info_tag[modimpl_id]["ponderation"]
modimpl_coefs_ponderes_df[modimpl_id] *= ponderation
# Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)
moyennes_ues_tag = comp.moy_ue.compute_ue_moys_apc(
self.sem_cube,
self.etuds,
self.formsemestre.modimpls_sorted,
self.modimpl_inscr_df,
modimpl_coefs_ponderes_df,
modimpls_mask,
self.dispense_ues,
block=self.formsemestre.block_moyennes,
)
# Ne conserve que les UEs standards
colonnes = [ue.id for ue in self.ues_standards]
moyennes_ues_tag = moyennes_ues_tag[colonnes]
# Applique le masque d'inscription aux UE pour ne conserver que les UE dans lequel l'étudiant est inscrit
moyennes_ues_tag = moyennes_ues_tag[colonnes] * ues_inscr_parcours_df[colonnes]
# Transforme les UEs en acronyme
acronymes = [self.ues_to_acronymes[ue.id] for ue in self.ues_standards]
moyennes_ues_tag.columns = acronymes
# Tri par etudids et par ordre alphabétique d'acronyme
moyennes_ues_tag = moyennes_ues_tag.sort_index()
moyennes_ues_tag = moyennes_ues_tag.sort_index(axis=1)
return moyennes_ues_tag
def compute_moy_gen(self):
"""Récupère les moyennes des UEs pour le calcul de la moyenne générale,
en associant à chaque UE.id son acronyme (toutes UEs confondues)
"""
df_ues = pd.DataFrame(
{ue.id: self.etud_moy_ue[ue.id] for ue in self.ues_standards},
index=self.etudids,
)
# Transforme les UEs en acronyme
colonnes = df_ues.columns
acronymes = [self.ues_to_acronymes[col] for col in colonnes]
df_ues.columns = acronymes
# Tri par ordre aphabétique de colonnes
df_ues.sort_index(axis=1)
return df_ues
def _get_tags_dict(self, avec_moyennes_tags=True):
"""Renvoie les tags personnalisés (déduits des modules du semestre)
et les tags automatiques ('but'), et toutes leurs informations,
dans un dictionnaire de la forme :
``{"personnalises": {tag: info_sur_le_tag},
"auto": {tag: {}}``
Returns:
Le dictionnaire structuré des tags ("personnalises" vs. "auto")
"""
dict_tags = {"personnalises": dict(), "auto": dict()}
if avec_moyennes_tags:
# Les tags perso (seulement si l'option d'utiliser les tags perso est choisie)
dict_tags["personnalises"] = get_synthese_tags_personnalises_semestre(
self.formsemestre
)
# Les tags automatiques
# Déduit des compétences
# dict_ues_competences = get_noms_competences_from_ues(self.nt.formsemestre)
# noms_tags_comp = list(set(dict_ues_competences.values()))
# BUT
dict_tags["auto"] = {"but": {}, "ressources": {}, "saes": {}}
return dict_tags
def _check_tags(self, dict_tags):
"""Vérifie l'unicité des tags"""
noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys())))
noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp
noms_tags = noms_tags_perso + noms_tags_auto
intersection = list(set(noms_tags_perso) & set(noms_tags_auto))
if intersection:
liste_intersection = "\n".join(
[f"<li><code>{tag}</code></li>" for tag in intersection]
)
s = "s" if len(intersection) > 1 else ""
message = f"""Erreur dans le module PE : Un des tags saisis dans votre
programme de formation fait parti des tags réservés. En particulier,
votre semestre <em>{self.formsemestre.titre_annee()}</em>
contient le{s} tag{s} réservé{s} suivant :
<ul>
{liste_intersection}
</ul>
Modifiez votre programme de formation pour le{s} supprimer.
Il{s} ser{'ont' if s else 'a'} automatiquement à vos documents de poursuites d'études.
"""
raise ScoValueError(message)
def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre):
"""Etant données les implémentations des modules du semestre (modimpls),
synthétise les tags renseignés dans le programme pédagogique &
associés aux modules du semestre,
en les associant aux modimpls qui les concernent (modimpl_id) et
au coeff de repondération fournie avec le tag (par défaut 1 si non indiquée)).
Le dictionnaire fournit est de la forme :
``{ tag : { modimplid: {"modimpl": ModImpl,
"ponderation": coeff_de_reponderation}
} }``
Args:
formsemestre: Le formsemestre à la base de la recherche des tags
Return:
Un dictionnaire décrivant les tags
"""
synthese_tags = {}
# Instance des modules du semestre
modimpls = formsemestre.modimpls_sorted
for modimpl in modimpls:
modimpl_id = modimpl.id
# Liste des tags pour le module concerné
tags = sco_tag_module.module_tag_list(modimpl.module.id)
# Traitement des tags recensés, chacun pouvant étant de la forme
# "mathématiques", "théorie", "pe:0", "maths:2"
for tag in tags:
# Extraction du nom du tag et du coeff de pondération
(tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag)
# Ajout d'une clé pour le tag
if tagname not in synthese_tags:
synthese_tags[tagname] = {}
# Ajout du module (modimpl) au tagname considéré
synthese_tags[tagname][modimpl_id] = {
"modimpl": modimpl, # les données sur le module
"ponderation": ponderation, # la pondération demandée pour le tag sur le module
}
return synthese_tags

406
app/pe/moys/pe_sxtag.py Normal file
View File

@ -0,0 +1,406 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
from app.pe import pe_affichage, pe_comp
import app.pe.moys.pe_ressemtag as pe_ressemtag
import pandas as pd
import numpy as np
from app.pe.moys import pe_moytag, pe_tabletags
import app.pe.rcss.pe_trajectoires as pe_trajectoires
from app.scodoc.sco_utils import ModuleType
class SxTag(pe_tabletags.TableTag):
def __init__(
self,
sxtag_id: (str, int),
semx: pe_trajectoires.SemX,
ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag],
):
"""Calcule les moyennes/classements par tag d'un semestre de type 'Sx'
(par ex. 'S1', 'S2', ...) représentés par acronyme d'UE.
Il représente :
* pour les étudiants *non redoublants* : moyennes/classements
du semestre suivi
* pour les étudiants *redoublants* : une fusion des moyennes/classements
dans les (2) 'Sx' qu'il a suivi, en exploitant les informations de capitalisation :
meilleure moyenne entre l'UE capitalisée et l'UE refaite (la notion de meilleure
s'appliquant à la moyenne d'UE)
Un SxTag (regroupant potentiellement plusieurs semestres) est identifié
par un tuple ``(Sx, fid)`` :
* ``x`` est le rang (semestre_id) du semestre
* ``fid`` le formsemestre_id du semestre final (le plus récent) du regroupement.
Les **tags**, les **UE** et les inscriptions aux UEs (pour les étudiants)
considérés sont uniquement ceux du semestre final.
Args:
sxtag_id: L'identifiant de SxTag
ressembuttags: Un dictionnaire de la forme `{fid: ResSemBUTTag(fid)}` donnant
les semestres à regrouper et les résultats/moyennes par tag des
semestres
"""
pe_tabletags.TableTag.__init__(self)
assert sxtag_id and len(sxtag_id) == 2 and sxtag_id[1] in ressembuttags
self.sxtag_id: (str, int) = sxtag_id
"""Identifiant du SxTag de la forme (nom_Sx, fid_semestre_final)"""
assert (
len(self.sxtag_id) == 2
and isinstance(self.sxtag_id[0], str)
and isinstance(self.sxtag_id[1], int)
), "Format de l'identifiant du SxTag non respecté"
self.agregat = sxtag_id[0]
"""Nom de l'aggrégat du RCS"""
self.semx = semx
"""Le SemX sur lequel il s'appuie"""
assert semx.rcs_id == sxtag_id, "Problème de correspondance SxTag/SemX"
# Les resultats des semestres taggués à prendre en compte dans le SemX
self.ressembuttags = {
fid: ressembuttags[fid] for fid in semx.semestres_aggreges
}
"""Les ResSemBUTTags à regrouper dans le SxTag"""
# Les données du semestre final
self.fid_final = sxtag_id[1]
self.ressembuttag_final = ressembuttags[self.fid_final]
"""Le ResSemBUTTag final"""
# Ajoute les etudids et les états civils
self.etuds = self.ressembuttag_final.etuds
"""Les étudiants (extraits du ReSemBUTTag final)"""
self.add_etuds(self.etuds)
self.etudids_sorted = sorted(self.etudids)
"""Les etudids triés"""
# Affichage
pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}")
# Les tags
self.tags_sorted = self.ressembuttag_final.tags_sorted
"""Tags (extraits du ReSemBUTTag final)"""
aff_tag = pe_affichage.repr_tags(self.tags_sorted)
pe_affichage.pe_print(f"--> Tags : {aff_tag}")
# Les UE données par leur acronyme
self.acronymes_sorted = self.ressembuttag_final.acronymes_sorted
"""Les acronymes des UEs (extraits du ResSemBUTTag final)"""
# L'association UE-compétences extraites du dernier semestre
self.acronymes_ues_to_competences = (
self.ressembuttag_final.acronymes_ues_to_competences
)
"""L'association acronyme d'UEs -> compétence"""
self.competences_sorted = sorted(self.acronymes_ues_to_competences.values())
"""Les compétences triées par nom"""
aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences)
pe_affichage.pe_print(f"--> UEs/Compétences : {aff}")
# Les coeffs pour la moyenne générale (traduisant également l'inscription
# des étudiants aux UEs) (etudids_sorted x acronymes_ues_sorted)
self.matrice_coeffs_moy_gen = self.ressembuttag_final.matrice_coeffs_moy_gen
"""La matrice des coeffs pour la moyenne générale"""
aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen)
pe_affichage.pe_print(
f"--> Moyenne générale calculée avec pour coeffs d'UEs : {aff}"
)
# Masque des inscriptions et des capitalisations
self.masque_df = None
"""Le DataFrame traduisant les capitalisations des différents semestres"""
self.masque_df, masque_cube = compute_masques_capitalisation_cube(
self.etudids_sorted,
self.acronymes_sorted,
self.ressembuttags,
self.fid_final,
)
pe_affichage.aff_capitalisations(
self.etuds,
self.ressembuttags,
self.fid_final,
self.acronymes_sorted,
self.masque_df,
)
# Les moyennes par tag
self.moyennes_tags: dict[str, pd.DataFrame] = {}
"""Moyennes aux UEs (identifiées par leur acronyme) des différents tags"""
if self.tags_sorted:
pe_affichage.pe_print("--> Calcul des moyennes par tags :")
for tag in self.tags_sorted:
pe_affichage.pe_print(f" > MoyTag 👜{tag}")
# Masque des inscriptions aux UEs (extraits de la matrice de coefficients)
inscr_mask: np.array = ~np.isnan(self.matrice_coeffs_moy_gen.to_numpy())
# Moyennes (tous modules confondus)
if not self.has_notes_tag(tag):
pe_affichage.pe_print(
f" --> Semestre (final) actuellement sans notes"
)
matrice_moys_ues = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted
)
else:
# Moyennes tous modules confondus
### Cube de note etudids x UEs tous modules confondus
notes_df_gen, notes_cube_gen = self.compute_notes_ues_cube(tag)
# DataFrame des moyennes (tous modules confondus)
matrice_moys_ues = self.compute_notes_ues(
notes_cube_gen, masque_cube, inscr_mask
)
# Mémorise les infos pour la moyenne au tag
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
tag,
pe_moytag.CODE_MOY_UE,
matrice_moys_ues,
self.matrice_coeffs_moy_gen,
)
# Affichage de debug
aff = pe_affichage.repr_profil_coeffs(
self.matrice_coeffs_moy_gen, with_index=True
)
pe_affichage.pe_print(f" > Moyenne générale calculée avec : {aff}")
def has_notes_tag(self, tag):
"""Détermine si le SxTag, pour un tag donné, est en cours d'évaluation.
Si oui, n'a pas (encore) de notes dans le resformsemestre final.
Args:
tag: Le tag visé
Returns:
True si a des notes, False sinon
"""
moy_tag_dernier_sem = self.ressembuttag_final.moyennes_tags[tag]
return moy_tag_dernier_sem.has_notes()
def __eq__(self, other):
"""Egalité de 2 SxTag sur la base de leur identifiant"""
return self.sxtag_id == other.sxtag_id
def get_repr(self, verbose=False) -> str:
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
est basée)"""
if verbose:
return f"SXTag basé sur {self.semx.get_repr()}"
else:
# affichage = [str(fid) for fid in self.ressembuttags]
return f"SXTag {self.agregat}#{self.fid_final}"
def compute_notes_ues_cube(self, tag) -> (pd.DataFrame, np.array):
"""Construit le cube de notes des UEs (etudid x accronyme_ue x semestre_aggregé)
nécessaire au calcul des moyennes du tag pour le RCS Sx.
(Renvoie également le dataframe associé pour debug).
Args:
tag: Le tag considéré (personalisé ou "but")
"""
# Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2)
# etudids_sorted = etudids_sorted
# acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()])
semestres_id = list(self.ressembuttags.keys())
dfs = {}
for frmsem_id in semestres_id:
# Partant d'un dataframe vierge
df = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted
)
# Charge les notes du semestre tag
sem_tag = self.ressembuttags[frmsem_id]
moys_tag = sem_tag.moyennes_tags[tag]
notes = moys_tag.matrice_notes_gen # dataframe etudids x ues
# les étudiants et les acronymes communs
etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs(
df, notes
)
# Recopie
df.loc[etudids_communs, acronymes_communs] = notes.loc[
etudids_communs, acronymes_communs
]
# Supprime tout ce qui n'est pas numérique
for col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
# Stocke le df
dfs[frmsem_id] = df
"""Réunit les notes sous forme d'un cube etudids x ues x semestres"""
semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs]
etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1)
return dfs, etudids_x_ues_x_semestres
def compute_notes_ues(
self,
set_cube: np.array,
masque_cube: np.array,
inscr_mask: np.array,
) -> pd.DataFrame:
"""Calcule la moyenne par UEs à un tag donné en prenant la note maximum (UE
par UE) obtenue par un étudiant à un semestre.
Args:
set_cube: notes moyennes aux modules ndarray
(semestre_ids x etudids x UEs), des floats avec des NaN
masque_cube: masque indiquant si la note doit être prise en compte ndarray
(semestre_ids x etudids x UEs), des 1.0 ou des 0.0
inscr_mask: masque etudids x UE traduisant les inscriptions des
étudiants aux UE (du semestre terminal)
Returns:
Un DataFrame avec pour columns les moyennes par ues,
et pour rows les etudid
"""
# etudids_sorted: liste des étudiants (dim. 0 du cube) trié par etudid
# acronymes_sorted: liste des acronymes des ues (dim. 1 du cube) trié par acronyme
nb_etuds, nb_ues, nb_semestres = set_cube.shape
nb_etuds_mask, nb_ues_mask = inscr_mask.shape
# assert nb_etuds == len(self.etudids_sorted)
# assert nb_ues == len(self.acronymes_sorted)
# assert nb_etuds == nb_etuds_mask
# assert nb_ues == nb_ues_mask
# Entrées à garder dans le cube en fonction du masque d'inscription aux UEs du parcours
inscr_mask_3D = np.stack([inscr_mask] * nb_semestres, axis=-1)
set_cube = set_cube * inscr_mask_3D
# Entrées à garder en fonction des UEs capitalisées ou non
set_cube = set_cube * masque_cube
# Quelles entrées du cube contiennent des notes ?
mask = ~np.isnan(set_cube)
# Enlève les NaN du cube pour les entrées manquantes : NaN -> -1.0
set_cube_no_nan = np.nan_to_num(set_cube, nan=-1.0)
# Les moyennes par ues
# TODO: Pour l'instant un max sans prise en compte des UE capitalisées
etud_moy = np.max(set_cube_no_nan, axis=2)
# Fix les max non calculé -1 -> NaN
etud_moy[etud_moy < 0] = np.NaN
# Le dataFrame
etud_moy_tag_df = pd.DataFrame(
etud_moy,
index=self.etudids_sorted, # les etudids
columns=self.acronymes_sorted, # les acronymes d'UEs
)
etud_moy_tag_df = etud_moy_tag_df.fillna(np.nan)
return etud_moy_tag_df
def compute_masques_capitalisation_cube(
etudids_sorted: list[int],
acronymes_sorted: list[str],
ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag],
formsemestre_id_final: int,
) -> (pd.DataFrame, np.array):
"""Construit le cube traduisant les masques des UEs à prendre en compte dans le calcul
des moyennes, en utilisant le dataFrame de capitalisations de chaque ResSemBUTTag
Ces masques contiennent : 1 si la note doit être prise en compte, 0 sinon
Le masque des UEs à prendre en compte correspondant au semestre final (identifié par
son formsemestre_id_final) est systématiquement à 1 (puisque les résultats
de ce semestre doivent systématiquement
être pris en compte notamment pour les étudiants non redoublant).
Args:
etudids_sorted: La liste des etudids triés par ordre croissant (dim 0)
acronymes_sorted: La liste des acronymes de UEs triés par acronyme croissant (dim 1)
ressembuttags: Le dictionnaire des résultats de semestres BUT (tous tags confondus)
formsemestre_id_final: L'identifiant du formsemestre_id_final
"""
# Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2)
# etudids_sorted = etudids_sorted
# acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()])
semestres_id = list(ressembuttags.keys())
dfs = {}
for frmsem_id in semestres_id:
# Partant d'un dataframe contenant des 1.0
if frmsem_id == formsemestre_id_final:
df = pd.DataFrame(1.0, index=etudids_sorted, columns=acronymes_sorted)
else: # semestres redoublés
df = pd.DataFrame(0.0, index=etudids_sorted, columns=acronymes_sorted)
# Traitement des capitalisations : remplace les infos de capitalisations par les coeff 1 ou 0
capitalisations = ressembuttags[frmsem_id].capitalisations
capitalisations = capitalisations.replace(True, 1.0).replace(False, 0.0)
# Met à 0 les coeffs des UEs non capitalisées pour les étudiants
# inscrits dans les 2 semestres: 1.0*False => 0.0
etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs(
df, capitalisations
)
df.loc[etudids_communs, acronymes_communs] = capitalisations.loc[
etudids_communs, acronymes_communs
]
# Stocke le df
dfs[frmsem_id] = df
"""Réunit les notes sous forme d'un cube etudids x ues x semestres"""
semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs]
etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1)
return dfs, etudids_x_ues_x_semestres

203
app/pe/moys/pe_tabletags.py Normal file
View File

@ -0,0 +1,203 @@
# -*- pole: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Thu Sep 8 09:36:33 2016
@author: barasc
"""
import pandas as pd
from app.models import Identite
from app.pe.moys import pe_moytag
TAGS_RESERVES = ["but"]
CHAMPS_ADMINISTRATIFS = ["Civilité", "Nom", "Prénom"]
class TableTag(object):
def __init__(self):
"""Classe centralisant différentes méthodes communes aux
SemestreTag, TrajectoireTag, AggregatInterclassTag
"""
# Les étudiants
# self.etuds: list[Identite] = None # A venir
"""Les étudiants"""
# self.etudids: list[int] = {}
"""Les etudids"""
def add_etuds(self, etuds: list[Identite]):
"""Mémorise les informations sur les étudiants
Args:
etuds: la liste des identités de l'étudiant
"""
# self.etuds = etuds
self.etudids = list({etud.etudid for etud in etuds})
def get_all_significant_tags(self):
"""Liste des tags de la table, triée par ordre alphabétique,
extraite des clés du dictionnaire ``moyennes_tags``, en ne
considérant que les moyennes ayant des notes.
Returns:
Liste de tags triés par ordre alphabétique
"""
tags = []
tag: str = ""
moytag: pe_moytag.MoyennesTag = None
for tag, moytag in self.moyennes_tags.items():
if moytag.has_notes():
tags.append(tag)
return sorted(tags)
def to_df(
self,
administratif=True,
aggregat=None,
tags_cibles=None,
cohorte=None,
options={"min_max_moy": True},
) -> pd.DataFrame:
"""Renvoie un dataframe listant toutes les données
des moyennes/classements/nb_inscrits/min/max/moy
des étudiants aux différents tags.
tags_cibles limitent le dataframe aux tags indiqués
type_colonnes indiquent si les colonnes doivent être passées en multiindex
Args:
administratif: Indique si les données administratives sont incluses
aggregat: l'aggrégat représenté
tags_cibles: la liste des tags ciblés
cohorte: la cohorte représentée
Returns:
Le dataframe complet de synthèse
"""
# Les tags visés
tags_tries = self.get_all_significant_tags()
if not tags_cibles:
tags_cibles = tags_tries
tags_cibles = sorted(tags_cibles)
# Les tags visés avec des notes
# Les étudiants visés
if administratif:
df = df_administratif(self.etuds, aggregat=aggregat, cohorte=cohorte)
else:
df = pd.DataFrame(index=self.etudids)
if not self.is_significatif():
return df
# Ajout des données par tags
for tag in tags_cibles:
if tag in self.moyennes_tags:
moy_tag_df = self.moyennes_tags[tag].to_df(
aggregat=aggregat, cohorte=cohorte, options=options
)
df = df.join(moy_tag_df)
# Tri par nom, prénom
if administratif:
colonnes_tries = [
_get_champ_administratif(champ, aggregat=aggregat, cohorte=cohorte)
for champ in CHAMPS_ADMINISTRATIFS[1:]
] # Nom + Prénom
df = df.sort_values(by=colonnes_tries)
return df
def has_etuds(self):
"""Indique si un tabletag contient des étudiants"""
return len(self.etuds) > 0
def is_significatif(self):
"""Indique si une tabletag a des données"""
# A des étudiants
if not self.etuds:
return False
# A des tags avec des notes
tags_tries = self.get_all_significant_tags()
if not tags_tries:
return False
return True
def _get_champ_administratif(champ, aggregat=None, cohorte=None):
"""Pour un champ donné, renvoie l'index (ou le multindex)
à intégrer au dataframe"""
liste = []
if aggregat != None:
liste += [aggregat]
liste += ["Administratif", "Identité"]
if cohorte != None:
liste += [champ]
liste += [champ]
return "|".join(liste)
def df_administratif(
etuds: list[Identite], aggregat=None, cohorte=None
) -> pd.DataFrame:
"""Renvoie un dataframe donnant les données administratives
des étudiants du TableTag
Args:
etuds: Identité des étudiants générant les données administratives
"""
identites = {etud.etudid: etud for etud in etuds}
donnees = {}
etud: Identite = None
for etudid, etud in identites.items():
data = {
CHAMPS_ADMINISTRATIFS[0]: etud.civilite_str,
CHAMPS_ADMINISTRATIFS[1]: etud.nom,
CHAMPS_ADMINISTRATIFS[2]: etud.prenom_str,
}
donnees[etudid] = {
_get_champ_administratif(champ, aggregat, cohorte): data[champ]
for champ in CHAMPS_ADMINISTRATIFS
}
colonnes = [
_get_champ_administratif(champ, aggregat, cohorte)
for champ in CHAMPS_ADMINISTRATIFS
]
df = pd.DataFrame.from_dict(donnees, orient="index", columns=colonnes)
df = df.sort_values(by=colonnes[1:])
return df

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

@ -0,0 +1,234 @@
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""Affichages, debug
"""
from flask import g
from app import log
from app.pe.rcss import pe_rcs
PE_DEBUG = False
# On stocke les logs PE dans g.scodoc_pe_log
# pour ne pas modifier les nombreux appels à pe_print.
def pe_start_log() -> list[str]:
"Initialize log"
g.scodoc_pe_log = []
return g.scodoc_pe_log
def pe_print(*a, **cles):
"Log (or print in PE_DEBUG mode) and store in g"
if PE_DEBUG:
msg = " ".join(a)
print(msg)
else:
lines = getattr(g, "scodoc_pe_log")
if lines is None:
lines = pe_start_log()
msg = " ".join(a)
lines.append(msg)
if "info" in cles:
log(msg)
def pe_get_log() -> str:
"Renvoie une chaîne avec tous les messages loggués"
return "\n".join(getattr(g, "scodoc_pe_log", []))
# Affichage dans le tableur pe en cas d'absence de notes
SANS_NOTE = "-"
def repr_profil_coeffs(matrice_coeffs_moy_gen, with_index=False):
"""Affiche les différents types de coefficients (appelés profil)
d'une matrice_coeffs_moy_gen (pour debug)
"""
# Les profils des coeffs d'UE (pour debug)
profils = []
index_a_profils = {}
for i in matrice_coeffs_moy_gen.index:
val = matrice_coeffs_moy_gen.loc[i].fillna("-")
val = " | ".join([str(v) for v in val])
if val not in profils:
profils += [val]
index_a_profils[val] = [str(i)]
else:
index_a_profils[val] += [str(i)]
# L'affichage
if len(profils) > 1:
if with_index:
elmts = [
" " * 10
+ prof
+ " (par ex. "
+ ", ".join(index_a_profils[prof][:10])
+ ")"
for prof in profils
]
else:
elmts = [" " * 10 + prof for prof in profils]
profils_aff = "\n" + "\n".join(elmts)
else:
profils_aff = "\n".join(profils)
return profils_aff
def repr_asso_ue_comp(acronymes_ues_to_competences):
"""Représentation textuelle de l'association UE -> Compétences
fournies dans acronymes_ues_to_competences
"""
champs = acronymes_ues_to_competences.keys()
champs = sorted(champs)
aff_comp = []
for acro in champs:
aff_comp += [f"📍{acro} (∈ 💡{acronymes_ues_to_competences[acro]})"]
return ", ".join(aff_comp)
def aff_UEs(champs):
"""Représentation textuelle des UEs fournies dans `champs`"""
champs_tries = sorted(champs)
aff_comp = []
for comp in champs_tries:
aff_comp += ["📍" + comp]
return ", ".join(aff_comp)
def aff_competences(champs):
"""Affiche les compétences"""
champs_tries = sorted(champs)
aff_comp = []
for comp in champs_tries:
aff_comp += ["💡" + comp]
return ", ".join(aff_comp)
def repr_tags(tags):
"""Affiche les tags"""
tags_tries = sorted(tags)
aff_tag = ["👜" + tag for tag in tags_tries]
return ", ".join(aff_tag)
def aff_tags_par_categories(dict_tags):
"""Etant donné un dictionnaire de tags, triés
par catégorie (ici "personnalisés" ou "auto")
représentation textuelle des tags
"""
noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys())))
noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp
if noms_tags_perso:
aff_tags_perso = ", ".join([f"👜{nom}" for nom in noms_tags_perso])
aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto])
return f"Tags du programme de formation : {aff_tags_perso} + Automatiques : {aff_tags_auto}"
else:
aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto])
return f"Tags automatiques {aff_tags_auto} (aucun tag personnalisé)"
# Affichage
def aff_trajectoires_suivies_par_etudiants(etudiants):
"""Affiche les trajectoires (regroupement de (form)semestres)
amenant un étudiant du S1 à un semestre final"""
# Affichage pour debug
etudiants_ids = etudiants.etudiants_ids
jeunes = list(enumerate(etudiants_ids))
for no_etud, etudid in jeunes:
etat = "" if etudid in etudiants.abandons_ids else ""
pe_print(f"--> {etat} {etudiants.identites[etudid].nomprenom} (#{etudid}) :")
trajectoires = etudiants.trajectoires[etudid]
for nom_rcs, rcs in trajectoires.items():
if rcs:
pe_print(f" > RCS ⏯️{nom_rcs}: {rcs.get_repr()}")
def aff_semXs_suivis_par_etudiants(etudiants):
"""Affiche les SemX (regroupement de semestres de type Sx)
amenant un étudiant à valider un Sx"""
etudiants_ids = etudiants.etudiants_ids
jeunes = list(enumerate(etudiants_ids))
for no_etud, etudid in jeunes:
etat = "" if etudid in etudiants.abandons_ids else ""
pe_print(f"--> {etat} {etudiants.identites[etudid].nomprenom} :")
for nom_rcs, rcs in etudiants.semXs[etudid].items():
if rcs:
pe_print(f" > SemX ⏯️{nom_rcs}: {rcs.get_repr()}")
vides = []
for nom_rcs in pe_rcs.TOUS_LES_SEMESTRES:
les_semX_suivis = []
for no_etud, etudid in jeunes:
if etudiants.semXs[etudid][nom_rcs]:
les_semX_suivis.append(etudiants.semXs[etudid][nom_rcs])
if not les_semX_suivis:
vides += [nom_rcs]
vides = sorted(list(set(vides)))
pe_print(f"⚠️ SemX sans données : {', '.join(vides)}")
def aff_capitalisations(etuds, ressembuttags, fid_final, acronymes_sorted, masque_df):
"""Affichage des capitalisations du sxtag pour debug"""
aff_cap = []
for etud in etuds:
cap = []
for frmsem_id in ressembuttags:
if frmsem_id != fid_final:
for accr in acronymes_sorted:
if masque_df[frmsem_id].loc[etud.etudid, accr] > 0.0:
cap += [accr]
if cap:
aff_cap += [f" > {etud.nomprenom} : {', '.join(cap)}"]
if aff_cap:
pe_print(f"--> ⚠️ Capitalisations :")
pe_print("\n".join(aff_cap))
def repr_comp_et_ues(acronymes_ues_to_competences):
"""Affichage pour debug"""
aff_comp = []
competences_sorted = sorted(acronymes_ues_to_competences.keys())
for comp in competences_sorted:
liste = []
for acro in acronymes_ues_to_competences:
if acronymes_ues_to_competences[acro] == comp:
liste += ["📍" + acro]
aff_comp += [f" 💡{comp} (⇔ {', '.join(liste)})"]
return "\n".join(aff_comp)
def aff_rcsemxs_suivis_par_etudiants(etudiants):
"""Affiche les RCSemX (regroupement de SemX)
amenant un étudiant du S1 à un Sx"""
etudiants_ids = etudiants.etudiants_ids
jeunes = list(enumerate(etudiants_ids))
for no_etud, etudid in jeunes:
etat = "" if etudid in etudiants.abandons_ids else ""
pe_print(f"-> {etat} {etudiants.identites[etudid].nomprenom} :")
for nom_rcs, rcs in etudiants.rcsemXs[etudid].items():
if rcs:
pe_print(f" > RCSemX ⏯️{nom_rcs}: {rcs.get_repr()}")
vides = []
for nom_rcs in pe_rcs.TOUS_LES_RCS:
les_rcssemX_suivis = []
for no_etud, etudid in jeunes:
if etudiants.rcsemXs[etudid][nom_rcs]:
les_rcssemX_suivis.append(etudiants.rcsemXs[etudid][nom_rcs])
if not les_rcssemX_suivis:
vides += [nom_rcs]
vides = sorted(list(set(vides)))
pe_print(f"⚠️ RCSemX vides : {', '.join(vides)}")

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

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

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

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

@ -0,0 +1,653 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. c All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on 17/01/2024
@author: barasc
"""
import pandas as pd
from app import ScoValueError
from app.models import FormSemestre, Identite, Formation
from app.pe import pe_comp, pe_affichage
from app.pe.rcss import pe_rcs
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
from app.comp.res_sem import load_formsemestre_results
class EtudiantsJuryPE:
"""Classe centralisant la gestion des étudiants à prendre en compte dans un jury de PE"""
def __init__(self, annee_diplome: int):
"""
Args:
annee_diplome: L'année de diplomation
"""
self.annee_diplome = annee_diplome
"""L'année du diplôme"""
self.identites: dict[int:Identite] = {} # ex. ETUDINFO_DICT
"""Les identités des étudiants traités pour le jury"""
self.cursus: dict[int:dict] = {}
"""Les cursus (semestres suivis, abandons) des étudiants"""
self.trajectoires: dict[int:dict] = {}
"""Les trajectoires (regroupement cohérents de semestres) suivis par les étudiants"""
self.semXs: dict[int:dict] = {}
"""Les semXs (RCS de type Sx) suivis par chaque étudiant"""
self.rcsemXs: dict[int:dict] = {}
"""Les RC de SemXs (RCS de type Sx, xA, xS) suivis par chaque étudiant"""
self.etudiants_diplomes = {}
"""Les identités des étudiants à considérer au jury (ceux qui seront effectivement
diplômés)"""
self.diplomes_ids = {}
"""Les etudids des étudiants diplômés"""
self.etudiants_ids = {}
"""Les etudids des étudiants dont il faut calculer les moyennes/classements
(même si d'éventuels abandons).
Il s'agit des étudiants inscrits dans les co-semestres (ceux du jury mais aussi
d'autres ayant été réorientés ou ayant abandonnés)"""
self.cosemestres: dict[int, FormSemestre] = None
"Les cosemestres donnant lieu à même année de diplome"
self.abandons = {}
"""Les étudiants qui ne seront pas diplômés à ce jury (redoublants/réorientés)"""
self.abandons_ids = {}
"""Les etudids des étudiants redoublants/réorientés"""
def find_etudiants(self):
"""Liste des étudiants à prendre en compte dans le jury PE, en les recherchant
de manière automatique par rapport à leur année de diplomation ``annee_diplome``.
Les données obtenues sont stockées dans les attributs de EtudiantsJuryPE.
*Remarque* : ex: JuryPE.get_etudiants_in_jury()
"""
cosemestres = pe_comp.get_cosemestres_diplomants(self.annee_diplome)
self.cosemestres = cosemestres
pe_affichage.pe_print(
f"1) Recherche des cosemestres -> {len(cosemestres)} trouvés", info=True
)
pe_affichage.pe_print(
"2) Liste des étudiants dans les différents cosemestres", info=True
)
etudiants_ids = get_etudiants_dans_semestres(cosemestres)
pe_affichage.pe_print(
f" => {len(etudiants_ids)} étudiants trouvés dans les cosemestres",
info=True,
)
# Analyse des parcours étudiants pour déterminer leur année effective de diplome
# avec prise en compte des redoublements, des abandons, ....
pe_affichage.pe_print(
"3) Analyse des parcours individuels des étudiants", info=True
)
# Ajoute une liste d'étudiants
self.add_etudiants(etudiants_ids)
# Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris
self.etudiants_diplomes = self.get_etudiants_diplomes()
self.diplomes_ids = set(self.etudiants_diplomes.keys())
self.etudiants_ids = set(self.identites.keys())
# Les abandons (pour debug)
self.abandons = self.get_etudiants_redoublants_ou_reorientes()
# Les identités des étudiants ayant redoublés ou ayant abandonnés
self.abandons_ids = set(self.abandons)
# Les identifiants des étudiants ayant redoublés ou ayant abandonnés
# Synthèse
pe_affichage.pe_print(f"4) Bilan", info=True)
pe_affichage.pe_print(
f"--> {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}",
info=True,
)
nbre_abandons = len(self.etudiants_ids) - len(self.etudiants_diplomes)
assert nbre_abandons == len(self.abandons_ids)
pe_affichage.pe_print(
f"--> {nbre_abandons} étudiants traités mais non diplômés (redoublement, réorientation, abandon)"
)
def add_etudiants(self, etudiants_ids):
"""Ajoute une liste d'étudiants aux données du jury"""
nbre_etudiants_ajoutes = 0
for etudid in etudiants_ids:
if etudid not in self.identites:
nbre_etudiants_ajoutes += 1
# L'identité de l'étudiant
self.identites[etudid] = Identite.get_etud(etudid)
# Analyse son cursus
self.analyse_etat_etudiant(etudid, self.cosemestres)
# Analyse son parcours pour atteindre chaque semestre de la formation
self.structure_cursus_etudiant(etudid)
self.etudiants_ids = set(self.identites.keys())
return nbre_etudiants_ajoutes
def get_etudiants_diplomes(self) -> dict[int, Identite]:
"""Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
qui vont être à traiter au jury PE pour
l'année de diplômation donnée et n'ayant ni été réorienté, ni abandonné.
Returns:
Un dictionnaire `{etudid: Identite(etudid)}`
"""
etudids = [
etudid
for etudid, cursus_etud in self.cursus.items()
if cursus_etud["diplome"] == self.annee_diplome
and cursus_etud["abandon"] is False
]
etudiants = {etudid: self.identites[etudid] for etudid in etudids}
return etudiants
def get_etudiants_redoublants_ou_reorientes(self) -> dict[int, Identite]:
"""Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
dont les notes seront prises en compte (pour les classements) mais qui n'apparaitront
pas dans le jury car diplômé une autre année (redoublants) ou réorienté ou démissionnaire.
Returns:
Un dictionnaire `{etudid: Identite(etudid)}`
"""
etudids = [
etudid
for etudid, cursus_etud in self.cursus.items()
if cursus_etud["diplome"] != self.annee_diplome
or cursus_etud["abandon"] is True
]
etudiants = {etudid: self.identites[etudid] for etudid in etudids}
return etudiants
def analyse_etat_etudiant(self, etudid: int, cosemestres: dict[int, FormSemestre]):
"""Analyse le cursus d'un étudiant pouvant être :
* l'un de ceux sur lesquels le jury va statuer (année de diplômation du jury considéré)
* un étudiant qui ne sera pas considéré dans le jury mais qui a participé dans sa scolarité
à un (ou plusieurs) semestres communs aux étudiants du jury (et impactera les classements)
L'analyse consiste :
* à insérer une entrée dans ``self.cursus`` pour mémoriser son identité,
avec son nom, prénom, etc...
* à analyser son parcours, pour déterminer s'il a démissionné, redoublé (autre année de diplôme)
ou a abandonné l'IUT en cours de route (cf. clé abandon). Un étudiant est considéré
en abandon si connaissant son dernier semestre (par ex. un S3) il n'est pas systématiquement
inscrit à l'un des S4, S5 ou S6 existants dans les cosemestres.
Args:
etudid: L'etudid d'un étudiant, à ajouter à ceux traiter par le jury
cosemestres: Dictionnaire {fid: Formsemestre(fid)} donnant accès aux cosemestres
de même année de diplomation
"""
identite = Identite.get_etud(etudid)
# Le cursus global de l'étudiant (restreint aux semestres APC)
formsemestres = identite.get_formsemestres()
semestres_etudiant = {
formsemestre.formsemestre_id: formsemestre
for formsemestre in formsemestres
if formsemestre.formation.is_apc()
}
# Le parcours final
parcour = formsemestres[0].etuds_inscriptions[etudid].parcour
if parcour:
libelle = parcour.libelle
else:
libelle = None
self.cursus[etudid] = {
"etudid": etudid, # les infos sur l'étudiant
"etat_civil": identite.etat_civil, # Ajout à la table jury
"nom": identite.nom,
"entree": formsemestres[-1].date_debut.year, # La date d'entrée à l'IUT
"parcours": libelle, # Le parcours final
"diplome": get_annee_diplome(
identite
), # Le date prévisionnelle de son diplôme
"formsemestres": semestres_etudiant, # les semestres de l'étudiant
"nb_semestres": len(
semestres_etudiant
), # le nombre de semestres de l'étudiant
"abandon": False, # va être traité en dessous
}
# Si l'étudiant est succeptible d'être diplomé
if self.cursus[etudid]["diplome"] == self.annee_diplome:
# Est-il démissionnaire : charge son dernier semestre pour connaitre son état ?
dernier_semes_etudiant = formsemestres[0]
res = load_formsemestre_results(dernier_semes_etudiant)
etud_etat = res.get_etud_etat(etudid)
if etud_etat == scu.DEMISSION:
self.cursus[etudid]["abandon"] = True
else:
# Est-il réorienté ou a-t-il arrêté (volontairement) sa formation ?
self.cursus[etudid]["abandon"] = arret_de_formation(
identite, cosemestres
)
# Initialise ses trajectoires/SemX/RCSemX
self.trajectoires[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS}
self.semXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_SEMESTRES}
self.rcsemXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS}
def structure_cursus_etudiant(self, etudid: int):
"""Structure les informations sur les semestres suivis par un
étudiant, pour identifier les semestres qui seront pris en compte lors de ses calculs
de moyennes PE.
Cette structuration s'appuie sur les numéros de semestre: pour chaque Si, stocke :
le dernier semestre (en date) de numéro i qu'il a suivi (1 ou 0 si pas encore suivi).
Ce semestre influera les interclassements par semestre dans la promo.
"""
semestres_significatifs = get_semestres_significatifs(
self.cursus[etudid]["formsemestres"], self.annee_diplome
)
# Tri des semestres par numéro de semestre
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
# les semestres de n°i de l'étudiant:
semestres_i = {
fid: sem_sig
for fid, sem_sig in semestres_significatifs.items()
if sem_sig.semestre_id == i
}
self.cursus[etudid][f"S{i}"] = semestres_i
def get_formsemestres_finals_des_rcs(self, nom_rcs: str) -> dict[int, FormSemestre]:
"""Pour un nom de RCS donné, ensemble des formsemestres finals possibles
pour les RCS. Par ex. un RCS '3S' incluant S1+S2+S3 a pour semestre final un S3.
Les formsemestres finals obtenus traduisent :
* les différents parcours des étudiants liés par exemple au choix de modalité
(par ex: S1 FI + S2 FI + S3 FI ou S1 FI + S2 FI + S3 UFA), en renvoyant les
formsemestre_id du S3 FI et du S3 UFA.
* les éventuelles situations de redoublement (par ex pour 1 étudiant ayant
redoublé sa 2ème année :
S1 + S2 + S3 (1ère session) et S1 + S2 + S3 + S4 + S3 (2ème session), en
renvoyant les formsemestre_id du S3 (1ère session) et du S3 (2ème session)
Args:
nom_rcs: Le nom du RCS (parmi Sx, xA, xS)
Returns:
Un dictionnaire ``{fid: FormSemestre(fid)}``
"""
formsemestres_terminaux = {}
for trajectoire_aggr in self.cursus.values():
trajectoire = trajectoire_aggr[nom_rcs]
if trajectoire:
# Le semestre terminal de l'étudiant de l'aggrégat
fid = trajectoire.formsemestre_final.formsemestre_id
formsemestres_terminaux[fid] = trajectoire.formsemestre_final
return formsemestres_terminaux
def nbre_etapes_max_diplomes(self, etudids: list[int]) -> int:
"""Partant d'un ensemble d'étudiants,
nombre de semestres (étapes) maximum suivis par les étudiants du jury.
Args:
etudids: Liste d'étudid d'étudiants
"""
nbres_semestres = []
for etudid in etudids:
nbres_semestres.append(self.cursus[etudid]["nb_semestres"])
if not nbres_semestres:
return 0
return max(nbres_semestres)
def df_administratif(self, etudids: list[int]) -> pd.DataFrame:
"""Synthétise toutes les données administratives d'un groupe
d'étudiants fournis par les etudid dans un dataFrame
Args:
etudids: La liste des étudiants à prendre en compte
"""
etudids = list(etudids)
# Récupération des données des étudiants
administratif = {}
nbre_semestres_max = self.nbre_etapes_max_diplomes(etudids)
for etudid in etudids:
etudiant = self.identites[etudid]
cursus = self.cursus[etudid]
formsemestres = cursus["formsemestres"]
parcours = cursus["parcours"]
if not parcours:
parcours = ""
if cursus["diplome"]:
diplome = cursus["diplome"]
else:
diplome = "indéterminé"
administratif[etudid] = {
"etudid": etudiant.id,
"INE": etudiant.code_ine or "",
"NIP": etudiant.code_nip or "",
"Nom": etudiant.nom,
"Prenom": etudiant.prenom,
"Civilite": etudiant.civilite_str,
"Age": pe_comp.calcul_age(etudiant.date_naissance),
"Parcours": parcours,
"Date entree": cursus["entree"],
"Date diplome": diplome,
"Nb semestres": len(formsemestres),
}
# Ajout des noms de semestres parcourus
etapes = etapes_du_cursus(formsemestres, nbre_semestres_max)
administratif[etudid] |= etapes
# Construction du dataframe
df = pd.DataFrame.from_dict(administratif, orient="index")
# Tri par nom/prénom
df.sort_values(by=["Nom", "Prenom"], inplace=True)
return df
def get_semestres_significatifs(formsemestres, annee_diplome):
"""Partant d'un ensemble de semestre, renvoie les semestres qui amèneraient les étudiants
à être diplômé à l'année visée, y compris s'ils n'avaient pas redoublé et seraient donc
diplômé plus tard.
De fait, supprime les semestres qui conduisent à une diplomation postérieure
à celle visée.
Args:
formsemestres: une liste de formsemestres
annee_diplome: l'année du diplôme visée
Returns:
Un dictionnaire ``{fid: FormSemestre(fid)}`` dans lequel les semestres
amènent à une diplômation antérieur à celle de la diplômation visée par le jury
"""
# semestres_etudiant = self.cursus[etudid]["formsemestres"]
semestres_significatifs = {}
for fid in formsemestres:
semestre = formsemestres[fid]
if pe_comp.get_annee_diplome_semestre(semestre) <= annee_diplome:
semestres_significatifs[fid] = semestre
return semestres_significatifs
def get_etudiants_dans_semestres(semestres: dict[int, FormSemestre]) -> set:
"""Ensemble d'identifiants des étudiants (identifiés via leur ``etudid``)
inscrits à l'un des semestres de la liste de ``semestres``.
Args:
semestres: Un dictionnaire ``{fid: Formsemestre(fid)}`` donnant un
ensemble d'identifiant de semestres
Returns:
Un ensemble d``etudid``
"""
etudiants_ids = set()
for sem in semestres.values(): # pour chacun des semestres de la liste
etudiants_du_sem = {ins.etudid for ins in sem.inscriptions}
pe_affichage.pe_print(f" --> {sem} : {len(etudiants_du_sem)} etudiants")
etudiants_ids = (
etudiants_ids | etudiants_du_sem
) # incluant la suppression des doublons
return etudiants_ids
def get_annee_diplome(etud: Identite) -> int | None:
"""L'année de diplôme prévue d'un étudiant en fonction de ses semestres
d'inscription (pour un BUT).
Args:
identite: L'identité d'un étudiant
Returns:
L'année prévue de sa diplômation, ou None si aucun semestre
"""
formsemestres_apc = get_semestres_apc(etud)
if formsemestres_apc:
dates_possibles_diplome = []
# Années de diplômation prédites en fonction des semestres
# (d'une formation APC) d'un étudiant
for sem_base in formsemestres_apc:
annee = pe_comp.get_annee_diplome_semestre(sem_base)
if annee:
dates_possibles_diplome.append(annee)
if dates_possibles_diplome:
return max(dates_possibles_diplome)
return None
def get_semestres_apc(identite: Identite) -> list:
"""Liste des semestres d'un étudiant qui correspondent à une formation APC.
Args:
identite: L'identité d'un étudiant
Returns:
Liste de ``FormSemestre`` correspondant à une formation APC
"""
semestres = identite.get_formsemestres()
semestres_apc = []
for sem in semestres:
if sem.formation.is_apc():
semestres_apc.append(sem)
return semestres_apc
def arret_de_formation(etud: Identite, cosemestres: dict[int, FormSemestre]) -> bool:
"""Détermine si un étudiant a arrêté sa formation (volontairement ou non). Il peut s'agir :
* d'une réorientation à l'initiative du jury de semestre ou d'une démission
(on pourrait utiliser les code NAR pour réorienté & DEM pour démissionnaire
des résultats du jury renseigné dans la BDD, mais pas nécessaire ici)
* d'un arrêt volontaire : l'étudiant disparait des listes d'inscrits (sans pour
autant avoir été indiqué NAR ou DEM).
Dans les cas, on considérera que l'étudiant a arrêté sa formation s'il n'est pas
dans l'un des "derniers" cosemestres (semestres conduisant à la même année de diplômation)
connu dans Scodoc. Par "derniers" cosemestres, est fait le choix d'analyser tous les cosemestres
de rang/semestre_id supérieur (et donc de dates) au dernier semestre dans lequel il a été inscrit.
Par ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc),
l'étudiant doit appartenir à une instance des S5 qui conduisent à la diplomation dans
l'année visée. S'il n'est que dans un S4, il a sans doute arrêté. A moins qu'il ne soit
parti à l'étranger et là, pas de notes.
TODO:: Cas de l'étranger, à coder/tester
**Attention** : Cela suppose que toutes les instances d'un semestre donné
(par ex: toutes les instances de S6 accueillant un étudiant soient créées ; sinon les
étudiants non inscrits dans un S6 seront considérés comme ayant abandonnés)
TODO:: Peut-être à mettre en regard avec les propositions d'inscriptions d'étudiants dans un nouveau semestre
Pour chaque étudiant, recherche son dernier semestre en date (validé ou non) et
regarde s'il n'existe pas parmi les semestres existants dans Scodoc un semestre :
* dont les dates sont postérieures (en terme de date de début)
* de n° au moins égal à celui de son dernier semestre valide (S5 -> S5 ou S5 -> S6)
dans lequel il aurait pu s'inscrire mais ne l'a pas fait.
Args:
etud: L'identité d'un étudiant
cosemestres: Les semestres donnant lieu à diplômation (sans redoublement) en date du jury
Returns:
Est-il réorienté, démissionnaire ou a-t-il arrêté de son propre chef sa formation ?
TODO:: A reprendre pour le cas des étudiants à l'étranger
"""
# Les semestres APC de l'étudiant
semestres = get_semestres_apc(etud)
semestres_apc = {sem.semestre_id: sem for sem in semestres}
if not semestres_apc:
return True
# Le dernier semestre de l'étudiant
dernier_formsemestre = semestres[0]
rang_dernier_semestre = dernier_formsemestre.semestre_id
# Les cosemestres de rang supérieur ou égal à celui de formsemestre, triés par rang,
# sous la forme ``{semestre_id: [liste des comestres associé à ce semestre_id]}``
cosemestres_tries_par_rang = pe_comp.tri_semestres_par_rang(cosemestres)
cosemestres_superieurs = {}
for rang in cosemestres_tries_par_rang:
if rang > rang_dernier_semestre:
cosemestres_superieurs[rang] = cosemestres_tries_par_rang[rang]
# Si pas d'autres cosemestres postérieurs
if not cosemestres_superieurs:
return False
# Pour chaque rang de (co)semestres, y-a-il un dans lequel il est inscrit ?
etat_inscriptions = {rang: False for rang in cosemestres_superieurs}
for rang in etat_inscriptions:
for sem in cosemestres_superieurs[rang]:
etudiants_du_sem = {ins.etudid for ins in sem.inscriptions}
if etud.etudid in etudiants_du_sem:
etat_inscriptions[rang] = True
# Vérifie qu'il n'y a pas de "trous" dans les rangs des cosemestres
rangs = sorted(etat_inscriptions.keys())
if list(rangs) != list(range(min(rangs), max(rangs) + 1)):
difference = set(range(min(rangs), max(rangs) + 1)) - set(rangs)
affichage = ",".join([f"S{val}" for val in difference])
raise ScoValueError(
f"Il manque le(s) semestre(s) {affichage} au cursus de {etud.etat_civil} ({etud.etudid})."
)
# Est-il inscrit à tous les semestres de rang supérieur ? Si non, est démissionnaire
est_demissionnaire = sum(etat_inscriptions.values()) != len(rangs)
if est_demissionnaire:
non_inscrit_a = [
rang for rang in etat_inscriptions if not etat_inscriptions[rang]
]
affichage = ", ".join([f"S{val}" for val in non_inscrit_a])
pe_affichage.pe_print(
f"--> ⛔ {etud.etat_civil} ({etud.etudid}), non inscrit dans {affichage} amenant à diplômation"
)
else:
pe_affichage.pe_print(f"--> ✅ {etud.etat_civil} ({etud.etudid})")
return est_demissionnaire
def etapes_du_cursus(
semestres: dict[int, FormSemestre], nbre_etapes_max: int
) -> list[str]:
"""Partant d'un dictionnaire de semestres (qui retrace
la scolarité d'un étudiant), liste les noms des
semestres (en version abbrégée)
qu'un étudiant a suivi au cours de sa scolarité à l'IUT.
Les noms des semestres sont renvoyés dans un dictionnaire
``{"etape i": nom_semestre_a_etape_i}``
avec i variant jusqu'à nbre_semestres_max. (S'il n'y a pas de semestre à l'étape i,
le nom affiché est vide.
La fonction suppose la liste des semestres triées par ordre
décroissant de date.
Args:
semestres: une liste de ``FormSemestre``
nbre_etapes_max: le nombre d'étapes max prise en compte
Returns:
Une liste de nom de semestre (dans le même ordre que les ``semestres``)
See also:
app.pe.pe_affichage.nom_semestre_etape
"""
assert len(semestres) <= nbre_etapes_max
noms = [nom_semestre_etape(sem, avec_fid=False) for (fid, sem) in semestres.items()]
noms = noms[::-1] # trie par ordre croissant
dico = {f"Etape {i+1}": "" for i in range(nbre_etapes_max)}
for i, nom in enumerate(noms): # Charge les noms de semestres
dico[f"Etape {i+1}"] = nom
return dico
def nom_semestre_etape(semestre: FormSemestre, avec_fid=False) -> str:
"""Nom d'un semestre à afficher dans le descriptif des étapes de la scolarité
d'un étudiant.
Par ex: Pour un S2, affiche ``"Semestre 2 FI S014-2015 (129)"`` avec :
* 2 le numéro du semestre,
* FI la modalité,
* 2014-2015 les dates
Args:
semestre: Un ``FormSemestre``
avec_fid: Ajoute le n° du semestre à la description
Returns:
La chaine de caractères décrivant succintement le semestre
"""
formation: Formation = semestre.formation
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
description = [
parcours.SESSION_NAME.capitalize(),
str(semestre.semestre_id),
semestre.modalite, # eg FI ou FC
f"{semestre.date_debut.year}-{semestre.date_fin.year}",
]
if avec_fid:
description.append(f"(#{semestre.formsemestre_id})")
return " ".join(description)

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

@ -0,0 +1,863 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
# ----------------------------------------------------------
# Ensemble des fonctions et des classes
# permettant les calculs preliminaires (hors affichage)
# a l'edition d'un jury de poursuites d'etudes
# ----------------------------------------------------------
import io
import os
import time
from zipfile import ZipFile
import numpy as np
import pandas as pd
import jinja2
from app.pe.rcss import pe_rcs
from app.pe.moys import pe_sxtag
import app.pe.pe_affichage as pe_affichage
import app.pe.pe_etudiant as pe_etudiant
from app.pe.moys import (
pe_tabletags,
pe_ressemtag,
pe_sxtag,
pe_rcstag,
pe_interclasstag,
pe_moytag,
)
import app.pe.pe_rcss_jury as pe_rcss_jury
from app.scodoc.sco_utils import *
class JuryPE(object):
"""
Classe mémorisant toutes les informations nécessaires pour établir un jury de PE, sur la base
d'une année de diplôme. De ce semestre est déduit :
1. l'année d'obtention du DUT,
2. tous les étudiants susceptibles à ce stade (au regard de leur parcours) d'être diplomés.
Les options sont :
``
options = {
"cohorte_restreinte": False,
"moyennes_tags": True,
"moyennes_ue_res_sae": True,
"moyennes_ues_rcues": True,
"min_max_moy": False,
"synthese_individuelle_etud": False,
}
Args:
diplome : l'année d'obtention du diplome BUT et du jury de PE (généralement février XXXX)
"""
def __init__(
self,
diplome: int,
formsemestre_id_base,
options={
"moyennes_tags": True,
"moyennes_ue_res_sae": True,
"moyennes_ues_rcues": True,
"min_max_moy": False,
"publipostage": False,
},
):
pe_affichage.pe_start_log()
self.diplome = diplome
"L'année du diplome"
self.formsemestre_id_base = formsemestre_id_base
"""L'identifiant du formsemestre ayant servi à lancer le jury"""
self.nom_export_zip = f"Jury_PE_{self.diplome}"
"Nom du zip où ranger les fichiers générés"
# Les options
self.options = options
"""Options de configuration (cf. pe_sem_recap)"""
pe_affichage.pe_print(
f"Données de poursuite d'étude générées le {time.strftime('%d/%m/%Y à %H:%M')}\n",
info=True,
)
pe_affichage.pe_print("Options", info=True)
for cle, val in self.options.items():
pe_affichage.pe_print(f" > {cle} -> {val}", info=True)
# Chargement des étudiants à prendre en compte dans le jury
pe_affichage.pe_print(
f"""***********************************************************"""
)
pe_affichage.pe_print(
f"""*** Recherche des étudiants diplômés 🎓 en {self.diplome}""", info=True
)
pe_affichage.pe_print(
f"""***********************************************************"""
)
# Les infos sur les étudiants
self.etudiants = pe_etudiant.EtudiantsJuryPE(self.diplome)
"""Les informations sur les étudiants du jury PE"""
self.etudiants.find_etudiants()
self.diplomes_ids = self.etudiants.diplomes_ids
self.rcss_jury = pe_rcss_jury.RCSsJuryPE(self.diplome, self.etudiants)
"""Les informations sur les regroupements de semestres"""
self.zipdata = io.BytesIO()
with ZipFile(self.zipdata, "w") as zipfile:
if not self.diplomes_ids:
pe_affichage.pe_print("*** Aucun étudiant diplômé", info=True)
else:
try:
self._gen_xls_diplomes(zipfile)
self._gen_xls_ressembuttags(zipfile)
self._gen_trajectoires()
self._gen_semXs()
self._gen_xls_sxtags(zipfile)
self._gen_rcsemxs()
self._gen_xls_rcstags(zipfile)
self._gen_xls_interclasstags(zipfile)
self._gen_xls_synthese_jury_par_tag(zipfile)
self._gen_html_synthese_par_etudiant(zipfile)
except Exception as e:
if pe_affichage.PE_DEBUG == True:
raise e
else:
pe_affichage.pe_print(str(e))
# raise e
# et le log
self._add_log_to_zip(zipfile)
# Fin !!!! Tada :)
def _gen_xls_diplomes(self, zipfile: ZipFile):
"Intègre le bilan des semestres taggués au zip"
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
if self.diplomes_ids:
onglet = "diplômés"
df_diplome = self.etudiants.df_administratif(self.diplomes_ids)
df_diplome.to_excel(writer, onglet, index=True, header=True)
if self.etudiants.abandons_ids:
onglet = "redoublants-réorientés"
df_abandon = self.etudiants.df_administratif(
self.etudiants.abandons_ids
)
df_abandon.to_excel(writer, onglet, index=True, header=True)
output.seek(0)
self.add_file_to_zip(
zipfile,
f"etudiants_{self.diplome}.xlsx",
output.read(),
path="details",
)
def _gen_xls_ressembuttags(self, zipfile: ZipFile):
"""Calcule les moyennes par tag des résultats des Semestres BUT"""
pe_affichage.pe_print(
f"""*************************************************************************"""
)
pe_affichage.pe_print(
f"""*** Génère les ResSemBUTTag (ResSemestreBUT taggués)""", info=True
)
pe_affichage.pe_print(
f"""*************************************************************************"""
)
# Tous les formsestres des étudiants
formsemestres = get_formsemestres_etudiants(self.etudiants)
pe_affichage.pe_print(
f"1) Génère les {len(formsemestres)} ResSemBUTTag", info=True
)
self.ressembuttags = {}
for frmsem_id, formsemestre in formsemestres.items():
# Crée le semestre_tag et exécute les calculs de moyennes
ressembuttag = pe_ressemtag.ResSemBUTTag(formsemestre, options=self.options)
self.ressembuttags[frmsem_id] = ressembuttag
# Ajoute les étudiants découverts dans les ressembuttags aux données des étudiants
# nbre_etudiants_ajoutes = self.etudiants.add_etudiants(
# ressembuttag.etudids_sorted
# )
# if nbre_etudiants_ajoutes:
# pe_affichage.pe_print(
# f"--> Ajout de {nbre_etudiants_ajoutes} étudiants aux données du jury"
# )
# Intègre le bilan des semestres taggués au zip final
pe_affichage.pe_print(f"2) Bilan", info=True)
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
onglets = []
for res_sem_tag in self.ressembuttags.values():
if res_sem_tag.is_significatif():
onglet = res_sem_tag.get_repr(verbose=True)
onglet = onglet.replace("Semestre ", "S")
onglets += ["📊" + onglet]
df = res_sem_tag.to_df(options=self.options)
# Conversion colonnes en multiindex
df = convert_colonnes_to_multiindex(df)
# écriture dans l'onglet
df.to_excel(writer, onglet, index=True, header=True)
pe_affichage.pe_print(
f"--> Export excel de {', '.join(onglets)}", info=True
)
output.seek(0)
self.add_file_to_zip(
zipfile,
f"ResSemBUTTags_{self.diplome}.xlsx",
output.read(),
path="details",
)
def _gen_trajectoires(self):
"""Génère l'ensemble des trajectoires (RCS), qui traduisent les différents
chemins au sein des (form)semestres pour atteindre la cible d'un
RCS (par ex: 'S2' ou '3S').
"""
pe_affichage.pe_print(
"***************************************************************************"
)
pe_affichage.pe_print(
"*** Génère les trajectoires (≠tes combinaisons de semestres) des étudiants",
info=True,
)
pe_affichage.pe_print(
"***************************************************************************"
)
self.rcss_jury.cree_trajectoires()
pe_affichage.aff_trajectoires_suivies_par_etudiants(self.etudiants)
def _gen_semXs(self):
"""Génère les SemXs (trajectoires/combinaisons de semestre de même rang x)
qui traduisent les différents chemins des étudiants pour valider un semestre Sx.
"""
pe_affichage.pe_print(
"***************************************************************************"
)
pe_affichage.pe_print(
"*** Génère les SemXs (RCS de même Sx donnant lieu à validation du semestre)",
info=True,
)
pe_affichage.pe_print(
"***************************************************************************"
)
# Génère les regroupements de semestres de type Sx
self.rcss_jury.cree_semxs()
pe_affichage.aff_semXs_suivis_par_etudiants(self.etudiants)
def _gen_xls_sxtags(self, zipfile: ZipFile):
"""Génère les semestres taggués en s'appuyant sur les RCF de type Sx (pour
identifier les redoublements impactant les semestres taggués).
"""
# Génère les moyennes des RCS de type Sx
pe_affichage.pe_print(
"***************************************************************************"
)
pe_affichage.pe_print(
"*** Calcule les moyennes des SxTag (moyennes d'un RCS de type Sx)",
info=True,
)
pe_affichage.pe_print(
"***************************************************************************"
)
# Les SxTag (moyenne de Sx par UE)
pe_affichage.pe_print("1) Calcul des moyennes", info=True)
self.sxtags = {}
for rcf_id, rcf in self.rcss_jury.semXs.items():
# SxTag traduisant le RCF
sxtag_id = rcf_id
self.sxtags[sxtag_id] = pe_sxtag.SxTag(sxtag_id, rcf, self.ressembuttags)
# Intègre le bilan des semestres taggués au zip final
pe_affichage.pe_print("2) Bilan", info=True)
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
onglets = []
for sxtag in self.sxtags.values():
if sxtag.is_significatif():
onglet = sxtag.get_repr(verbose=False)
onglets += ["📊" + onglet]
df = sxtag.to_df(options=self.options)
# Conversion colonnes en multiindex
df = convert_colonnes_to_multiindex(df)
# écriture dans l'onglet
df.to_excel(writer, onglet, index=True, header=True)
pe_affichage.pe_print(
f"--> Export excel de {', '.join(onglets)}", info=True
)
output.seek(0)
if onglets:
self.add_file_to_zip(
zipfile,
f"semestres_taggues_{self.diplome}.xlsx",
output.read(),
path="details",
)
def _gen_rcsemxs(self):
"""Génère les regroupements cohérents de RCFs qu'ont suivi chaque étudiant"""
pe_affichage.pe_print(
"""******************************************************************************"""
)
pe_affichage.pe_print(
"""*** Génère les RCSemX (regroupements cohérents de données extraites des SemX)\n"""
"""*** amenant du S1 à un semestre final""",
info=True,
)
pe_affichage.pe_print(
"""******************************************************************************"""
)
self.rcss_jury.cree_rcsemxs(options=self.options)
if "moyennes_ues_rcues" in self.options and self.options["moyennes_ues_rcues"]:
pe_affichage.aff_rcsemxs_suivis_par_etudiants(self.etudiants)
def _gen_xls_rcstags(self, zipfile: ZipFile):
"""Génère les RCS taggués traduisant les moyennes (orientées compétences)
de regroupements de semestre de type Sx, xA ou xS.
Stocke le résultat dans self.rccs_tag, un dictionnaire de
la forme ``{nom_aggregat: {fid_terminal: RCSTag(fid_terminal)} }``
Pour rappel : Chaque RCS est identifié par un nom d'aggrégat et par un formsemestre terminal.
Par exemple :
* combinaisons '3S' : S1+S2+S3 en prenant en compte tous les S3 qu'ont fréquenté les
étudiants du jury PE. Ces S3 marquent les formsemestre terminal de chaque combinaison.
Args:
etudiants: Les données des étudiants
semestres_tag: Les semestres tag (pour lesquels des moyennes par tag ont été calculés)
"""
# Génère les moyennes des RCS de type Sx
pe_affichage.pe_print(
"""****************************************************"""
)
pe_affichage.pe_print(
"""*** Génère les moyennes associées aux RCSemX""", info=True
)
pe_affichage.pe_print(
"""****************************************************"""
)
pe_affichage.pe_print("1) Calcul des moyennes des RCSTag", info=True)
if not self.rcss_jury.rcsemxs:
if (
"moyennes_ues_rcues" in self.options
and not self.options["moyennes_ues_rcues"]
):
pe_affichage.pe_print(" -> Pas de RCSemX à calculer (cf. options)")
else:
pe_affichage.pe_print(
" -> Pas de RCSemX à calculer (alors qu'aucune option ne les limite) => problème"
)
self.rcsstags = {}
return
# Calcul des RCSTags sur la base des RCSemX
self.rcsstags = {}
for rcs_id, rcsemx in self.rcss_jury.rcsemxs.items():
self.rcsstags[rcs_id] = pe_rcstag.RCSemXTag(
rcsemx, self.sxtags, self.rcss_jury.semXs_suivis
)
# Intègre le bilan des trajectoires tagguées au zip final
pe_affichage.pe_print("2) Bilan", info=True)
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
onglets = []
for rcs_tag in self.rcsstags.values():
if rcs_tag.is_significatif():
onglet = rcs_tag.get_repr(verbose=False)
onglets += ["📊" + onglet]
df = rcs_tag.to_df(options=self.options)
# Conversion colonnes en multiindex
df = convert_colonnes_to_multiindex(df)
onglets += ["📊" + onglet]
# écriture dans l'onglet
df.to_excel(writer, onglet, index=True, header=True)
pe_affichage.pe_print(
f"--> Export excel de {', '.join(onglets)}", info=True
)
output.seek(0)
if onglets:
self.add_file_to_zip(
zipfile,
f"RCRCFs_{self.diplome}.xlsx",
output.read(),
path="details",
)
def _gen_xls_interclasstags(self, zipfile: ZipFile):
"""Génère les interclassements sur la promo de diplômés
par (nom d') aggrégat
en distinguant les interclassements par accronymes d'UEs (sur les SxTag)
et ceux par compétences (sur les RCSTag).
"""
pe_affichage.pe_print(
"""******************************************************************"""
)
pe_affichage.pe_print(
"""*** Génère les interclassements sur chaque type de RCS/agrégat"""
)
pe_affichage.pe_print(
"""******************************************************************"""
)
if (
"moyennes_ues_rcues" not in self.options
or self.options["moyennes_ues_rcues"]
):
self.interclasstags = {
pe_moytag.CODE_MOY_UE: {},
pe_moytag.CODE_MOY_COMPETENCES: {},
}
else:
self.interclasstags = {pe_moytag.CODE_MOY_UE: {}}
etudiants_diplomes = self.etudiants.etudiants_diplomes
# Les interclassements par UE (toujours présents par défaut)
for Sx in pe_rcs.TOUS_LES_SEMESTRES:
interclass = pe_interclasstag.InterClassTag(
Sx,
pe_moytag.CODE_MOY_UE,
etudiants_diplomes,
self.rcss_jury.semXs,
self.sxtags,
self.rcss_jury.semXs_suivis,
)
self.interclasstags[pe_moytag.CODE_MOY_UE][Sx] = interclass
# Les interclassements par compétences
if (
"moyennes_ues_rcues" not in self.options
or self.options["moyennes_ues_rcues"]
):
for nom_rcs in pe_rcs.TOUS_LES_RCS:
interclass = pe_interclasstag.InterClassTag(
nom_rcs,
pe_moytag.CODE_MOY_COMPETENCES,
etudiants_diplomes,
self.rcss_jury.rcsemxs,
self.rcsstags,
self.rcss_jury.rcsemxs_suivis,
)
self.interclasstags[pe_moytag.CODE_MOY_COMPETENCES][
nom_rcs
] = interclass
# Intègre le bilan des aggrégats (interclassé par promo) au zip final
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
onglets = []
for (
type_interclass
) in self.interclasstags: # Pour les types d'interclassements prévus
interclasstag = self.interclasstags[type_interclass]
for nom_rcs, interclass in interclasstag.items():
if interclass.is_significatif():
onglet = interclass.get_repr()
onglets += ["📊" + onglet]
df = interclass.to_df(cohorte="Promo", options=self.options)
# Conversion colonnes en multiindex
df = convert_colonnes_to_multiindex(df)
onglets += [onglet]
# écriture dans l'onglet
df.to_excel(writer, onglet, index=True, header=True)
pe_affichage.pe_print(f"=> Export excel de {', '.join(onglets)}", info=True)
output.seek(0)
if onglets:
self.add_file_to_zip(
zipfile,
f"InterClassTags_{self.diplome}.xlsx",
output.read(),
path="details",
)
def _gen_xls_synthese_jury_par_tag(self, zipfile: ZipFile):
"""Synthèse des éléments du jury PE tag par tag"""
pe_affichage.pe_print(
"**************************************************************************************"
)
pe_affichage.pe_print(
"*** Synthèse finale des moyennes par tag et par type de moyennes (UEs ou Compétences)",
info=True,
)
pe_affichage.pe_print(
"**************************************************************************************"
)
self.synthese = {}
pe_affichage.pe_print(" -> Synthèse des données administratives", info=True)
self.synthese["administratif"] = self.etudiants.df_administratif(
self.diplomes_ids
)
tags = self._do_tags_list(self.interclasstags)
for tag in tags:
for type_moy in self.interclasstags:
self.synthese[(tag, type_moy)] = self.df_tag_type(tag, type_moy)
# Export des données => mode 1 seule feuille -> supprimé
pe_affichage.pe_print("*** Export du jury de synthese par tags", info=True)
output = io.BytesIO()
with pd.ExcelWriter( # pylint: disable=abstract-class-instantiated
output, engine="openpyxl"
) as writer:
onglets = []
for onglet, df in self.synthese.items():
# Conversion colonnes en multiindex
df_final = df.copy()
if (
"publipostage" not in self.options
or not self.options["publipostage"]
):
df_final = convert_colonnes_to_multiindex(df_final)
# Nom de l'onglet
if isinstance(onglet, tuple):
(repr, type_moy) = onglet
nom_onglet = onglet[0][: 31 - 7]
if type_moy == pe_moytag.CODE_MOY_COMPETENCES:
nom_onglet = nom_onglet + " (Comp)"
else:
nom_onglet = nom_onglet + " (UEs)"
else:
nom_onglet = onglet
onglets += [nom_onglet]
# écriture dans l'onglet:
df_final = df_final.replace("nan", "")
df_final.to_excel(writer, nom_onglet, index=True, header=True)
pe_affichage.pe_print(f"=> Export excel de {', '.join(onglets)}", info=True)
output.seek(0)
if onglets:
self.add_file_to_zip(
zipfile, f"synthese_jury_{self.diplome}.xlsx", output.read()
)
def _gen_html_synthese_par_etudiant(self, zipfile: ZipFile):
"""Synthèse des éléments du jury PE, étudiant par étudiant"""
# Synthèse des éléments du jury PE
pe_affichage.pe_print("**************************************************")
pe_affichage.pe_print("*** Synthèse finale étudiant par étudiant", info=True)
pe_affichage.pe_print("**************************************************")
if (
"moyennes_ues_rcues" not in self.options
or self.options["moyennes_ues_rcues"]
):
etudids = list(self.diplomes_ids)
for etudid in etudids:
nom, prenom, html = self.synthetise_jury_etudiant(etudid)
self.add_file_to_zip(
zipfile, f"{nom}_{prenom}.html", html, path="etudiants"
)
else:
pe_affichage.pe_print(" > Pas de synthèse étudiant/étudiant possible/prévu")
def _add_log_to_zip(self, zipfile):
"""Add a text file with the log messages"""
log_data = pe_affichage.pe_get_log()
self.add_file_to_zip(zipfile, "pe_log.txt", log_data)
def add_file_to_zip(self, zipfile: ZipFile, filename: str, data, path=""):
"""Add a file to given zip
All files under NOM_EXPORT_ZIP/
path may specify a subdirectory
Args:
zipfile: ZipFile
filename: Le nom du fichier à intégrer au zip
data: Les données du fichier
path: Un dossier dans l'arborescence du zip
"""
path_in_zip = os.path.join(path, filename) # self.nom_export_zip,
zipfile.writestr(path_in_zip, data)
def get_zipped_data(self) -> io.BytesIO | None:
"""returns file-like data with a zip of all generated (CSV) files.
Warning: reset stream to the begining.
"""
self.zipdata.seek(0)
return self.zipdata
def _do_tags_list(self, interclassements: dict[str, dict]):
"""La liste des tags extraites des interclassements"""
tags = []
# Pour chaque type d'interclassements
for type in interclassements:
interclassement = interclassements[type]
for aggregat in interclassement:
interclass = interclassement[aggregat]
if interclass.tags_sorted:
tags.extend(interclass.tags_sorted)
tags = sorted(set(tags))
return tags
# **************************************************************************************************************** #
# Méthodes pour la synthèse du juryPE
# *****************************************************************************************************************
def df_tag_type(self, tag, type_moy):
"""Génère le DataFrame synthétisant les moyennes/classements (groupe +
interclassement promo) pour tous les aggrégats prévus, en fonction
du type (UEs ou Compétences) de données souhaitées,
tels que fourni dans l'excel final.
Si type=UEs => tous les sxtag du tag
Si type=Compétences => tous les rcstag du tag
Args:
tag: Un des tags (a minima `but`)
type_moy: Un type de moyenne
Returns:
"""
# Les données des étudiants
etuds = [etud for etudid, etud in self.etudiants.etudiants_diplomes.items()]
df = pe_tabletags.df_administratif(etuds, aggregat="Administratif", cohorte="")
if type_moy == pe_moytag.CODE_MOY_UE:
aggregats = pe_rcs.TOUS_LES_SEMESTRES
else:
aggregats = pe_rcs.TOUS_LES_RCS
aff_aggregat = []
for aggregat in aggregats:
# Descr de l'aggrégat
descr = pe_rcs.TYPES_RCS[aggregat]["descr"]
# L'interclassement associé
interclass = self.interclasstags[type_moy][aggregat]
if interclass.is_significatif():
# Le dataframe du classement sur le groupe
df_groupe = interclass.compute_df_synthese_moyennes_tag(
tag, aggregat=aggregat, type_colonnes=False, options=self.options
)
if not df_groupe.empty:
aff_aggregat += [aggregat]
df = df.join(df_groupe)
# Le dataframe du classement sur la promo
df_promo = interclass.to_df(
administratif=False,
aggregat=aggregat,
tags_cibles=[tag],
cohorte="Promo",
options=self.options,
)
if not df_promo.empty:
aff_aggregat += [aggregat]
df = df.join(df_promo)
if aff_aggregat:
pe_affichage.pe_print(
f" -> Synthèse de 👜{tag} par {type_moy} avec {', '.join(aff_aggregat)}"
)
else:
pe_affichage.pe_print(f" -> Synthèse du tag {tag} par {type_moy} : <vide>")
return df
# Fin de l'aggrégat
def synthetise_jury_etudiant(self, etudid) -> (str, str, str):
"""Synthétise les résultats d'un étudiant dans un
fichier html à son nom en s'appuyant sur la synthese final
Returns:
Un tuple nom, prenom, html
"""
etudiant = self.etudiants.identites[etudid]
nom = etudiant.nom
prenom = etudiant.prenom # initial du prénom
parcours = self.etudiants.cursus[etudid]["parcours"]
if not parcours:
parcours = "<parcours indéterminé>"
# Accès au template
environnement = jinja2.Environment(
loader=jinja2.FileSystemLoader("app/templates/")
)
template = environnement.get_template("pe/pe_view_resultats_etudiant.j2")
# Colonnes des tableaux htmls => competences
competences = []
for aggregat in pe_rcs.TOUS_LES_RCS:
# L'interclassement associé
interclass = self.interclasstags[pe_moytag.CODE_MOY_COMPETENCES][aggregat]
competences.extend(interclass.champs_sorted)
competences = sorted(set(competences))
colonnes_html = competences
tags = self._do_tags_list(self.interclasstags)
# Les données par UE
moyennes = {}
for tag in tags:
moyennes[tag] = {}
# Les données de synthèse
df = self.synthese[(tag, pe_moytag.CODE_MOY_COMPETENCES)]
for aggregat in pe_rcs.TOUS_LES_RCS:
# moyennes[tag][aggregat] = {}
descr = pe_rcs.get_descr_rcs(aggregat)
moy = {}
est_significatif = False
for comp in competences + ["Général"]:
moy[comp] = {
"note": "",
"rang_groupe": "",
"rang_promo": "",
}
colonne = pe_moytag.get_colonne_df(
aggregat, tag, comp, "Groupe", "note"
)
if colonne in df.columns:
valeur = df.loc[etudid, colonne]
if not np.isnan(valeur):
moy[comp]["note"] = round(valeur, 2)
est_significatif = True
# else:
# print(f"{colonne} manquante")
colonne = pe_moytag.get_colonne_df(
aggregat, tag, comp, "Groupe", "rang"
)
if colonne in df.columns:
valeur = df.loc[etudid, colonne]
if valeur and str(valeur) != "nan":
moy[comp]["rang_groupe"] = valeur
colonne = pe_moytag.get_colonne_df(
aggregat, tag, comp, "Promo", "rang"
)
if colonne in df.columns:
valeur = df.loc[etudid, colonne]
if valeur and str(valeur) != "nan":
moy[comp]["rang_promo"] = valeur
if est_significatif:
moyennes[tag][descr] = moy
html = template.render(
nom=nom,
prenom=prenom,
parcours=parcours,
colonnes_html=colonnes_html,
tags=tags,
moyennes=moyennes,
)
return (nom, prenom, html)
def get_formsemestres_etudiants(etudiants: pe_etudiant.EtudiantsJuryPE) -> dict:
"""Ayant connaissance des étudiants dont il faut calculer les moyennes pour
le jury PE (attribut `self.etudiant_ids) et de leurs trajectoires (semestres
parcourus), renvoie un dictionnaire ``{fid: FormSemestre(fid)}``
contenant l'ensemble des formsemestres de leurs cursus, dont il faudra calculer
la moyenne.
Args:
etudiants: Les étudiants du jury PE
Returns:
Un dictionnaire de la forme `{fid: FormSemestre(fid)}`
"""
semestres = {}
for etudid in etudiants.etudiants_ids:
for cle in etudiants.cursus[etudid]:
if cle.startswith("S"):
semestres = semestres | etudiants.cursus[etudid][cle]
return semestres
def convert_colonnes_to_multiindex(df):
"""Convertit les colonnes d'un df pour obtenir des colonnes
multiindex"""
df_final = df.copy()
colonnes = list(df.columns)
colonnes = [tuple(col.split("|")) for col in colonnes]
# modifie le nom du semestre par sa descr
colonnes_verbose = []
for col in colonnes:
if col[0] in pe_rcs.TYPES_RCS:
descr = pe_rcs.get_descr_rcs(col[0])
col_verbose = [descr] + list(col[1:])
col_verbose = tuple(col_verbose)
else:
col_verbose = col
colonnes_verbose.append(col_verbose)
df_final.columns = pd.MultiIndex.from_tuples(colonnes_verbose)
return df_final

File diff suppressed because it is too large Load Diff

289
app/pe/pe_rcss_jury.py Normal file
View File

@ -0,0 +1,289 @@
import app.pe.pe_comp
from app.pe.rcss import pe_rcs, pe_trajectoires, pe_rcsemx
import app.pe.pe_etudiant as pe_etudiant
import app.pe.pe_comp as pe_comp
from app.models import FormSemestre
from app.pe import pe_affichage
class RCSsJuryPE:
"""Classe centralisant tous les regroupements cohérents de
semestres (RCS) des étudiants à prendre en compte dans un jury PE
Args:
annee_diplome: L'année de diplomation
"""
def __init__(self, annee_diplome: int, etudiants: pe_etudiant.EtudiantsJuryPE):
self.annee_diplome = annee_diplome
"""Année de diplômation"""
self.etudiants = etudiants
"""Les étudiants recensés"""
self.trajectoires: dict[tuple(int, str) : pe_trajectoires.Trajectoire] = {}
"""Ensemble des trajectoires recensées (regroupement de (form)semestres BUT)"""
self.trajectoires_suivies: dict[int:dict] = {}
"""Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS,
sa Trajectoire : {etudid: {nom_RCS: Trajectoire}}"""
self.semXs: dict[tuple(int, str) : pe_trajectoires.SemX] = {}
"""Ensemble des SemX recensés (regroupement de (form)semestre BUT de rang x) :
{(nom_RCS, fid_terminal): SemX}"""
self.semXs_suivis: dict[int:dict] = {}
"""Dictionnaire associant, pour chaque étudiant et pour chaque RCS de type Sx,
son SemX : {etudid: {nom_RCS_de_type_Sx: SemX}}"""
self.rcsemxs: dict[tuple(int, str) : pe_rcsemx.RCSemX] = {}
"""Ensemble des RCSemX (regroupement de SemX donnant les résultats aux sems de rang x)
recensés : {(nom_RCS, fid_terminal): RCSemX}"""
self.rcsemxs_suivis: dict[int:str] = {}
"""Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS,
son RCSemX : {etudid: {nom_RCS: RCSemX}}"""
def cree_trajectoires(self):
"""Créé toutes les trajectoires, au regard du cursus des étudiants
analysés + les mémorise dans les données de l'étudiant
Args:
etudiants: Les étudiants à prendre en compte dans le Jury PE
"""
tous_les_aggregats = pe_rcs.TOUS_LES_RCS
for etudid in self.etudiants.cursus:
self.trajectoires_suivies[etudid] = self.etudiants.trajectoires[etudid]
for nom_rcs in tous_les_aggregats:
# L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre
# terminal (par ex: S3) et son numéro (par ex: 3)
noms_semestres = pe_rcs.TYPES_RCS[nom_rcs]["aggregat"]
nom_semestre_final = noms_semestres[-1]
for etudid in self.etudiants.cursus:
# Le (ou les) semestre(s) marquant la fin du cursus de l'étudiant
sems_final = self.etudiants.cursus[etudid][nom_semestre_final]
if sems_final:
# Le formsemestre final (dernier en date) de l'étudiant,
# marquant la fin de son aggrégat (par ex: son dernier S3 en date)
formsemestre_final = app.pe.pe_comp.get_dernier_semestre_en_date(
sems_final
)
# Ajout (si nécessaire) et récupération du RCS associé
rcs_id = (nom_rcs, formsemestre_final.formsemestre_id)
if rcs_id not in self.trajectoires:
self.trajectoires[rcs_id] = pe_trajectoires.Trajectoire(
nom_rcs, formsemestre_final
)
rcs = self.trajectoires[rcs_id]
# La liste des semestres de l'étudiant à prendre en compte
# pour cette trajectoire
semestres_a_aggreger = get_rcs_etudiant(
self.etudiants.cursus[etudid], formsemestre_final, nom_rcs
)
# Ajout des semestres au RCS
rcs.add_semestres(semestres_a_aggreger)
# Mémorise le RCS suivi par l'étudiant
self.trajectoires_suivies[etudid][nom_rcs] = rcs
self.etudiants.trajectoires[etudid][nom_rcs] = rcs
def cree_semxs(self):
"""Créé les SemXs (trajectoires/combinaisons de semestre de même rang x),
en ne conservant dans les trajectoires que les regroupements
de type Sx"""
self.semXs = {}
for rcs_id, trajectoire in self.trajectoires.items():
if trajectoire.nom in pe_rcs.TOUS_LES_SEMESTRES:
self.semXs[rcs_id] = pe_trajectoires.SemX(trajectoire)
# L'association (pour chaque étudiant entre chaque Sx et le SemX associé)
self.semXs_suivis = {}
for etudid in self.etudiants.trajectoires:
self.semXs_suivis[etudid] = {
agregat: None for agregat in pe_rcs.TOUS_LES_SEMESTRES
}
for agregat in pe_rcs.TOUS_LES_SEMESTRES:
trajectoire = self.etudiants.trajectoires[etudid][agregat]
if trajectoire:
rcs_id = trajectoire.rcs_id
semX = self.semXs[rcs_id]
self.semXs_suivis[etudid][agregat] = semX
self.etudiants.semXs[etudid][agregat] = semX
def cree_rcsemxs(self, options={"moyennes_ues_rcues": True}):
"""Créé tous les RCSemXs, au regard du cursus des étudiants
analysés (trajectoires traduisant son parcours dans les
différents semestres) + les mémorise dans les données de l'étudiant
"""
self.rcsemxs_suivis = {}
self.rcsemxs = {}
if "moyennes_ues_rcues" in options and options["moyennes_ues_rcues"] == False:
# Pas de RCSemX généré
pe_affichage.pe_print("⚠️ Pas de RCSemX générés")
return
# Pour tous les étudiants du jury
pas_de_semestres = []
for etudid in self.trajectoires_suivies:
self.rcsemxs_suivis[etudid] = {
nom_rcs: None for nom_rcs in pe_rcs.TOUS_LES_RCS_AVEC_PLUSIEURS_SEM
}
# Pour chaque aggréggat de type xA ou Sx ou xS
for agregat in pe_rcs.TOUS_LES_RCS:
trajectoire = self.trajectoires_suivies[etudid][agregat]
if not trajectoire:
self.rcsemxs_suivis[etudid][agregat] = None
else:
# Identifiant de la trajectoire => donnera ceux du RCSemX
tid = trajectoire.rcs_id
# Ajout du RCSemX
if tid not in self.rcsemxs:
self.rcsemxs[tid] = pe_rcsemx.RCSemX(
trajectoire.nom, trajectoire.formsemestre_final
)
# Récupére les SemX (RC de type Sx) associés aux semestres de son cursus
# Par ex: dans S1+S2+S1+S2+S3 => les 2 S1 devient le SemX('S1'), les 2 S2 le SemX('S2'), etc..
# Les Sx pris en compte dans l'aggrégat
noms_sems_aggregat = pe_rcs.TYPES_RCS[agregat]["aggregat"]
semxs_a_aggreger = {}
for Sx in noms_sems_aggregat:
semestres_etudiants = self.etudiants.cursus[etudid][Sx]
if not semestres_etudiants:
pas_de_semestres += [
f"{Sx} pour {self.etudiants.identites[etudid].nomprenom}"
]
else:
semx_id = get_semx_from_semestres_aggreges(
self.semXs, semestres_etudiants
)
if not semx_id:
raise (
"Il manque un SemX pour créer les RCSemX dans cree_rcsemxs"
)
# Les SemX à ajouter au RCSemX
semxs_a_aggreger[semx_id] = self.semXs[semx_id]
# Ajout des SemX à ceux à aggréger dans le RCSemX
rcsemx = self.rcsemxs[tid]
rcsemx.add_semXs(semxs_a_aggreger)
# Mémoire du RCSemX aux informations de suivi de l'étudiant
self.rcsemxs_suivis[etudid][agregat] = rcsemx
self.etudiants.rcsemXs[etudid][agregat] = rcsemx
# Affichage des étudiants pour lesquels il manque un semestre
pas_de_semestres = sorted(set(pas_de_semestres))
if pas_de_semestres:
pe_affichage.pe_print("⚠️ Semestres manquants :")
pe_affichage.pe_print(
"\n".join([" " * 10 + psd for psd in pas_de_semestres])
)
def get_rcs_etudiant(
semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str
) -> dict[int, FormSemestre]:
"""Ensemble des semestres parcourus (trajectoire)
par un étudiant dans le cadre
d'un RCS de type Sx, iA ou iS et ayant pour semestre terminal `formsemestre_final`.
Par ex: pour un RCS "3S", dont le formsemestre_terminal est un S3, regroupe
le ou les S1 qu'il a suivi (1 ou 2 si redoublement) + le ou les S2 + le ou les S3.
Les semestres parcourus sont antérieurs (en terme de date de fin)
au formsemestre_terminal.
Args:
cursus: Dictionnaire {fid: Formsemestre} donnant l'ensemble des semestres
dans lesquels l'étudiant a été inscrit
formsemestre_final: le semestre final visé
nom_rcs: Nom du RCS visé
"""
numero_semestre_terminal = formsemestre_final.semestre_id
# semestres_significatifs = self.get_semestres_significatifs(etudid)
semestres_significatifs = {}
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
semestres_significatifs = semestres_significatifs | semestres[f"S{i}"]
if nom_rcs.startswith("S"): # les semestres
numero_semestres_possibles = [numero_semestre_terminal]
elif nom_rcs.endswith("A"): # les années
numero_semestres_possibles = [
int(sem[-1]) for sem in pe_rcs.TYPES_RCS[nom_rcs]["aggregat"]
]
assert numero_semestre_terminal in numero_semestres_possibles
else: # les xS = tous les semestres jusqu'à Sx (eg S1, S2, S3 pour un S3 terminal)
numero_semestres_possibles = list(range(1, numero_semestre_terminal + 1))
semestres_aggreges = {}
for fid, semestre in semestres_significatifs.items():
# Semestres parmi ceux de n° possibles & qui lui sont antérieurs
if (
semestre.semestre_id in numero_semestres_possibles
and semestre.date_fin <= formsemestre_final.date_fin
):
semestres_aggreges[fid] = semestre
return semestres_aggreges
def get_semx_from_semestres_aggreges(
semXs: dict[(str, int) : pe_trajectoires.SemX],
semestres_a_aggreger: dict[(str, int):FormSemestre],
) -> (str, int):
"""Partant d'un dictionnaire de SemX (de la forme
``{ (nom_rcs, fid): SemX }, et connaissant une liste
de (form)semestres suivis, renvoie l'identifiant
(nom_rcs, fid) du SemX qui lui correspond.
Le SemX qui correspond est tel que :
* le semestre final du SemX correspond au dernier semestre en date des
semestres_a_aggreger
* le rang du SemX est le même que celui des semestres_aggreges
* les semestres_a_aggreger (plus large, car contenant plusieurs
parcours), matchent avec les semestres aggrégés
par le SemX
Returns:
rcf_id: L'identifiant du RCF trouvé
"""
assert semestres_a_aggreger, "Pas de semestres à aggréger"
rangs_a_aggreger = [sem.semestre_id for fid, sem in semestres_a_aggreger.items()]
assert (
len(set(rangs_a_aggreger)) == 1
), "Tous les sem à aggréger doivent être de même rang"
# Le dernier semestre des semestres à regrouper
dernier_sem_a_aggreger = pe_comp.get_dernier_semestre_en_date(semestres_a_aggreger)
semxs_ids = [] # Au cas où il y ait plusieurs solutions
for semx_id, semx in semXs.items():
# Même semestre final ?
if semx.get_formsemestre_id_final() == dernier_sem_a_aggreger.formsemestre_id:
# Les fids
fids_a_aggreger = set(semestres_a_aggreger.keys())
# Ceux du semx
fids_semx = set(semx.semestres_aggreges.keys())
if fids_a_aggreger.issubset(
fids_semx
): # tous les semestres du semx correspond à des sems de la trajectoire
semxs_ids += [semx_id]
if len(semxs_ids) == 0:
return None # rien trouvé
elif len(semxs_ids) == 1:
return semxs_ids[0]
else:
raise "Plusieurs solutions :)"

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"]

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

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,89 @@
"""
from flask import send_file, request
from flask import flash, g, redirect, render_template, request, send_file, url_for
from app.decorators import permission_required, scodoc
from app.forms.pe.pe_sem_recap import ParametrageClasseurPE
from app.models import FormSemestre
from app.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)
form = ParametrageClasseurPE()
cosemestres_tries = pe_comp.tri_semestres_par_rang(cosemestres)
affichage_cosemestres_tries = {
rang: ", ".join([sem.titre_annee() for sem in cosemestres_tries[rang]])
for rang in cosemestres_tries
}
if request.method == "GET":
return _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,
form=form,
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
cosemestres=affichage_cosemestres_tries,
rangs_tries=sorted(affichage_cosemestres_tries.keys()),
)
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")
# request.method == "POST"
if form.validate_on_submit():
jury = pe_jury.JuryPE(annee_diplome, formsemestre_id, options=form.data)
if not jury.diplomes_ids:
flash("aucun étudiant à considérer !")
return redirect(
url_for(
"notes.pe_view_sem_recap",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
# 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"
data = jury.get_zipped_data()
return send_file(
data,
mimetype="application/zip",
download_name=scu.sanitize_filename(jury.nom_export_zip + ".zip"),
as_attachment=True,
)
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()
)
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"),
as_attachment=True,
return redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)

0
app/pe/rcss/__init__.py Normal file
View File

131
app/pe/rcss/pe_rcs.py Normal file
View File

@ -0,0 +1,131 @@
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on 01-2024
@author: barasc
"""
from app.models import FormSemestre
TYPES_RCS = {
"S1": {
"aggregat": ["S1"],
"descr": "Semestre 1 (S1)",
},
"S2": {
"aggregat": ["S2"],
"descr": "Semestre 2 (S2)",
},
"1A": {
"aggregat": ["S1", "S2"],
"descr": "BUT1 (S1+S2)",
},
"S3": {
"aggregat": ["S3"],
"descr": "Semestre 3 (S3)",
},
"S4": {
"aggregat": ["S4"],
"descr": "Semestre 4 (S4)",
},
"2A": {
"aggregat": ["S3", "S4"],
"descr": "BUT2 (S3+S4)",
},
"3S": {
"aggregat": ["S1", "S2", "S3"],
"descr": "Moyenne du S1 au S3 (S1+S2+S3)",
},
"4S": {
"aggregat": ["S1", "S2", "S3", "S4"],
"descr": "Moyenne du S1 au S4 (S1+S2+S3+S4)",
},
"S5": {
"aggregat": ["S5"],
"descr": "Semestre 5 (S5)",
},
"S6": {
"aggregat": ["S6"],
"descr": "Semestre 6 (S6)",
},
"3A": {
"aggregat": ["S5", "S6"],
"descr": "BUT3 (S5+S6)",
},
"5S": {
"aggregat": ["S1", "S2", "S3", "S4", "S5"],
"descr": "Moyenne du S1 au S5 (S1+S2+S3+S4+S5)",
},
"6S": {
"aggregat": ["S1", "S2", "S3", "S4", "S5", "S6"],
"descr": "Moyenne globale (S1+S2+S3+S4+S5+S6)",
},
}
"""Dictionnaire détaillant les différents regroupements cohérents
de semestres (RCS), en leur attribuant un nom et en détaillant
le nom des semestres qu'ils regroupent et l'affichage qui en sera fait
dans les tableurs de synthèse.
"""
TOUS_LES_RCS_AVEC_PLUSIEURS_SEM = [cle for cle in TYPES_RCS if not cle.startswith("S")]
TOUS_LES_RCS = list(TYPES_RCS.keys())
TOUS_LES_SEMESTRES = [cle for cle in TYPES_RCS if cle.startswith("S")]
def get_descr_rcs(nom_rcs: str) -> str:
"""Renvoie la description pour les tableurs de synthèse
Excel d'un nom de RCS"""
return TYPES_RCS[nom_rcs]["descr"]
class RCS:
"""Modélise un regroupement cohérent de semestres,
tous se terminant par un (form)semestre final.
"""
def __init__(self, nom: str, semestre_final: FormSemestre):
self.nom: str = nom
"""Nom du RCS"""
assert self.nom in TOUS_LES_RCS, "Le nom d'un RCS doit être un aggrégat"
self.aggregat: list[str] = TYPES_RCS[nom]["aggregat"]
"""Aggrégat (liste des nom des semestres aggrégés)"""
self.formsemestre_final: FormSemestre = semestre_final
"""(Form)Semestre final du RCS"""
self.rang_final = self.formsemestre_final.semestre_id
"""Rang du formsemestre final"""
self.rcs_id: (str, int) = (nom, semestre_final.formsemestre_id)
"""Identifiant du RCS sous forme (nom_rcs, id du semestre_terminal)"""
self.fid_final: int = self.formsemestre_final.formsemestre_id
"""Identifiant du (Form)Semestre final"""
def get_formsemestre_id_final(self) -> int:
"""Renvoie l'identifiant du formsemestre final du RCS
Returns:
L'id du formsemestre final (marquant la fin) du RCS
"""
return self.formsemestre_final.formsemestre_id
def __str__(self):
"""Représentation textuelle d'un RCS"""
return f"{self.nom}[#{self.formsemestre_final.formsemestre_id}{self.formsemestre_final.date_fin.year}]"
def get_repr(self, verbose=True):
"""Représentation textuelle d'un RCS"""
return self.__str__()
def __eq__(self, other):
"""Egalité de RCS"""
return (
self.nom == other.nom
and self.formsemestre_final == other.formsemestre_final
)

59
app/pe/rcss/pe_rcsemx.py Normal file
View File

@ -0,0 +1,59 @@
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on 01-2024
@author: barasc
"""
from app.models import FormSemestre
from app.pe.moys import pe_sxtag
from app.pe.rcss import pe_rcs, pe_trajectoires
class RCSemX(pe_rcs.RCS):
"""Modélise un regroupement cohérent de SemX (en même regroupant
des semestres Sx combinés pour former les résultats des étudiants
au semestre de rang x) dans le but de synthétiser les résultats
du S1 jusqu'au semestre final ciblé par le RCSemX (dépendant de l'aggrégat
visé).
Par ex: Si l'aggrégat du RCSemX est '3S' (=S1+S2+S3),
regroupement le SemX du S1 + le SemX du S2 + le SemX du S3 (chacun
incluant des infos sur les redoublements).
Args:
nom: Un nom du RCS (par ex: '5S')
semestre_final: Le semestre final du RCS
"""
def __init__(self, nom: str, semestre_final: FormSemestre):
pe_rcs.RCS.__init__(self, nom, semestre_final)
self.semXs_aggreges: dict[(str, int) : pe_sxtag.SxTag] = {}
"""Les semX à aggréger"""
def add_semXs(self, semXs: dict[(str, int) : pe_trajectoires.SemX]):
"""Ajoute des semXs aux semXs à regrouper dans le RCSemX
Args:
semXs: Dictionnaire ``{(str,fid): RCF}`` à ajouter
"""
self.semXs_aggreges = self.semXs_aggreges | semXs
def get_repr(self, verbose=True) -> str:
"""Représentation textuelle d'un RCSF
basé sur ses RCF aggrégés"""
title = f"""{self.__class__.__name__} {pe_rcs.RCS.__str__(self)}"""
if verbose:
noms = []
for semx_id, semx in self.semXs_aggreges.items():
noms.append(semx.get_repr(verbose=False))
if noms:
title += " <<" + "+".join(noms) + ">>"
else:
title += " <<vide>>"
return title

View File

@ -0,0 +1,87 @@
from app.models import FormSemestre
import app.pe.rcss.pe_rcs as pe_rcs
class Trajectoire(pe_rcs.RCS):
"""Regroupement Cohérent de Semestres ciblant un type d'aggrégat (par ex.
'S2', '3S', '1A') et un semestre final, et dont les données regroupées
sont des **FormSemestres** suivis par les étudiants.
Une *Trajectoire* traduit la succession de semestres
qu'ont pu suivre des étudiants pour aller d'un semestre S1 jusqu'au semestre final
de l'aggrégat.
Une *Trajectoire* peut être :
* un RCS de semestre de type "Sx" (cf. classe "SemX"), qui stocke les
formsemestres de rang x qu'ont suivi l'étudiant pour valider le Sx
(en général 1 formsemestre pour les non-redoublants et 2 pour les redoublants)
* un RCS de type iS ou iA (par ex, 3A=S1+S2+S3), qui identifie
les formsemestres que des étudiants ont suivis pour les amener jusqu'au semestre
terminal du RCS. Par ex: si le RCS est un 3S:
* des S1+S2+S1+S2+S3 si redoublement de la 1ère année
* des S1+S2+(année de césure)+S3 si césure, ...
Args:
nom: Un nom du RCS (par ex: '5S')
semestre_final: Le formsemestre final du RCS
"""
def __init__(self, nom: str, semestre_final: FormSemestre):
pe_rcs.RCS.__init__(self, nom, semestre_final)
self.semestres_aggreges: dict[int:FormSemestre] = {}
"""Formsemestres regroupés dans le RCS"""
def add_semestres(self, semestres: dict[int:FormSemestre]):
"""Ajout de semestres aux semestres à regrouper
Args:
semestres: Dictionnaire ``{fid: Formsemestre)``
"""
for sem in semestres.values():
assert isinstance(
sem, FormSemestre
), "Les données aggrégées d'une Trajectoire doivent être des FormSemestres"
self.semestres_aggreges = self.semestres_aggreges | semestres
def get_repr(self, verbose=True) -> str:
"""Représentation textuelle d'un RCS
basé sur ses semestres aggrégés"""
title = f"""{self.__class__.__name__} {pe_rcs.RCS.__str__(self)}"""
if verbose:
noms = []
for fid in self.semestres_aggreges:
semestre = self.semestres_aggreges[fid]
noms.append(f"S{semestre.semestre_id}#{fid}")
noms = sorted(noms)
if noms:
title += " <" + "+".join(noms) + ">"
else:
title += " <vide>"
return title
class SemX(Trajectoire):
"""Trajectoire (regroupement cohérent de (form)semestres
dans laquelle tous les semestres regroupés sont de même rang `x`.
Les SemX stocke les
formsemestres de rang x qu'ont suivi l'étudiant pour valider le Sx
(en général 1 formsemestre pour les non-redoublants et 2 pour les redoublants).
Ils servent à calculer les SemXTag (moyennes par tag des RCS de type `Sx`).
"""
def __init__(self, trajectoire: Trajectoire):
Trajectoire.__init__(self, trajectoire.nom, trajectoire.formsemestre_final)
semestres_aggreges = trajectoire.semestres_aggreges
for sem in semestres_aggreges.values():
assert (
sem.semestre_id == trajectoire.rang_final
), "Tous les semestres aggrégés d'un SemX doivent être de même rang"
self.semestres_aggreges = trajectoire.semestres_aggreges

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

@ -85,17 +85,6 @@ UE_ELECTIVE = 4 # UE "élective" dans certains cursus (UCAC?, ISCID)
UE_PROFESSIONNELLE = 5 # UE "professionnelle" (ISCID, ...)
UE_OPTIONNELLE = 6 # UE non fondamentales (ILEPS, ...)
def ue_is_fondamentale(ue_type):
return ue_type in (UE_STANDARD, UE_STAGE_LP, UE_PROFESSIONNELLE)
def ue_is_professionnelle(ue_type):
return (
ue_type == UE_PROFESSIONNELLE
) # NB: les UE_PROFESSIONNELLE sont à la fois fondamentales et pro
UE_TYPE_NAME = {
UE_STANDARD: "Standard",
UE_SPORT: "Sport/Culture (points bonus)",
@ -104,8 +93,6 @@ UE_TYPE_NAME = {
UE_ELECTIVE: "Elective (ISCID)",
UE_PROFESSIONNELLE: "Professionnelle (ISCID)",
UE_OPTIONNELLE: "Optionnelle",
# UE_FONDAMENTALE : '"Fondamentale" (eg UCAC)',
# UE_OPTIONNELLE : '"Optionnelle" (UCAC)'
}
# Couleurs RGB (dans [0.,1.]) des UE pour les bulletins:
@ -409,6 +396,7 @@ class CursusBUT(TypeCursus):
APC_SAE = True
USE_REFERENTIEL_COMPETENCES = True
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
ECTS_DIPLOME = 180
register_cursus(CursusBUT())

View File

@ -44,13 +44,16 @@ import random
from collections import OrderedDict
from xml.etree import ElementTree
import json
from typing import Any
from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
from openpyxl.utils import get_column_letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame
import reportlab
from reportlab.platypus import Paragraph, Spacer
from reportlab.platypus import Table, KeepInFrame
from reportlab.lib.colors import Color
from reportlab.lib import styles
from reportlab.lib.units import inch, cm, mm
from reportlab.rl_config import defaultPageSize # pylint: disable=no-name-in-module
from reportlab.lib.units import cm
from app.scodoc import html_sco_header
from app.scodoc import sco_utils as scu
@ -62,16 +65,32 @@ from app.scodoc.sco_pdf import SU
from app import log, ScoDocJSONEncoder
def mark_paras(L, tags) -> list[str]:
"""Put each (string) element of L between <tag>...</tag>,
def mark_paras(items: list[Any], tags: list[str]) -> list[str]:
"""Put each string element of items between <tag>...</tag>,
for each supplied tag.
Leave non string elements untouched.
"""
for tag in tags:
start = "<" + tag + ">"
end = "</" + tag.split()[0] + ">"
L = [(start + (x or "") + end) if isinstance(x, str) else x for x in L]
return L
items = [(start + (x or "") + end) if isinstance(x, str) else x for x in items]
return items
def add_query_param(url: str, key: str, value: str) -> str:
"add parameter key=value to the given URL"
# Parse the URL
parsed_url = urlparse(url)
# Parse the query parameters
query_params = parse_qs(parsed_url.query)
# Add or update the query parameter
query_params[key] = [value]
# Encode the query parameters
encoded_query_params = urlencode(query_params, doseq=True)
# Construct the new URL
new_url_parts = parsed_url._replace(query=encoded_query_params)
new_url = urlunparse(new_url_parts)
return new_url
class DEFAULT_TABLE_PREFERENCES(object):
@ -245,16 +264,16 @@ class GenTable:
colspan_count -= 1
# if colspan_count > 0:
# continue # skip cells after a span
if pdf_mode:
content = row.get(f"_{cid}_pdf", False) or row.get(cid, "")
elif xls_mode:
content = row.get(f"_{cid}_xls", False) or row.get(cid, "")
if pdf_mode and f"_{cid}_pdf" in row:
content = row[f"_{cid}_pdf"]
elif xls_mode and f"_{cid}_xls" in row:
content = row[f"_{cid}_xls"]
else:
content = row.get(cid, "")
# Convert None to empty string ""
content = "" if content is None else content
colspan = row.get("_%s_colspan" % cid, 0)
colspan = row.get(f"_{cid}_colspan", 0)
if colspan > 1:
pdf_style_list.append(
(
@ -477,13 +496,15 @@ class GenTable:
H.append('<span class="gt_export_icons">')
if self.xls_link:
H.append(
' <a href="%s&fmt=xls">%s</a>' % (self.base_url, scu.ICON_XLS)
f""" <a href="{add_query_param(self.base_url, "fmt", "xls")
}">{scu.ICON_XLS}</a>"""
)
if self.xls_link and self.pdf_link:
H.append("&nbsp;")
if self.pdf_link:
H.append(
' <a href="%s&fmt=pdf">%s</a>' % (self.base_url, scu.ICON_PDF)
f""" <a href="{add_query_param(self.base_url, "fmt", "pdf")
}">{scu.ICON_PDF}</a>"""
)
H.append("</span>")
H.append("</p>")
@ -582,9 +603,11 @@ class GenTable:
for line in data_list:
Pt.append(
[
Paragraph(SU(str(x)), CellStyle)
if (not isinstance(x, Paragraph))
else x
(
Paragraph(SU(str(x)), CellStyle)
if (not isinstance(x, Paragraph))
else x
)
for x in line
]
)
@ -654,6 +677,7 @@ class GenTable:
fmt="html",
page_title="",
filename=None,
cssstyles=[],
javascripts=[],
with_html_headers=True,
publish=True,
@ -674,6 +698,7 @@ class GenTable:
H.append(
self.html_header
or html_sco_header.sco_header(
cssstyles=cssstyles,
page_title=page_title,
javascripts=javascripts,
init_qtip=init_qtip,
@ -699,7 +724,7 @@ class GenTable:
)
else:
return pdf_doc
elif fmt == "xls" or fmt == "xlsx": # dans les 2 cas retourne du xlsx
elif fmt in ("xls", "xlsx"): # dans les 2 cas retourne du xlsx
xls = self.excel()
if publish:
return scu.send_file(
@ -708,8 +733,7 @@ class GenTable:
suffix=scu.XLSX_SUFFIX,
mime=scu.XLSX_MIMETYPE,
)
else:
return xls
return xls
elif fmt == "text":
return self.text()
elif fmt == "csv":
@ -789,7 +813,10 @@ if __name__ == "__main__":
document,
)
)
document.build(objects)
try:
document.build(objects)
except (ValueError, KeyError, reportlab.platypus.doctemplate.LayoutError) as exc:
raise ScoPDFFormatError(str(exc)) from exc
data = doc.getvalue()
with open("/tmp/gen_table.pdf", "wb") as f:
f.write(data)

View File

@ -30,7 +30,7 @@
import html
from flask import g, render_template
from flask import g, render_template, url_for
from flask import request
from flask_login import current_user
@ -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:
@ -161,7 +163,7 @@ def sco_header(
params = {
"page_title": page_title or sco_version.SCONAME,
"no_side_bar": no_side_bar,
"ScoURL": scu.ScoURL(),
"ScoURL": url_for("scolar.index_html", scodoc_dept=g.scodoc_dept),
"encoding": scu.SCO_ENCODING,
"titrebandeau_mkup": "<td>" + titrebandeau + "</td>",
"authuser": current_user.user_name,
@ -177,6 +179,7 @@ def sco_header(
H = [
"""<!DOCTYPE html><html lang="fr">
<!-- ScoDoc legacy -->
<head>
<meta charset="utf-8"/>
<title>%(page_title)s</title>
@ -189,7 +192,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:
@ -212,26 +220,33 @@ def sco_header(
<script>
window.onload=function(){{enableTooltips("gtrcontent")}};
const SCO_URL="{scu.ScoURL()}";
const SCO_URL="{url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)}";
const SCO_TIMEZONE="{scu.TIME_ZONE}";
</script>"""
)
# 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(
@ -289,13 +304,16 @@ def sco_header(
# div pour affichage messages temporaires
H.append('<div id="sco_msg" class="head_message"></div>')
#
H.append('<div class="sco-app-content">')
return "".join(H)
def sco_footer():
"""Main HTMl pages footer"""
return (
"""</div><!-- /gtrcontent -->""" + scu.CUSTOM_HTML_FOOTER + """</body></html>"""
"""</div></div><!-- /gtrcontent -->"""
+ scu.CUSTOM_HTML_FOOTER
+ """</body></html>"""
)

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)
@ -46,25 +102,33 @@ def sidebar_common():
<a href="{home_link}" class="sidebar">Accueil</a> <br>
<div id="authuser"><a id="authuserlink" href="{
url_for("users.user_info_page",
scodoc_dept=g.scodoc_dept, user_name=current_user.user_name)
scodoc_dept=g.scodoc_dept, user_name=current_user.user_name)
}">{current_user.user_name}</a>
<br><a id="deconnectlink" href="{url_for("auth.logout")}">déconnexion</a>
</div>
{sidebar_dept()}
<h2 class="insidebar">Scolarité</h2>
<a href="{scu.ScoURL()}" class="sidebar">Semestres</a> <br>
<a href="{scu.NotesURL()}" class="sidebar">Programmes</a> <br>
<a href="{
url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)
}" class="sidebar">Semestres</a> <br>
<a href="{
url_for("notes.index_html", scodoc_dept=g.scodoc_dept)
}" class="sidebar">Formations</a> <br>
"""
]
if current_user.has_permission(Permission.AbsChange):
H.append(
f""" <a href="{scu.AssiduitesURL()}" class="sidebar">Assiduité</a> <br> """
f""" <a href="{
url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept)
}" class="sidebar">Assiduité</a> <br> """
)
if current_user.has_permission(
Permission.UsersAdmin
) or current_user.has_permission(Permission.UsersView):
H.append(
f"""<a href="{scu.UsersURL()}" class="sidebar">Utilisateurs</a> <br>"""
f"""<a href="{
url_for("users.index_html", scodoc_dept=g.scodoc_dept)
}" class="sidebar">Utilisateurs</a> <br>"""
)
if current_user.has_permission(Permission.EditPreferences):
@ -85,7 +149,9 @@ def sidebar(etudid: int = None):
params = {}
H = [
f"""<div class="sidebar">
f"""
<!-- sidebar py -->
<div class="sidebar">
{ sidebar_common() }
<div class="box-chercheetud">Chercher étudiant:<br>
<form method="get" id="form-chercheetud"
@ -104,46 +170,57 @@ 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)
nbabsnj = nbabs - nbabsjust
inscription = etud.inscription_courante()
if inscription:
formsemestre = inscription.formsemestre
nbabsnj, nbabsjust, _ = sco_assiduites.formsemestre_get_assiduites_count(
etudid, formsemestre
)
H.append(
f"""<span title="absences du { cur_sem["date_debut"] } au {
cur_sem["date_fin"] }">({
sco_preferences.get_preference("assi_metrique", None)})
<br>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
f"""<span title="absences du {
formsemestre.date_debut.strftime(scu.DATE_FMT)
} au {
formsemestre.date_fin.strftime(scu.DATE_FMT)
}">({
sco_preferences.get_preference("assi_metrique", None)})
<br>{nbabsjust:1g} J., {nbabsnj:1g} N.J.</span>"""
)
H.append("<ul>")
if current_user.has_permission(Permission.AbsChange):
# 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

@ -12,6 +12,7 @@ import psycopg2.extras
from app import log
from app.scodoc.sco_exceptions import ScoException, ScoValueError, NoteProcessError
from app.scodoc import sco_utils as scu
quote_html = html.escape
@ -265,7 +266,7 @@ def DBUpdateArgs(cnx, table, vals, where=None, commit=False, convert_empty_to_nu
# log('vals=%s\n'%vals)
except psycopg2.errors.StringDataRightTruncation as exc:
cnx.rollback()
raise ScoValueError("champs de texte trop long !") from exc
raise ScoValueError("champ de texte trop long !") from exc
except:
cnx.rollback() # get rid of this transaction
log('Exception in DBUpdateArgs:\n\treq="%s"\n\tvals="%s"\n' % (req, vals))
@ -300,6 +301,7 @@ class EditableTable(object):
output_formators={},
input_formators={},
aux_tables=[],
convert_empty_to_nulls=True, # les arguments vides sont traduits en NULL
convert_null_outputs_to_empty=True,
html_quote=False, # changed in 9.0.10
fields_creators={}, # { field : [ sql_command_to_create_it ] }
@ -321,6 +323,7 @@ class EditableTable(object):
self.output_formators = output_formators
self.input_formators = input_formators
self.convert_null_outputs_to_empty = convert_null_outputs_to_empty
self.convert_empty_to_nulls = convert_empty_to_nulls
self.html_quote = html_quote
self.fields_creators = fields_creators
self.filter_nulls = filter_nulls
@ -351,6 +354,7 @@ class EditableTable(object):
self.table_name,
vals,
commit=True,
convert_empty_to_nulls=self.convert_empty_to_nulls,
return_id=(self.id_name is not None),
ignore_conflicts=self.insert_ignore_conflicts,
)
@ -444,7 +448,7 @@ def dictfilter(d, fields, filter_nulls=True):
"""returns a copy of d with only keys listed in "fields" and non null values"""
r = {}
for f in fields:
if f in d and (d[f] != None or not filter_nulls):
if f in d and (d[f] is not None or not filter_nulls):
try:
val = d[f].strip()
except:
@ -457,7 +461,8 @@ def dictfilter(d, fields, filter_nulls=True):
# --- Misc Tools
def DateDMYtoISO(dmy: str, null_is_empty=False) -> str | None: # XXX deprecated
# XXX deprecated, voir convert_fr_date
def DateDMYtoISO(dmy: str, null_is_empty=False) -> str | None:
"""Convert date string from french format (or ISO) to ISO.
If null_is_empty (default false), returns "" if no input.
"""
@ -471,7 +476,7 @@ def DateDMYtoISO(dmy: str, null_is_empty=False) -> str | None: # XXX deprecated
if not isinstance(dmy, str):
raise ScoValueError(f'Date (j/m/a) invalide: "{dmy}"')
try:
dt = datetime.datetime.strptime(dmy, "%d/%m/%Y")
dt = datetime.datetime.strptime(dmy, scu.DATE_FMT)
except ValueError:
try:
dt = datetime.datetime.fromisoformat(dmy)

File diff suppressed because it is too large Load Diff

View File

@ -34,6 +34,7 @@ from app.models.absences import BilletAbsence
from app.models.etudiants import Identite
from app.scodoc.gen_tables import GenTable
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
def query_billets_etud(etudid: int = None, etat: bool = None) -> Query:
@ -89,12 +90,12 @@ def table_billets(
m = " matin"
else:
m = " après-midi"
billet_dict["abs_begin_str"] = billet.abs_begin.strftime("%d/%m/%Y") + m
billet_dict["abs_begin_str"] = billet.abs_begin.strftime(scu.DATE_FMT) + m
if billet.abs_end.hour < 12:
m = " matin"
else:
m = " après-midi"
billet_dict["abs_end_str"] = billet.abs_end.strftime("%d/%m/%Y") + m
billet_dict["abs_end_str"] = billet.abs_end.strftime(scu.DATE_FMT) + m
if billet.etat == 0:
if billet.justified:
billet_dict["etat_str"] = "à traiter"
@ -124,12 +125,12 @@ def table_billets(
else:
billet_dict["nomprenom"] = billet.etudiant.nomprenom
billet_dict["_nomprenom_order"] = billet.etudiant.sort_key
billet_dict[
"_nomprenom_td_attrs"
] = f'id="{billet.etudiant.id}" class="etudinfo"'
billet_dict["_nomprenom_td_attrs"] = (
f'id="{billet.etudiant.id}" class="etudinfo"'
)
if with_links:
billet_dict["_nomprenom_target"] = url_for(
"scolar.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 flash, g, url_for
from flask_mail import Message
from app import db
@ -42,10 +42,10 @@ 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
from app.scodoc import sco_etud
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
@ -67,7 +67,7 @@ def abs_notify(etudid: int, date: str | datetime.datetime):
if not formsemestre:
return # non inscrit a la date, pas de notification
nbabs, nbabsjust = sco_assiduites.get_assiduites_count_in_interval(
_, nbabsjust, nbabs = sco_assiduites.get_assiduites_count_in_interval(
etudid,
metrique=scu.translate_assiduites_metric(
sco_preferences.get_preference(
@ -108,7 +108,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 +174,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,26 +267,35 @@ 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"]
txt = ""
if template:
txt = prefs["abs_notification_mail_tmpl"] % values
try:
txt = prefs["abs_notification_mail_tmpl"] % values
except KeyError:
flash("Mail non envoyé: format invalide (voir paramétrage)")
log("abs_notification_message: invalid key in abs_notification_mail_tmpl")
txt = ""
else:
log("abs_notification_message: empty template, not sending message")
if not txt:
return None
subject = f"""[ScoDoc] Trop d'absences pour {etud["nomprenom"]}"""
subject = f"""[ScoDoc] Trop d'absences pour {etud.nomprenom}"""
msg = Message(subject, sender=email.get_from_addr(formsemestre.departement.acronym))
msg.body = txt
return msg

View File

@ -515,11 +515,13 @@ class ApoEtud(dict):
# ne trouve pas de semestre impair
self.validation_annee_but = None
return
self.validation_annee_but: ApcValidationAnnee = ApcValidationAnnee.query.filter_by(
formsemestre_id=formsemestre.id,
etudid=self.etud["etudid"],
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
).first()
self.validation_annee_but: ApcValidationAnnee = (
ApcValidationAnnee.query.filter_by(
formsemestre_id=formsemestre.id,
etudid=self.etud["etudid"],
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
).first()
)
self.is_nar = (
self.validation_annee_but and self.validation_annee_but.code == NAR
)
@ -1003,7 +1005,7 @@ def comp_apo_sems(etape_apogee, annee_scolaire: int) -> list[dict]:
def nar_etuds_table(apo_data, nar_etuds):
"""Liste les NAR -> excel table"""
code_etape = apo_data.etape_apogee
today = datetime.datetime.today().strftime("%d/%m/%y")
today = datetime.datetime.today().strftime(scu.DATE_FMT)
rows = []
nar_etuds.sort(key=lambda k: k["nom"])
for e in nar_etuds:

View File

@ -49,11 +49,13 @@
"""
import datetime
import glob
import gzip
import mimetypes
import os
import re
import shutil
import time
import zlib
import chardet
@ -62,7 +64,7 @@ from flask import g
import app.scodoc.sco_utils as scu
from config import Config
from app import log
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_exceptions import ScoException, ScoValueError
class BaseArchiver:
@ -241,11 +243,13 @@ class BaseArchiver:
filename: str,
data: str | bytes,
dept_id: int = None,
compress=False,
):
"""Store data in archive, under given filename.
Filename may be modified (sanitized): return used filename
The file is created or replaced.
data may be str or bytes
If compress, data is gziped and filename suffix ".gz" added.
"""
if isinstance(data, str):
data = data.encode(scu.SCO_ENCODING)
@ -255,8 +259,14 @@ class BaseArchiver:
try:
scu.GSL.acquire()
fname = os.path.join(archive_id, filename)
with open(fname, "wb") as f:
f.write(data)
if compress:
if not fname.endswith(".gz"):
fname += ".gz"
with gzip.open(fname, "wb") as f:
f.write(data)
else:
with open(fname, "wb") as f:
f.write(data)
except FileNotFoundError as exc:
raise ScoValueError(
f"Erreur stockage archive (dossier inexistant, chemin {fname})"
@ -274,8 +284,17 @@ class BaseArchiver:
fname = os.path.join(archive_id, filename)
log(f"reading archive file {fname}")
try:
with open(fname, "rb") as f:
data = f.read()
if fname.endswith(".gz"):
try:
with gzip.open(fname) as f:
data = f.read()
except (OSError, EOFError, zlib.error) as exc:
raise ScoValueError(
f"Erreur lecture archive ({fname} invalide)"
) from exc
else:
with open(fname, "rb") as f:
data = f.read()
except FileNotFoundError as exc:
raise ScoValueError(
f"Erreur lecture archive (inexistant, chemin {fname})"
@ -288,6 +307,8 @@ class BaseArchiver:
"""
archive_id = self.get_id_from_name(oid, archive_name, dept_id=dept_id)
data = self.get(archive_id, filename)
if filename.endswith(".gz"):
filename = filename[:-3]
mime = mimetypes.guess_type(filename)[0]
if mime is None:
mime = "application/octet-stream"

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

@ -68,7 +68,7 @@ PV_ARCHIVER = SemsArchiver()
def do_formsemestre_archive(
formsemestre_id,
formsemestre: FormSemestre,
group_ids: list[int] = None, # si indiqué, ne prend que ces groupes
description="",
date_jury="",
@ -92,19 +92,18 @@ def do_formsemestre_archive(
raise ScoValueError(
"do_formsemestre_archive: version de bulletin demandée invalide"
)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
sem_archive_id = formsemestre_id
sem_archive_id = formsemestre.id
archive_id = PV_ARCHIVER.create_obj_archive(
sem_archive_id, description, formsemestre.dept_id
)
date = PV_ARCHIVER.get_archive_date(archive_id).strftime("%d/%m/%Y à %H:%M")
date = PV_ARCHIVER.get_archive_date(archive_id).strftime(scu.DATEATIME_FMT)
if not group_ids:
# tous les inscrits du semestre
group_ids = [sco_groups.get_default_group(formsemestre_id)]
group_ids = [sco_groups.get_default_group(formsemestre.id)]
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids, formsemestre_id=formsemestre_id
group_ids, formsemestre_id=formsemestre.id
)
groups_filename = "-" + groups_infos.groups_filename
etudids = [m["etudid"] for m in groups_infos.members]
@ -142,19 +141,23 @@ def do_formsemestre_archive(
)
# Bulletins en JSON
data = gen_formsemestre_recapcomplet_json(formsemestre_id, xml_with_decisions=True)
data = gen_formsemestre_recapcomplet_json(formsemestre.id, xml_with_decisions=True)
data_js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder)
if data:
PV_ARCHIVER.store(
archive_id, "Bulletins.json", data_js, dept_id=formsemestre.dept_id
archive_id,
"Bulletins.json",
data_js,
dept_id=formsemestre.dept_id,
compress=True,
)
# Décisions de jury, en XLS
if formsemestre.formation.is_apc():
response = jury_but_pv.pvjury_page_but(formsemestre_id, fmt="xls")
response = jury_but_pv.pvjury_page_but(formsemestre.id, fmt="xls")
data = response.get_data()
else: # formations classiques
data = sco_pv_forms.formsemestre_pvjury(
formsemestre_id, fmt="xls", publish=False
formsemestre.id, fmt="xls", publish=False
)
if data:
PV_ARCHIVER.store(
@ -165,7 +168,7 @@ def do_formsemestre_archive(
)
# Classeur bulletins (PDF)
data, _ = sco_bulletins_pdf.get_formsemestre_bulletins_pdf(
formsemestre_id, version=bul_version
formsemestre.id, version=bul_version
)
if data:
PV_ARCHIVER.store(
@ -173,10 +176,11 @@ def do_formsemestre_archive(
"Bulletins.pdf",
data,
dept_id=formsemestre.dept_id,
compress=True,
)
# Lettres individuelles (PDF):
data = sco_pv_lettres_inviduelles.pdf_lettres_individuelles(
formsemestre_id,
formsemestre.id,
etudids=etudids,
date_jury=date_jury,
date_commission=date_commission,
@ -217,7 +221,7 @@ def formsemestre_archive(formsemestre_id, group_ids: list[int] = None):
"""Make and store new archive for this formsemestre.
(all students or only selected groups)
"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
if not formsemestre.can_edit_pv():
raise ScoPermissionDenied(
dest_url=url_for(
@ -320,7 +324,7 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
else:
tf[2]["anonymous"] = False
do_formsemestre_archive(
formsemestre_id,
formsemestre,
group_ids=group_ids,
description=tf[2]["description"],
date_jury=tf[2]["date_jury"],
@ -352,7 +356,7 @@ def formsemestre_list_archives(formsemestre_id):
"""Page listing archives"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
sem_archive_id = formsemestre_id
L = []
archives_descr = []
for archive_id in PV_ARCHIVER.list_obj_archives(
sem_archive_id, dept_id=formsemestre.dept_id
):
@ -366,28 +370,30 @@ def formsemestre_list_archives(formsemestre_id):
archive_id, dept_id=formsemestre.dept_id
),
}
L.append(a)
archives_descr.append(a)
H = [html_sco_header.html_sem_header("Archive des PV et résultats ")]
if not L:
if not archives_descr:
H.append("<p>aucune archive enregistrée</p>")
else:
H.append("<ul>")
for a in L:
for a in archives_descr:
archive_name = PV_ARCHIVER.get_archive_name(a["archive_id"])
H.append(
'<li>%s : <em>%s</em> (<a href="formsemestre_delete_archive?formsemestre_id=%s&archive_name=%s">supprimer</a>)<ul>'
% (
a["date"].strftime("%d/%m/%Y %H:%M"),
a["description"],
formsemestre_id,
archive_name,
)
f"""<li>{a["date"].strftime("%d/%m/%Y %H:%M")} : <em>{a["description"]}</em>
(<a href="{ url_for( "notes.formsemestre_delete_archive", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id, archive_name=archive_name
)}">supprimer</a>)
<ul>"""
)
for filename in a["content"]:
H.append(
'<li><a href="formsemestre_get_archived_file?formsemestre_id=%s&archive_name=%s&filename=%s">%s</a></li>'
% (formsemestre_id, archive_name, filename, filename)
f"""<li><a href="{
url_for( "notes.formsemestre_get_archived_file", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
archive_name=archive_name,
filename=filename
)}">{filename[:-3] if filename.endswith(".gz") else filename}</a></li>"""
)
if not a["content"]:
H.append("<li><em>aucun fichier !</em></li>")
@ -399,7 +405,7 @@ def formsemestre_list_archives(formsemestre_id):
def formsemestre_get_archived_file(formsemestre_id, archive_name, filename):
"""Send file to client."""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
sem_archive_id = formsemestre.id
return PV_ARCHIVER.get_archived_file(
sem_archive_id, archive_name, filename, dept_id=formsemestre.dept_id

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