Compare commits

...

515 Commits

Author SHA1 Message Date
iziram 98e709bfd0 mise à jour master 2023-03-22 10:47:06 +01:00
Emmanuel Viennet cdcb4a2468 Changement de formation d'un formsemestre. Corrige form association. Réorganisation de code. 2023-03-22 09:53:28 +01:00
Emmanuel Viennet 03db1e183e Vérification acronymes dept 2023-03-22 09:53:28 +01:00
Emmanuel Viennet 6194bdc5ed Modifie message d'erreur html 2023-03-22 09:53:28 +01:00
Emmanuel Viennet bfa61cf035 Fix typo in user_name check 2023-03-22 09:53:28 +01:00
Emmanuel Viennet a267c69501 Supprime message sur page login 2023-03-22 09:53:28 +01:00
Emmanuel Viennet e16e4a0ff3 Rationalise accès Etudiant et FormSemestre, avec contrôle systématique du département. 2023-03-22 09:53:28 +01:00
Emmanuel Viennet fec6ccfb28 csrf expiration error page 2023-03-22 09:53:20 +01:00
Emmanuel Viennet d79cd95dff Améliore affichage UE capitalisées BUT en PDF 2023-03-22 09:53:20 +01:00
Emmanuel Viennet c732536922 Anciens formulaires: ajout csrf 2023-03-22 09:53:20 +01:00
Emmanuel Viennet 92d5bd9454 Améliore form bonus 2023-03-22 09:53:20 +01:00
Emmanuel Viennet 7eef38aefe UE capitalisées sur bulletins BUT PDF + code cleaning 2023-03-22 09:53:20 +01:00
Emmanuel Viennet 94ec3266ed Renforce vérification formulaire de login pour éviter de déclencher une erreur SQL 2023-03-22 09:53:20 +01:00
Emmanuel Viennet 5ed92f9080 Fix: archives (typo) 2023-03-22 09:53:20 +01:00
Emmanuel Viennet 212616655b PE: fix (mais calcul coef. non compatible BUT) 2023-03-22 09:53:20 +01:00
Emmanuel Viennet 1f818da064 Fix archive (dup requests), + fix broken link 2023-03-22 09:53:19 +01:00
Emmanuel Viennet c6f81d1301 code cleaning 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 23a118a8cc Fix: typo msg erreur dans export APo 2023-03-22 09:53:19 +01:00
Emmanuel Viennet ca5abc9c22 Fix: API partition_remove_etud 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 0e2a2d4b3b Fix: calcul coût formation quand données manquantes 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 963a326426 Fix: département par défaut edition utilisateur 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 0a093f420f Fix cascade sur modimpl/abs 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 9d76ef4d5d Fix formulaire création utilisateur (mail optionnel) 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 59cfb94b9d Missing migration 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 38262c066a Améliore gestion erreur lors import utilisateurs CAS 2023-03-22 09:53:19 +01:00
Emmanuel Viennet fabe9c90cf Ajout champ User.email_institutionnel 2023-03-22 09:53:19 +01:00
Emmanuel Viennet e2ac77332c Fix: encodage form config CAS 2023-03-22 09:53:19 +01:00
Jean-Marie Place dbcd65e2d4 correction cumul absences justifiées 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 7996ec2ecd Fix migration script 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 3f499f7631 Fix: déclaration table Identite / Unicite codes 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 40b6722743 Fix bonus Sceaux (cas particulier sans bonus) 2023-03-22 09:53:19 +01:00
Emmanuel Viennet f214aa8507 Exports excel recap et jury 2023-03-22 09:53:19 +01:00
Emmanuel Viennet cd56337958 Fix: réactivation de comptes scodoc7 bloqués 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 257ad34724 misc minor code cosmetic : no change 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 99812ca25d CAS: améliore formulaire config. 2023-03-22 09:53:19 +01:00
Emmanuel Viennet 60711d674b Ajout balises BUT pour bulletins PDF. Voir # 587 2023-03-22 09:53:19 +01:00
Emmanuel Viennet d5dfa37b91 Modernisation code: remplace DictDefault 2023-03-22 09:53:12 +01:00
Emmanuel Viennet ee2c9ccb84 Fix: création utilisateur avec CAS par non super-admin 2023-03-22 09:53:12 +01:00
Emmanuel Viennet 407129da0f Bonus IUT Sceaux 2023-03-22 09:53:12 +01:00
Emmanuel Viennet 422a200e88 Fix: ordre semestres cursus sur PV pdf 2023-03-22 09:53:12 +01:00
Emmanuel Viennet d05ec0e4f1 Import config utilisateurs CAS: permet de changer statut active 2023-03-22 09:53:12 +01:00
Emmanuel Viennet 98accd7a6a Utilisateurs:
- désactive automatiquement les comptes scodoc7 avec temp=1
 - améliore table export et affichages.
 - améliore log (et préfixe par 'auth: ')
2023-03-22 09:53:12 +01:00
Emmanuel Viennet 0011427302 Page XP pour dev Seb. 2023-03-22 09:53:12 +01:00
Emmanuel Viennet e02db2a751 CAS: config routes login/logout/validate 2023-03-22 09:53:12 +01:00
Emmanuel Viennet 6ec4011a3d CAS: export/import config comptes via Excel 2023-03-22 09:53:12 +01:00
Emmanuel Viennet 9c2c8d9047 CAS:
- enregistre date derniere connection.
 - nouvelle permission: ScoUsersChangeCASId
 - améliore affichage infos utilisateur.
2023-03-22 09:53:12 +01:00
Emmanuel Viennet be76fc8f42 CAS: options cas_force et cas_allow_scodoc_login, améliorations diverses. 2023-03-22 09:52:45 +01:00
Emmanuel Viennet e159ce883c Remove bp 2023-03-22 09:52:45 +01:00
Jean-Marie Place b1cb4ddea3 add default_partition filter in etud_add_group_infos 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 8fce660173 CAS: corrige formulaire config utilisateur (bug PB) 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 3c42bae235 Fix: édition coefs formation verrouillée 2023-03-22 09:52:45 +01:00
Emmanuel Viennet b1f203bf25 CAS: copy configuration from ScoDoc database at login/logout 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 8dbe5f7926 Envois de mail:
- réglage de l'adresse origine From au niveau global
 et systémtisation de son utilisation.
 - ajout de logs, réglage du log par défaut.
 - modernisation de code.
2023-03-22 09:52:45 +01:00
Emmanuel Viennet 0196d96543 Fix logout logging 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 40017ad69b Améliore détection décisions jurys avant désinscription ou suppression de semestre 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 31581419a7 Génère code par défaut pour les nouvelles UEs 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 25bd2b6e45 Fix user edit dialog 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 2a8ee95df7 CAS: id par défaut 2023-03-22 09:52:45 +01:00
Emmanuel Viennet b98d9c2036 version 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 76236f1125 Ignore CAS config during fresh database upgrade 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 3458e5f611 CAS: améliore UI chargement certificat 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 12d1e7fe99 CAS: Améliore traitement des erreurs 2023-03-22 09:52:45 +01:00
Emmanuel Viennet a407856cbb CAS: options pour SSL 2023-03-22 09:52:45 +01:00
Emmanuel Viennet a7437bfdc5 Table users: cosmetic 2023-03-22 09:52:45 +01:00
Emmanuel Viennet d3ba09e6da CAS: ajout infos pour admin sur table utilisateurs 2023-03-22 09:52:45 +01:00
Emmanuel Viennet 6a0713b432 Migration base pour CAS 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 935ae99e03 CAS: enhance log 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 363de7be76 CAS: synchro configuration 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 2f84d9968c Connexion au CAS (WIP) 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 94d49ac870 version 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 175f65cd1f Closes #565 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 8ddb3eb427 Fix: exports Apogée 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 1a6aa269ee Fix: tri tables 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 9264ac7b31 Rangs UEs et partitions: ne tient pas compte des DEM/DEFs 2023-03-22 09:52:44 +01:00
Emmanuel Viennet fdc819e904 Tests API: mise à jour fichier résultats de référence (calcul du rang DEM) 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 8dce157d06 Fix (tests unitaires) 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 50191e6f77 Rangs moy gen: place les DEM en fin. (WIP: pas encore les autres rangs) 2023-03-22 09:52:44 +01:00
Emmanuel Viennet b359aa5c93 Table recap: masque résultats des DEM et DEF 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 88e092f76b Fix: invalidations cache si désinscription ou DEM individuelle 2023-03-22 09:52:44 +01:00
Emmanuel Viennet de11836479 Création de nouvelles versions de formations: amélioration dialogue, propose systématriquement d'embarquer des formsemestres 2023-03-22 09:52:44 +01:00
Emmanuel Viennet c7731f0455 Améliore signalement des sem. verrouillés sur page édition coefs. 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 88320ce95f Améliore signalement des sem. verrouillés sur page édition programme 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 784b3eea8c Suppression des anciens exemples de publication bulletins 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 6dc19b6f80 Souligne UE capitalisées dans tables recap. 2023-03-22 09:52:44 +01:00
Emmanuel Viennet 6c7f0ef72f Test unitaire superficiel de (presque) toutes les vues du tableau de bord semestre 2023-03-22 09:52:35 +01:00
Emmanuel Viennet c8a70670be PV Jury PDF: refactoring, optimisation, amélioration. 2023-03-22 09:52:21 +01:00
Emmanuel Viennet 78fa0a88cf PDF: meilleure gestion d'erreur si police invalide 2023-03-22 09:50:04 +01:00
Emmanuel Viennet 07f478d6ea Renommage dans UI et code des anciens 'Parcours' ScoDoc en 'Cursus' 2023-03-22 09:49:50 +01:00
Emmanuel Viennet 8e7509b035 Refactoring et uniformisation tables jury/recap. 2023-03-22 09:47:55 +01:00
Emmanuel Viennet 5d77d415a2 Calcul des etuds d'un modimpl avec notes en ATT. Affichage sur tableau bord. Fix tri liste etuds (#595). 2023-03-22 09:46:08 +01:00
Emmanuel Viennet 6809f24cee Interdit modification coefs APC si sems verrouillés 2023-03-22 09:44:58 +01:00
Emmanuel Viennet e62038ea59 WIP: refactoring 2023-03-22 09:43:58 +01:00
Emmanuel Viennet fd34984b29 version 2023-03-22 09:43:45 +01:00
Emmanuel Viennet 3ac806220c WIP: table recap 2023-03-22 09:43:36 +01:00
Emmanuel Viennet f0c0490816 WIP: new code table recap. 2023-03-22 09:42:50 +01:00
Emmanuel Viennet bbcd6d7b33 Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2023-03-22 09:42:27 +01:00
Emmanuel Viennet c98df4529e BUT: dispenses d'UE capitalisées. Voir #537. 2023-03-22 09:42:07 +01:00
iziram 4f71575154 assiduites : fin grosses modifs 2023-02-23 09:30:51 +01:00
iziram b73a02ac67 assiduites : grosses modifs WIP
- trace justificatifs
- migrer entry_date
- calcul des assiduités justifiées
- ajout colonnes user_id et est_just
- bug fix timezone max
- remise à zero séquence, cmd downgrade assiduite (si dept none )
- API : filtrage par user_id et par est_just WIP
2023-02-22 22:40:27 +01:00
Emmanuel Viennet 4c648212dd Fix #607: invalidation cache tables 2023-02-22 20:13:28 +01:00
Emmanuel Viennet 157adf76e4 Amélioration front éditeur partitions 2023-02-22 20:13:28 +01:00
Emmanuel Viennet 29d8c7b0e6 API: unification codes erreur HTTP + check group/partition names 2023-02-22 20:13:28 +01:00
Emmanuel Viennet 300dd4ac22 Implement #601 2023-02-22 20:13:28 +01:00
Emmanuel Viennet baaf8b0244 Fix #283 2023-02-22 20:13:28 +01:00
Emmanuel Viennet f60eba1b9c Test unitaire superficiel de (presque) toutes les vues du tableau de bord semestre 2023-02-22 20:13:28 +01:00
Emmanuel Viennet 48a3950cfa Nouveau test unitaire sur les formsemestres 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 32efafd61d Fix: import formation xml 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 1a80202468 Unit tests: jjout absences dans resultats de reference 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 77723982f5 Building script: integrate full unit tests and API tests 2023-02-22 20:13:17 +01:00
Emmanuel Viennet fd5fefec71 Fix: import formation + tests unitaires formations 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 693f6c9cbd Mise à jour des menus. Feuille prépa jury seulement pour non BUT. 2023-02-22 20:13:17 +01:00
Emmanuel Viennet a9b809655b PV BUUT: options sans détail identité, anonyme et seulement diplômés 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 1e0645fd14 Fix: formulaires: saisie nombres/ exception 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 83e271ad8c Cache table recap: combinaisons evals/jury 2023-02-22 20:13:17 +01:00
Emmanuel Viennet a84a5da836 Absences semestre dans table recap 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 6235f2346e Cache table recap aussi en mode jury 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 831b6a1039 Ré-écriture de formation_list_table 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 8d91505b8b Modernisation code: formations 2023-02-22 20:13:17 +01:00
Emmanuel Viennet cc0eca20fa edition formation: modernisation code 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 0d9b810dd9 Fix syntax error (incorrect Jinja formatter) 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 58fb6ecea0 Fix templates filenames 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 77b877c063 Fix type: cretion formation 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 67e7e6cb1d Add progress bar to wget's build_release.sh for slow connections 2023-02-22 20:13:17 +01:00
Jean-Marie Place a3260f05b0 cancel black formatting 2023-02-22 20:13:17 +01:00
Jean-Marie Place 64f6b01140 fix typo 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 0b118a6947 PV: lettres individuelles: affichage des UEs et autres réparations. 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 1b022231f8 Table jury BUT: boutons 'Competences' et 'RCUEs' 2023-02-22 20:13:17 +01:00
Emmanuel Viennet c78745cd3d Réorganisation du code de génration de PV de jury PDF 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 71114c391d Fix exception si module sans matiere 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 39f7e1b63f Fix: enregistrement autorisations d'inscriptions auto sur sem. BUT impairs 2023-02-22 20:13:17 +01:00
Emmanuel Viennet e68c957c62 PV Jury PDF: refactoring, optimisation, amélioration. 2023-02-22 20:13:17 +01:00
Emmanuel Viennet aa631a8a27 PV jury BUT pdf 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 6dc770f79b PR #603 reprise: état du formulaire preferences. 2023-02-22 20:13:17 +01:00
Jean-Marie Place 72e96abfd0 fix bug "cannot revert preference to global" 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 0bc546853d Modernisation d'une partie des accès aux formations 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 120901d9dc Edition programmes (APC): verrouiller par indice de semestre #599 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 6efd2d2e6e Warning sur fiche étudiant BUT si pas inscrit à un parcours 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 8e77689ff0 Fix: API: descrinscription à un groupe de parcours 2023-02-22 20:13:17 +01:00
Emmanuel Viennet c649c05628 Menu groupes: met en avant l'éditeur de partition 2023-02-22 20:13:17 +01:00
Sébastien Lehmann 5861a2d802 Options de configuration partitions 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 86cf10d26d API: bulletins PDF sans signatures 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 87776b412c PDF: meilleure gestion d'erreur si police invalide 2023-02-22 20:13:17 +01:00
Emmanuel Viennet 3dcf8495b0 Renommage dans UI et code des anciens 'Parcours' ScoDoc en 'Cursus' 2023-02-22 20:13:05 +01:00
Emmanuel Viennet 8d7958c80d Refactoring et uniformisation tables jury/recap. 2023-02-22 20:12:48 +01:00
Emmanuel Viennet 0e5b4f9cb7 Calcul des etuds d'un modimpl avec notes en ATT. Affichage sur tableau bord. Fix tri liste etuds (#595). 2023-02-22 20:12:01 +01:00
Emmanuel Viennet 42a63298b4 Interdit modification coefs APC si sems verrouillés 2023-02-22 20:11:15 +01:00
Emmanuel Viennet 574f7fc376 WIP: refactoring 2023-02-22 20:10:59 +01:00
Emmanuel Viennet 5f9c525d39 version 2023-02-22 20:10:25 +01:00
Emmanuel Viennet 527a73b65a WIP: table recap 2023-02-22 20:10:14 +01:00
Emmanuel Viennet 28baca0696 WIP: new code table recap. 2023-02-22 20:09:43 +01:00
Emmanuel Viennet c0a4f40803 Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2023-02-22 20:09:24 +01:00
Emmanuel Viennet 95b7f813ff BUT: dispenses d'UE capitalisées. Voir #537. 2023-02-22 20:05:55 +01:00
iziram 3c227562ff assiduites : bug fix localize aware datetime 2023-02-20 22:37:51 +01:00
iziram b9554cd6b5 assiduites : meilleur gestion date UTC 2023-02-20 22:21:26 +01:00
iziram 6468bb30e4 erratum + oubli bug fix date api 2023-02-20 18:12:49 +01:00
iziram 104a7058a1 bug fix: filtrage par date 2023-02-20 14:20:54 +01:00
iziram 345a842f59 modif make_samples.py : spécification d'un fichier de samples 2023-02-15 10:32:35 +01:00
iziram 5aeffcbf1d module assiduité: corrections linter 2023-02-14 10:57:02 +01:00
Emmanuel Viennet 84ac334b13 Legende sur boutons table 2023-02-14 09:24:54 +01:00
Emmanuel Viennet 84a333c6d9 Fix typo (export table xls) 2023-02-14 09:24:54 +01:00
Emmanuel Viennet f78653c184 Fix typo 2023-02-14 09:24:54 +01:00
Emmanuel Viennet ca5ea5148d Table jury BUT: colonnes (niveaux de) compétences par année du parcours. WIP, A OPTIMISER 2023-02-14 09:24:54 +01:00
Emmanuel Viennet 7c25e14dda Table recap: bouton cols 'Vides' seulement si il y en a. 2023-02-14 09:24:54 +01:00
Emmanuel Viennet 06ad4f39a9 Renommage dans UI et code des anciens 'Parcours' ScoDoc en 'Cursus' 2023-02-14 09:24:54 +01:00
Emmanuel Viennet 41a4610b7a Scroll table pour avoir l'étudiant selectionné visible - à tester sur d'autres navigateurs? 2023-02-14 09:24:54 +01:00
Emmanuel Viennet 12ce1d16f2 Ajout colonne autorisations passage dans table recap jury 2023-02-14 09:24:54 +01:00
Sébastien Lehmann 462db3f9ef Partition editor : liens vers etu + bug non affect 2023-02-14 09:24:54 +01:00
Emmanuel Viennet 107341efab Refactoring et uniformisation tables jury/recap. 2023-02-14 09:24:53 +01:00
Emmanuel Viennet 7739530383 Tests YAML: check autorisations inscriptions 2023-02-14 09:22:09 +01:00
Emmanuel Viennet 7018a41425 Tests YAML: séparation fct spécifiques BUT 2023-02-14 09:22:09 +01:00
Emmanuel Viennet a992d80982 Fix: nom champ cursus (code_cursus) dans resultats API (test ok) 2023-02-14 09:22:09 +01:00
Emmanuel Viennet a8f97bedde Jurys avec notes en ATTente. #592 2023-02-14 09:22:09 +01:00
Emmanuel Viennet 73a242663f Calcul des etuds d'un modimpl avec notes en ATT. Affichage sur tableau bord. Fix tri liste etuds (#595). 2023-02-14 09:22:09 +01:00
Emmanuel Viennet eae49810dd Fix: ordre cols code UE dans table recap 2023-02-14 09:20:58 +01:00
Emmanuel Viennet 6bafcdbcac Tables recap: front: boutons vis cols 2023-02-14 09:20:58 +01:00
Emmanuel Viennet 323522a27c recap: place col. ues_validables 2023-02-14 09:20:58 +01:00
Emmanuel Viennet 9a7c98e906 Fonction ano users 2023-02-14 09:20:58 +01:00
Emmanuel Viennet c5e7012237 Suppression ref. comp. et dept: cascades. 2023-02-14 09:20:58 +01:00
Emmanuel Viennet 83d0200e57 Test unit geii88. Closes #569 2023-02-14 09:20:57 +01:00
Emmanuel Viennet 18d5dc5fbd Jury BUT: ne considère que les UE capitalisées ADM dans les RCUE des redoublants. 2023-02-14 09:20:57 +01:00
Emmanuel Viennet 3fb157e316 encodage apostrophes 2023-02-14 09:20:57 +01:00
Emmanuel Viennet 613837ac37 cosmetic table 2023-02-14 09:20:57 +01:00
Emmanuel Viennet bd1c2f1cb3 Amélioration table recap. Cas sans moyenne gen. 2023-02-14 09:20:57 +01:00
Emmanuel Viennet 8ecdfd4e62 cas tables vides 2023-02-14 09:20:57 +01:00
Emmanuel Viennet db58c57a78 Interdit modification coefs APC si sems verrouillés 2023-02-14 09:20:57 +01:00
Emmanuel Viennet c49bc44700 tables: reorganisation, corrections. 2023-02-14 09:20:20 +01:00
Emmanuel Viennet ecb3748c85 Tableau recap: export xls. (et abandon de l'export CSV). 2023-02-14 09:20:20 +01:00
Emmanuel Viennet 40919063f8 code cosmetic 2023-02-14 09:20:20 +01:00
Emmanuel Viennet 6a985f8558 Table recap: ok pour APC et classic, recap et jury. 2023-02-14 09:20:20 +01:00
Emmanuel Viennet 91176d282c WIP: table jury. 2023-02-14 09:20:20 +01:00
Emmanuel Viennet c45a229e95 Recap: resserre colonnes Apo et Type 2023-02-14 09:20:20 +01:00
Emmanuel Viennet b8f5cf712f Styles table recap 2023-02-14 09:20:20 +01:00
Emmanuel Viennet 705fcc31cd WIP: refactoring code gen. tables 2023-02-14 09:20:20 +01:00
Emmanuel Viennet b7b12d20ad API: formsemestre_resultat avec nouvelle table + test unitaire 2023-02-14 09:20:20 +01:00
Emmanuel Viennet 5144f9b8e1 Added API unit test for formsemestre_resultat 2023-02-14 09:20:20 +01:00
Emmanuel Viennet 0bd873ba65 WIP: refactoring table recap 2023-02-14 09:20:20 +01:00
Emmanuel Viennet e84e55467a WIP: refactoring 2023-02-14 09:20:20 +01:00
Emmanuel Viennet 87ff6b793c Ameliore affichage et export des malus dans table recap. 2023-02-14 09:19:59 +01:00
Emmanuel Viennet 95a3a74ce8 Nouvelle table recap avec malus. 2023-02-14 09:19:59 +01:00
Emmanuel Viennet 48f54fa232 version 2023-02-14 09:19:59 +01:00
Emmanuel Viennet bf6d718e47 WIP: table recap 2023-02-14 09:19:21 +01:00
Emmanuel Viennet d77f745437 WIP: new code table recap. 2023-02-14 09:18:55 +01:00
iziram 0f3e1ea95e module assiduités : fusion cache WIP 2023-02-13 17:50:58 +01:00
iziram aa956f4530 migration abs : fusion + cmd downgrade 2023-02-11 14:26:08 +01:00
iziram 21f57aab8f migration abs ->assiduites : statistiques 2023-02-10 14:08:31 +01:00
iziram 53c9658ce1 optimisation migration abs to assiduites (WIP) 2023-02-09 21:04:53 +01:00
iziram e18990d804 assiduites : Nouveau comptage + script migration (ajout progresse bar + options) 2023-02-08 19:48:34 +01:00
iziram c11599b64f script migration abs -> assiduites (WIP) 2023-02-07 18:49:51 +01:00
iziram 095eb6ce20 module assiduites : rework dates + rev (tests unit test api )
module assiduites : rework dates + rev (tests unit test api )

oubli fichier
2023-02-07 15:33:09 +01:00
iziram 61d4186ad3 module assiduites & justificatifs : révisions
module assiduites : révisions 

module assiduites/justificatifs : révisions 
2023-02-03 16:24:29 +01:00
iziram 4d72fec42d correction etat branche + tests unitaire + tests api 2023-02-03 14:51:05 +01:00
Emmanuel Viennet a63e14ce06 Améliore messages d'erreur si maquette Apo mal encodée 2023-02-03 11:41:09 +01:00
Emmanuel Viennet ba909d72f0 Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2023-02-03 11:40:52 +01:00
Emmanuel Viennet 44a72c1ab9 BUT: dispenses d'UE capitalisées. Voir #537. 2023-02-03 11:39:18 +01:00
iziram 2fd1b039f4 justificatif: test model + api 2023-02-03 10:40:51 +01:00
Emmanuel Viennet 0f8998c891 Fix: suppression semestre avec dispense UE 2023-02-03 08:42:25 +01:00
Emmanuel Viennet be367de2a1 Modif message tableau bord sem. 2023-02-03 08:42:25 +01:00
Emmanuel Viennet e81ad610b6 typo 2023-02-03 08:42:25 +01:00
Emmanuel Viennet 0f45101000 get_formsemestre => ScoValueError 2023-02-03 08:42:25 +01:00
Emmanuel Viennet 1224b46846 Améliore messages d'erreur si maquette Apo mal encodée 2023-02-03 08:42:25 +01:00
Emmanuel Viennet 6b2ea5c5bc Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2023-02-03 08:41:58 +01:00
iziram a7b856b1ec simplification enum + fonction generic + revisions 2023-02-02 22:20:25 +01:00
iziram 547040bb93 justificatifs : archivage+test api (sauf export) 2023-02-01 20:00:14 +01:00
iziram 8bc780f2cf api justificatif : modèle + api ( archivage) 2023-02-01 15:08:06 +01:00
Emmanuel Viennet 86f5751e79 version 2023-02-01 14:56:16 +01:00
Emmanuel Viennet b160f64e4f 9.4.35 2023-02-01 14:56:16 +01:00
Emmanuel Viennet ee2ac9d986 Test API: fix (login utilisateur unique) 2023-02-01 14:56:16 +01:00
Emmanuel Viennet fc78484186 Fix test API formation et ajout d'un test (test_formation_export_with_ids) 2023-02-01 14:56:16 +01:00
Emmanuel Viennet 21b5474a6f Fix test unit: test_formations 2023-02-01 14:56:15 +01:00
Emmanuel Viennet 3c1acc9c00 Améliore messages d'erreur si maquette Apo mal encodée 2023-02-01 14:56:15 +01:00
Emmanuel Viennet 2548a97515 Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2023-02-01 14:56:00 +01:00
Emmanuel Viennet ce1cb7516b BUT: dispenses d'UE capitalisées. Voir #537. 2023-02-01 14:51:19 +01:00
iziram cf3258f5f9 Assiduites : révisions + corrections linter 2023-01-31 16:23:49 +01:00
Emmanuel Viennet 3998b5a366 Table recap: efface données client cachées si erreur. 2023-01-31 15:25:55 +01:00
Emmanuel Viennet 728010bf69 Templates Jinja2: extension .j2 au lieu de .html 2023-01-31 15:25:55 +01:00
Emmanuel Viennet e9f23d8b3e Suppression de toutes les décisions de jury d'un semestre 2023-01-31 15:25:55 +01:00
Emmanuel Viennet f6d442beb4 Bonus IUT Valencienne 2023-01-31 15:25:55 +01:00
Emmanuel Viennet b19c94a1f4 Ajout champ commentaire dans les formations (=> migration) 2023-01-31 15:25:55 +01:00
Emmanuel Viennet 9fb70aef5d Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2023-01-31 15:25:55 +01:00
Emmanuel Viennet c8c05ecd77 BUT: dispenses d'UE capitalisées. Voir #537. 2023-01-31 15:25:55 +01:00
iziram 02ec55ca18 - Modifi comportement Batch
- Ajout description assiduite
2023-01-30 15:12:31 +01:00
Emmanuel Viennet b30d5eb996 Bonus Besançon - Vesoul 2023-01-30 13:33:08 +01:00
Emmanuel Viennet e33bc1e303 Modernise code (evals) 2023-01-30 13:33:08 +01:00
Emmanuel Viennet d2362c1080 Fix: saisie auto sur étud. redoublant puis défaillant 2023-01-30 13:33:08 +01:00
Emmanuel Viennet 6e4bf424e5 Form. passage: # 566 2023-01-30 13:33:08 +01:00
Emmanuel Viennet 2170b06e33 Améliore qq explications 2023-01-30 13:33:08 +01:00
Emmanuel Viennet 696f4a5410 Accès lecture décisions jury BUT depuis la fiche. Améliore navigation. MAJ textes de référence. 2023-01-30 13:33:08 +01:00
Emmanuel Viennet 9880645c01 Jurys BUT: modif. autorisations passage. Cosmetic css. 2023-01-30 13:33:08 +01:00
Emmanuel Viennet 4d0ea06559 Améliore import/export formations APC. 2023-01-30 13:33:08 +01:00
Emmanuel Viennet f46e3f6db5 typo 2023-01-30 13:33:08 +01:00
Emmanuel Viennet 4d66fb13ee Fix: tri noms étudiants accentués sur form saisie notes 2023-01-30 13:33:08 +01:00
Emmanuel Viennet 138f9597f5 Fix: avis poursuites études 2023-01-30 13:33:08 +01:00
Emmanuel Viennet 91e8c9185b Fix #578 API : Gestion semestre verrouillé. + tests unitaires API OK. 2023-01-30 13:33:08 +01:00
Emmanuel Viennet f3b2c6d4fe Fix #564 Passage de semestre: inscrire aux groupes de parcours si ils existent 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 7277c9f999 critical errors handler (xp) 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 9a19919bae Fix #570 liens PREV/NEXT si un seul étudiant dans le semestre 2023-01-30 13:33:07 +01:00
Emmanuel Viennet d97c0c08aa Lien navigation sur jury BUT sem. impair 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 325978a175 Fix #571 cursus BUT avec validation antérieure 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 135ca9fc1c Fix #576: invalidation cache lors des modifs assoc UE/refcomp 2023-01-30 13:33:07 +01:00
Emmanuel Viennet a4072efe4c Saisie automatique des décisions de jury BUT pour semestres pairs ou impairs. 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 4430eb9a61 Fix: missing import 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 073c3c7c44 Fix #573 (API set group) 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 75b87b24de Fix #569 front: ADJR 2023-01-30 13:33:07 +01:00
Emmanuel Viennet e0f6b022b1 Fix #568: affichage cursus 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 98c6761f6a Fix #572: Affichage date dans table Description du semestre 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 53514ef919 Envoi mail bulletins: bcc multiple addr. 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 294ce1d708 Fix: ordre col. tableau recap (groupe admission) 2023-01-30 13:33:07 +01:00
Emmanuel Viennet cf63e1c038 Fix: affichage cursus BUT si un sem n'a pas de ref. comp. 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 584a7af2a1 Jury et cursus BUT: ajout d'informations + modif fiche étudiant 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 635320fd62 Tests cas S1/S2/S1-red 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 6867974957 Form config APo: ADJR 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 6ad415dfca Amélioration tests. Cas geii84 OK 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 2919ff517c Jury BUT: RCUE redoublés: l'UE impaire doit être actuellement meilleure que celle éventuellement capitalisée 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 89948db135 Vérifie type id pour vues ScoDoc7 2023-01-30 13:33:07 +01:00
Emmanuel Viennet bdf90dfd69 WIP: Affichage validation cursus BUT sur page etudiant. 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 0b9c9be222 Fix: list_but_ue_inscriptions si aucun étudiant. 2023-01-30 13:33:07 +01:00
Emmanuel Viennet b5cf210112 Ajout ligne avec type sur table recap. Voir #561. Mais pas exporté en Excel. 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 6833a28274 Fix ordre colonnes res/sae dans tableau recap. 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 5753ac92f4 JS table recap: initialisation des labels des boutons 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 16cc35f63c Pas de warning si UE/module bonus non associé à un niveau 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 5e0922a4bf Fix #559 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 10148bc7c0 Affichage table recap BUT si pas de moyenne générale: pas de rangs 2023-01-30 13:33:07 +01:00
Emmanuel Viennet 556d8e7cbf Implémente #557 2023-01-30 13:33:07 +01:00
Emmanuel Viennet c6b2af5635 moved code example 2023-01-30 13:33:06 +01:00
Emmanuel Viennet cc0c544519 Exemple utilisation sco_archive 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 71116e6b39 Dispenses d'UE BUT associées à un formsemestre 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 3121a6d54c BUT: dispenses d'UE / jury avec RCUE incomplet 2023-01-30 13:33:06 +01:00
pascal.bouron 83afc1d6a0 Mise à jour de 'app/static/js/releve-but.js'
Suppression du block moyenne lorsque `data.options.block_moyenne_generale`   est true
2023-01-30 13:33:06 +01:00
Emmanuel Viennet 5fc08b9716 form inscription/desinscription à toutes les UEs du BUT 2023-01-30 13:33:06 +01:00
Emmanuel Viennet e7559b7a78 Bulletins BUT json: ajout champs block_moyenne_generale et bgcolor 2023-01-30 13:33:06 +01:00
Emmanuel Viennet d8a98b6e5b msg erreur 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 1287aecc4b Fix: cascades sur modimpls 2023-01-30 13:33:06 +01:00
Sébastien Lehmann 549323e781 Saisie notes : masquer DEM & ne pas copier coller 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 0ff5fa46d9 Améliore saisie 'automatique' des décisions BUT 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 8d124eca3e Modif log en mode TEST 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 6a7638d7ff Log enregistrement jurys BUT 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 452bbf2885 typo 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 4915852d66 Jury BUT: fix enregistrement décisions + message cohérence 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 85f00c7cb6 Jury BUT: amélioration front et back. Voir #547. Tests YAML: refonte circuit jury. Cas lyon43. Tests ok. 2023-01-30 13:33:06 +01:00
Emmanuel Viennet b8b3fbb324 Fix: bulletin avec UE sans ECTS 2023-01-30 13:33:06 +01:00
Emmanuel Viennet dd93d952d7 Tests YAML jury BUT: amélioration code test + yaml GEII Lyon ok 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 00c09b1eb8 Fix: bulletin BUT json des démissionnaires. Closes #553 2023-01-30 13:33:06 +01:00
Emmanuel Viennet 0e628273cf Contraint champs formsemestre non nulls 2023-01-30 13:33:06 +01:00
pascal.bouron 664d5483fc Mise à jour de 'app/scodoc/sco_moduleimpl_status.py'
Correction PR#551 qui ne traitait pas le cas des évaluations incompletes lorsque evaluation.publish_incomplete
2023-01-30 13:33:06 +01:00
Emmanuel Viennet b4eab5fcbc Avertissements dates sur tableau bord semestre 2023-01-30 13:33:06 +01:00
Emmanuel Viennet c551634417 Merge -> 9.4.24 2023-01-30 13:33:06 +01:00
pascal.bouron efe8673e8a Mise à jour de 'app/scodoc/sco_moduleimpl_status.py'
Correction ScoDoc/ScoDoc#550 :

Ajout du cas des évaluations "en attente" comportant des ATT.
2023-01-30 13:33:06 +01:00
Sébastien Lehmann f17b10da3b Filtres + affectation non affectés 2023-01-30 13:33:06 +01:00
Emmanuel Viennet cf18520e9c Jury BUT: amélioration gestion redoublants + #547 (WIP) 2023-01-30 13:33:06 +01:00
Emmanuel Viennet cda20c27b2 WIP: Test jury BUT: GEII Lyon 2023-01-30 13:33:06 +01:00
Sébastien Lehmann b7983a8d59 Nouvelle version editeur partitions 2023-01-30 13:33:05 +01:00
Emmanuel Viennet 47b3eec14b formsemestre_status: warning si toutes evals visibles 2023-01-30 13:33:05 +01:00
Emmanuel Viennet 9f6068caa2 Updater: DEBIAN_FRONTEND=noninteractive 2023-01-30 13:33:05 +01:00
Emmanuel Viennet 04277d1f57 Fix: formsemestre_note_etuds_sans_notes 2023-01-30 13:33:05 +01:00
Emmanuel Viennet f9d15da553 Tests Yaml: saisie notes non numériques (EXC, ABS, ...) 2023-01-30 13:33:05 +01:00
Emmanuel Viennet a7126990f0 Fix typo 2023-01-30 13:33:05 +01:00
Emmanuel Viennet 42d92cb998 Warning si poids non éditables 2023-01-30 13:33:05 +01:00
Emmanuel Viennet 2d3d7d49fc Ajout commentaires 2023-01-30 13:33:05 +01:00
Emmanuel Viennet 277e87add9 Fix erreur si changement jours travaillés 2023-01-30 13:33:05 +01:00
Emmanuel Viennet fffb07d612 Jury BUT: Messages d'erreur si pas de ref. comp. 2023-01-30 13:33:05 +01:00
Emmanuel Viennet afe9ae69a9 Change année copyright 2023-01-30 13:33:05 +01:00
Emmanuel Viennet 16f953caf6 Fix: tri groupes sans numeros 2023-01-30 13:33:04 +01:00
iziram f3b1b8a3cb corrections REV#Emm + samples API (WIP) 2023-01-05 17:47:32 +01:00
Emmanuel Viennet 7adc7d824b add some type annotations 2022-12-27 09:22:26 +01:00
Emmanuel Viennet cf900d2027 Fix: jury BUT si UE non associée à comp. 2022-12-27 09:22:26 +01:00
Emmanuel Viennet 1fa8375b11 BUT: dispenses d'UE capitalisées. Voir #537. 2022-12-27 09:22:26 +01:00
Emmanuel Viennet bdefa111a7 Jury BUT: stats jury, #425 2022-12-26 07:58:09 +01:00
Emmanuel Viennet c5b2df379e Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2022-12-26 07:58:08 +01:00
Emmanuel Viennet 3460b217dd Ajout colonne cursus sur tableaux recap BUT. Saisie jury sur sem. impairs avec tableau réduit. 2022-12-26 07:56:11 +01:00
Emmanuel Viennet 18aed44644 Remplissage notes manquantes par groupes. closes #534 2022-12-26 07:56:11 +01:00
Emmanuel Viennet ec632dd43c Jury BUT: complète logs étudiants. + cosmetic 2022-12-26 07:56:11 +01:00
Emmanuel Viennet acc1ecf906 Tests YAML: permet d'indiquer la décision de jury sur les UEs 2022-12-26 07:56:11 +01:00
Emmanuel Viennet 9566551e7e Améliore visu jury BUT. + minor code cleaning. 2022-12-26 07:56:11 +01:00
Emmanuel Viennet 7e1b0177f0 WIP: jury BUT avec redoublements (à compléter). 2022-12-26 07:56:11 +01:00
Emmanuel Viennet 8e6dc37a87 BUT: jury inter-année pour les redoublants 2022-12-26 07:56:11 +01:00
Emmanuel Viennet a4840f494b Fix: acces photo sans photos ni portail 2022-12-26 07:56:11 +01:00
Emmanuel Viennet a28f58a443 Test yaml GMP: ajoute S1 redoublé 2022-12-26 07:56:11 +01:00
Emmanuel Viennet 2a41cf972c Test yaml GMP: inscrit à un parcours 2022-12-26 07:56:11 +01:00
iziram f96571f520 test api assiduite + correction problèmes 2022-12-22 21:36:09 +01:00
iziram 4df1bdda8e tests unitaires Modèle + MAJ fichier migration 2022-12-20 20:02:26 +01:00
Emmanuel Viennet 6c88dfa722 Test unitaire 'GMP Le Mans'. Modification calcul des niveaux de parcours (cas étudiants non inscrits). Modification contrainte unicité validation année. 2022-12-20 10:02:20 +01:00
iziram b9f3db91d4 tests unitaires assiduites 2022-12-19 21:32:45 +01:00
Emmanuel Viennet 3e2631b94d BUT: dispenses d'UE capitalisées. Voir #537. 2022-12-19 13:32:43 +01:00
Emmanuel Viennet 9251810814 Tests unitaires yaml: check des RCUEs 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 4c83c69f7c API: rétabli formation.referentiel_competence_id. Tous tests OK. 2022-12-19 13:28:38 +01:00
Emmanuel Viennet b09dc63fe3 N'exporte pas le ref. comp. dans les formations 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 075d864de3 Fix: API formsemestre (parcours) 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 6440ca4a1f Fix API: formsemestres_courants 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 7d2d19f3a8 Tests unit BUT 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 882d131837 typo in preferences 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 3012fc465d Tests Yaml: vérification des résultats jury + fix explanation 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 7069fb6e31 Tests Yaml: vérification des résultats jury 2022-12-19 13:28:38 +01:00
Emmanuel Viennet be2d7926bf Tests: modif programme test GB 2022-12-19 13:28:38 +01:00
Emmanuel Viennet bc6d9d5442 Test unit. logo: désactive vérification contenu répertoire 2022-12-19 13:28:38 +01:00
Emmanuel Viennet a0a6dbea00 Pas d'UEs externes en BUT. Voir #542 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 872e741d9f Check APC conformity: cas UE de parcours 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 5258a570a6 Fix: affichage d'une UE capitalisée sans ECTS (None) 2022-12-19 13:28:38 +01:00
Emmanuel Viennet f0da8434a9 Groupes de parcours: API, avertissements. 2022-12-19 13:28:38 +01:00
Emmanuel Viennet e995228ca7 Ameliore gestion groupes de parcours 2022-12-19 13:28:38 +01:00
Emmanuel Viennet e59fce5f6b Modifie le calcul de l'ensemble des UE si aucun parcours BUT n'est coché: prend toutes. 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 0d9338dc0a Fix: jury BUT / UE si pas de résultat, tableau bord module si absence de poids. 2022-12-19 13:28:38 +01:00
Emmanuel Viennet f1fd4d98d7 Tests unitaires yaml: reset sequences to get same ids 2022-12-19 13:28:38 +01:00
Emmanuel Viennet c6e35dd4cd Cosmetic: BUT SAE apres res. 2022-12-19 13:28:38 +01:00
Emmanuel Viennet cb8d313dc7 Cosmetic: BUT ue_table: cache UE rattachement pour res. et SAE 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 3e3b09134d Fix tableau bord module si aucune eval. 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 7b3c50620b Cosmetic: tableau bord module: normalise poids évaluations pour Hinton Map 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 63fb09348d Cosmetic: tableau bord module: code + présentation 2022-12-19 13:28:38 +01:00
Emmanuel Viennet 7a9dc11af3 Améliore édition groupe: message si pas partition non éditable 2022-12-19 13:28:38 +01:00
Sébastien Lehmann d178c636bf Correctif relevé tri UEs capitalisées 2022-12-19 13:28:38 +01:00
Emmanuel Viennet c6a06266fa Corrige calcul liste UEs BUT: cas où le semestre n'est déclaré dans aucun parcours. 2022-12-19 13:28:38 +01:00
Emmanuel Viennet e1504adc03 BUT: dispenses d'UE capitalisées. Voir #537. 2022-12-19 13:28:38 +01:00
iziram a9615bc077 Ajout:
- Route formsemstre Extension Count & Query
Correction:
  - Route delete : mise en conformité avec la documentation
  - Simplifcation fonction de gestion des métriques
2022-12-16 10:13:40 +01:00
iziram 3ff4abd19c API Assiduites :
- Comptage
- Filtrage par Formsemestre
- Envoi en lot (create / delete)
2022-12-14 17:41:57 +01:00
Emmanuel Viennet 0a1a847044 Modif handler ScoBugCatcher pour mode dev 2022-12-13 10:12:20 +01:00
Emmanuel Viennet f5442b924f Fix unit tests setup 2022-12-13 10:12:20 +01:00
Emmanuel Viennet bec4cd7978 BUT: corrige affichage coefs UE tableau sem., et niveaux sur fiche etud. + unit tests 2022-12-13 10:12:20 +01:00
Emmanuel Viennet f63fa43862 WIP: liste des UE d'un semestre avec parcours 2022-12-13 10:12:19 +01:00
Emmanuel Viennet ca20c303f0 BUT: tests unitaires yaml: associe modules/parcours + fix formation GB exemple 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 014886c288 BUT: tests unitaires yaml avec association UE/Competences 2022-12-13 10:12:19 +01:00
Emmanuel Viennet e2110f4abb BUT: corrige calcul inscriptions UE de parcours 2022-12-13 10:12:19 +01:00
Emmanuel Viennet ff12f4312e Jury BUT: affichage si UE non associées 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 930a96b984 WIP: Nouveaux tests unitaires pour les cursus BUT 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 60fa12df81 Corrige annulation dispense d'UE APC 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 0324771aa2 évite pandas FutureWarning, et considère que dans les parcours sans UE, les étudiants sont inscrits à toutes. 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 688fc5401f Gestion du champ 'boursier' 2022-12-13 10:12:19 +01:00
Emmanuel Viennet c1cbd6bce0 Edition du champ 'boursier' 2022-12-13 10:12:19 +01:00
Emmanuel Viennet f2ffd69fe6 Automatise les tests unitaires de l'API 2022-12-13 10:12:19 +01:00
Emmanuel Viennet ba5b5cdb6f Fix regression in API/formsemestre_etudiants 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 51b0ca088c Fix unit tests 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 9c618692d1 Fix: API bul JSON classic cap (...) 2022-12-13 10:12:19 +01:00
Emmanuel Viennet a0c33b3c19 Enregistrement de l'étape lors de l'inscription au semestre 2022-12-13 10:12:19 +01:00
Emmanuel Viennet ef1b28fe27 Edition UEs: renumérote si besoin 2022-12-13 10:12:19 +01:00
Emmanuel Viennet f246d9e82c Fix: API bulletins JSON classic sans matières 2022-12-13 10:12:19 +01:00
Emmanuel Viennet cd36737460 API: ajout champ dept_name dans /departements et /departement 2022-12-13 10:12:19 +01:00
Emmanuel Viennet acb8e6aab2 Add col. version in refcomp table 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 8ef19b14c7 Nouvelles versions des ref. de comp. GACO, QLIO, SD fournies par Orebut. 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 7af381becc Fix: do_formsemestre_inscription_with_modules args 2022-12-13 10:12:19 +01:00
Emmanuel Viennet c9b4058717 Fix: bul. classique JSON format long_mat avec UE cap. 2022-12-13 10:12:19 +01:00
Emmanuel Viennet 42b03dbdfa Fix: bug rare si cache modimpl_results non en accord avec modimpl.evaluations 2022-12-13 10:12:19 +01:00
Emmanuel Viennet a87dbd9927 BUT: dispenses d'UE capitalisées. Voir #537. 2022-12-13 10:12:19 +01:00
Emmanuel Viennet ae9aad0619 Modification calcul bonus IUT St Nazaire 2022-12-13 10:12:19 +01:00
Emmanuel Viennet e38d4bde81 Prépare tests unitaires jury BUT avec parcours 2022-12-13 10:12:18 +01:00
Sébastien Lehmann 25d1132a06 Correction ref compétences 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 4c730a6302 API: formsemestre/bulletins au format long_mat. 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 287e4df74e Suppress warning on 0/0 in compute_mat_moys_classic 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 77348c2cdf API: bulletins: re-ecriture et format json classic avec matières (long_mat, short_mat). 2022-12-13 10:12:18 +01:00
Emmanuel Viennet f67a11519e Jury BUT: par défaut, autorise à passer après un semestre impair 2022-12-13 10:12:18 +01:00
Emmanuel Viennet f9a9c2088d Bulletin BUT: poids des évaluations restreint au parcours de l'étudiant. Closes #524 (part 2). 2022-12-13 10:12:18 +01:00
Emmanuel Viennet afbb1fb0e2 formsemestre_recap_parcours_table: UE du parcours. Closes #524 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 346701d91e Améliore tests unitaires: create_module 2022-12-13 10:12:18 +01:00
Emmanuel Viennet f647ff1139 Jury BUT: cosmetic 2022-12-13 10:12:18 +01:00
Emmanuel Viennet f5988b9e34 Jury BUT: pas de saisie décision annuelle sur sem. impairs 2022-12-13 10:12:18 +01:00
Emmanuel Viennet f318f35c1b Bulletin JSON classique: format 'long_mat' avec matières. Closes #535 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 4626cb9a3e Bulletin JSON classique: ajoute matières. Closes #535 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 0a58437fa9 WIP: jury BUT: prise en compte des UE capitalisées dans les RCUEs 2022-12-13 10:12:18 +01:00
Emmanuel Viennet c906cd7f16 minor code cleaning 2022-12-13 10:12:18 +01:00
Emmanuel Viennet ee86fba3d3 min/max evals sur bul. json classic. + Tests unitaires bulletin. 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 5018298d12 Améliore gestion font pdf manquant 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 9d64caa749 Adaptation du script diagnostic.sh pour ScoDoc 9 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 6f257dc80d Fix: bul. compat. XML 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 49d176c603 9.4.3 2022-12-13 10:12:18 +01:00
Emmanuel Viennet 559b66de8b Fix: bulletin HTML sur démissionnaires sans groupes 2022-12-13 10:12:18 +01:00
Emmanuel Viennet d7f1114a42 Paramétrage dates annees scolaires (pivots) + tous test unitaires OK 2022-12-13 10:12:18 +01:00
Emmanuel Viennet a2ea7d7a02 FIX: calcul notes moyennes avec rattrapages ou session 2 + test unitaire 2022-12-13 10:12:18 +01:00
Emmanuel Viennet f6d8de5a20 pylint: force chargement plugins flask 2022-12-13 10:12:17 +01:00
Emmanuel Viennet 2bf678ac50 WIP: mise à jour des tests unitaires 2022-12-13 10:12:17 +01:00
Jean-Marie PLACE 63c0667694 improve_csv_read 2022-12-13 10:12:17 +01:00
Jean-Marie PLACE 3f7f4172b5 tolère les logos surnuméraires lors des tests 2022-12-13 10:12:17 +01:00
Emmanuel Viennet 528d5c8863 Fix: bonus sport Ville Avray 2022-12-13 10:12:17 +01:00
Jean-Marie Place 7b28e0ba6b dates antipodiques: ajout get_periode, tests et intégration 2022-12-13 10:12:17 +01:00
Emmanuel Viennet 588f2f26eb WIP: paramétrage dates antipodiques 2022-12-13 10:12:17 +01:00
Emmanuel Viennet 4940decf57 En BUT, n'affiche plus l'UE de rattachement dans Voir les inscriptions aux modules. Closes #523 2022-12-13 10:12:17 +01:00
Emmanuel Viennet d055c17c6b Fix #525 (lien intranet) 2022-12-13 10:12:17 +01:00
Emmanuel Viennet ea0a49d837 Calcul moyenne LP UE stages&projets: bug fix #388 2022-12-13 10:12:17 +01:00
Emmanuel Viennet 59a6ee3b3e Fix: page creation module 2022-12-13 10:12:17 +01:00
Emmanuel Viennet 111634db99 Fix version num 2022-12-13 10:12:17 +01:00
Emmanuel Viennet 26dcc31ffb Fix: modif semestre avec inscriptions sans parcours 2022-12-13 10:12:17 +01:00
Emmanuel Viennet 18f4b9cd42 Améliore traitement arguments etud_info_html et ue_table 2022-12-13 10:12:17 +01:00
Emmanuel Viennet dbc9aab7c3 Fix: permissions RelationsEntreprisesExport. + reserve ScoEtudChangePhoto for future API entry. 2022-12-13 10:12:17 +01:00
Jean-Marie PLACE 9c50d03dd8 add new fields in tests 2022-12-13 10:12:17 +01:00
Emmanuel Viennet 2d76cc0ad1 Clonage UE et modules pour faciliter saisie programmes 2022-12-13 10:12:17 +01:00
iziram b7fb8879df api assiduite faite, test unitaire à venir 2022-11-03 10:29:30 +01:00
Emmanuel Viennet 3e0f43d5ea 9.4.0 2022-11-02 08:00:25 +01:00
Emmanuel Viennet dcdd83d2e8 Liens navigation sur saisie jury BUT semestriel. #425 2022-11-02 08:00:25 +01:00
Emmanuel Viennet 4d453d5d14 Interdit changement du ref. de comp. si formsemestres existants. Closes #506. 2022-11-02 08:00:25 +01:00
Emmanuel Viennet 20b13b05cf Améliore synchro groupes de parcours / parcours du formsemestre. Closes #508. 2022-11-02 08:00:25 +01:00
Emmanuel Viennet a730bf759b Flag pour bloquer calcul moyenne generale BUT + reimplemente flag blocage moyennes 2022-11-02 08:00:25 +01:00
Jean-Marie PLACE a63349382e fix tests api (date courante, changement dans les champs réponses) 2022-11-02 08:00:25 +01:00
Emmanuel Viennet dab6bad08f Modification Bonus Sport IUT Amiens 2022-11-02 08:00:25 +01:00
Emmanuel Viennet e435dd10db Bul. HTML: desactive affichage min/max/moy du groupe (non calculé actuellement) 2022-11-02 08:00:25 +01:00
Sébastien Lehmann 95100ed429 Relevé : ajout rang partition 2022-11-02 08:00:25 +01:00
Emmanuel Viennet 155a093635 API: modification format evaluations, et ajout route /evaluation. 2022-11-02 08:00:25 +01:00
Jean-Marie PLACE c85a51a8c5 gestion des dates dans les tests/exemples 2022-11-02 08:00:24 +01:00
Emmanuel Viennet d50107079b Fix: résiste aux mélanges de référentiels de compétences... 2022-11-02 08:00:24 +01:00
Emmanuel Viennet 9535ff1e91 Desactive upload referentiels competences en prod. 2022-11-02 08:00:24 +01:00
Emmanuel Viennet f4d8f4dded minor fix 2022-11-02 08:00:24 +01:00
Emmanuel Viennet ba003d7c02 9.3.60 2022-11-02 08:00:24 +01:00
Emmanuel Viennet 7ca3290357 BUT: calcul moy. gen. indicative ne considérant que les UE du parcours 2022-11-02 08:00:24 +01:00
Emmanuel Viennet 7cb98e3f31 BUT: édition des coefs: légende 2022-11-02 08:00:24 +01:00
Emmanuel Viennet 365e54f7e1 BUT: édition des coefs: visualise mods hors parcours 2022-11-02 08:00:24 +01:00
Emmanuel Viennet 9da5506361 BUT: édition des coefs: UE et mod de tronc commun 2022-11-02 08:00:24 +01:00
Emmanuel Viennet 979359257b BUT: édition des coefs: filtre par parcours 2022-11-02 08:00:24 +01:00
Emmanuel Viennet cc674b4e65 BUT: edition programme: affiche parcours des modules 2022-11-02 08:00:24 +01:00
Emmanuel Viennet c103111aa1 BUT: autorise plusieurs UE vers le même niveau du tronc commun 2022-11-02 08:00:24 +01:00
Emmanuel Viennet b9d6688250 BUT: associe UE aux parcours. Modification pour #487. 2022-11-02 08:00:24 +01:00
Emmanuel Viennet 93e54982b6 test unitaire: test_but_assoc_refcomp 2022-11-02 08:00:24 +01:00
Emmanuel Viennet eefdd5458e Modifie refcomp_desassoc (#506) 2022-11-02 08:00:24 +01:00
Emmanuel Viennet 072d839b75 Fix pour ReportLab: transforme les <br> en <br/> 2022-11-02 08:00:24 +01:00
Emmanuel Viennet 0de35b5400 API: /formsemestres/query?ine=xxxx 2022-11-02 08:00:24 +01:00
Emmanuel Viennet cfcb100ab7 API: ajout /formsemestres/query?nip=xxxx 2022-11-02 08:00:24 +01:00
iziram 1c48758940 premier jet model assiduites 2022-10-28 11:42:52 +02:00
Emmanuel Viennet 3bae99c5cd Fix affiche coefs modules si tous nuls 2022-10-25 17:56:19 +02:00
Emmanuel Viennet ae2f8a97fd Améliore édition détail UE 2022-10-25 17:52:47 +02:00
Emmanuel Viennet 1598537f24 Corrige Import/Export formations BUT en XML 2022-10-24 23:18:45 +02:00
Emmanuel Viennet 557a0c3f6d Bul. BUT: Prise en compte de l'affichage ue rangs 2022-10-24 22:15:19 +02:00
Emmanuel Viennet fa84f9ed89 Retire certaines vues obsoletes (API ScoDoc 7). 2022-10-24 10:49:34 +02:00
Emmanuel Viennet dac46b8366 Import formations / ref. comp. : fixes #510 2022-10-23 23:28:24 +02:00
Emmanuel Viennet a92d2a6edf JSON: exporte dates au format ISO 2022-10-23 14:51:57 +02:00
Emmanuel Viennet caf88c5909 Fix: affichage coef. évaluations modules bonus 2022-10-15 10:27:00 +02:00
Emmanuel Viennet 4da21bf4d3 tri des billets 2022-10-09 14:25:49 +02:00
Emmanuel Viennet bdaf416ccb rename env var. API_PASSWORD for API tests 2022-10-07 22:37:06 +02:00
Emmanuel Viennet 066e03dae8 Amélioration tableau bord semestre / saisie notes manquantes 2022-10-06 14:06:02 +02:00
Emmanuel Viennet 1847250bab Remplissage des notes des étudiants inscrits en cours de route: améliore détection notes 2022-10-06 00:19:54 +02:00
Emmanuel Viennet 1ce4ffecad Remplissage des notes des étudiants inscrits en cours de route 2022-10-05 23:49:09 +02:00
Emmanuel Viennet 91e77dd2dc Fix: url photo inconnue 2022-10-05 15:48:02 +02:00
Emmanuel Viennet 7dcebf4b83 Fix: alignement visu poids évaluation 2022-10-05 10:34:18 +02:00
Emmanuel Viennet 10caea92ae Améliore initialisation poids évaluations 2022-10-05 10:31:25 +02:00
Emmanuel Viennet 75c5256ba9 Jury BUT: décisions lorsque démission sur un semestre 2022-10-04 21:56:10 +02:00
Emmanuel Viennet 678959c76a code cleaning 2022-10-04 21:55:43 +02:00
Emmanuel Viennet 77c0294c83 Saisie jury: améliore page démissionnaires/défaillant. Voir #425 2022-10-03 19:13:15 +02:00
Emmanuel Viennet 8d72229e8b modernise conversion date 2022-10-03 11:59:59 +02:00
Emmanuel Viennet 84b02edd48 mail auteur dump 2022-10-03 11:59:38 +02:00
Emmanuel Viennet ec4aa0e26f Fix affichage moduleimpl_status 2022-10-03 11:07:43 +02:00
Emmanuel Viennet 50e8f2b4fe Fix: bug enregistrement décision jury 2022-10-03 10:28:46 +02:00
Emmanuel Viennet 318f6f8a65 Envoi des bulletins par mail: exclu démissions/défaillances. Closes #356 2022-10-03 09:46:33 +02:00
Emmanuel Viennet 91e508bf9f get_ue_poids_dict: sort 2022-10-03 09:04:04 +02:00
Emmanuel Viennet 036ce650c6 Fix: <br> pour ReportrLab pdf 2022-10-03 08:37:29 +02:00
Emmanuel Viennet 37a8b3bb0b Edition préférences: sections dépliables. + Code cleaning. 2022-10-02 23:43:29 +02:00
Emmanuel Viennet ad46a190ab minor code cleaning 2022-10-02 20:50:35 +02:00
Emmanuel Viennet f69ce75b1f Ajoute préférence pour empêcher l'édition des poids des évaluations. Closes #389 2022-10-02 20:15:56 +02:00
Emmanuel Viennet 1813e3c7ce retirer comments du css 2022-10-02 19:02:44 +02:00
Emmanuel Viennet 7bbdff67a0 Visualise poids évaluation sur tableau bord module. Closes #411. 2022-10-02 18:43:18 +02:00
Emmanuel Viennet dcf0f73c1b 9.3.50 2022-10-01 19:00:44 +02:00
Emmanuel Viennet 06cbd65365 Tableau bord module: avertissement si poids d'évaluation nuls. Début de #411. 2022-10-01 18:56:10 +02:00
Emmanuel Viennet 268b75d441 Améliore code création formsemestres. 2022-10-01 15:34:39 +02:00
Emmanuel Viennet 0c5e338970 Liste décisions sur page démission. Closes #499 2022-10-01 10:39:46 +02:00
Emmanuel Viennet 2731a4728b N'impose pas la présence de codes Apogée sur les éléments des semestres extérieurs 2022-09-30 23:29:06 +02:00
Emmanuel Viennet 4f87f22586 Modernisation code démission/défaillance... 2022-09-30 22:43:39 +02:00
Emmanuel Viennet a3e4c34745 Bulletin : situation "inscrit" des démissionnaires #498 2022-09-30 20:55:09 +02:00
Emmanuel Viennet 78bb9a706e Jury BUT sur semestres isolés. 2022-09-30 16:20:51 +02:00
Emmanuel Viennet d6be0e131f code cleaning 2022-09-30 16:01:43 +02:00
Emmanuel Viennet 9527240ea8 code cleaning 2022-09-30 09:37:20 +02:00
Emmanuel Viennet c6c7187c34 fix edit ordre UEs 2022-09-29 22:39:54 +02:00
Emmanuel Viennet 453f11084b ext 2022-09-29 22:39:26 +02:00
Emmanuel Viennet 6b29a205b6 Jury BUT: si formsemestre extérieur, propose toujours ADM 2022-09-29 22:09:19 +02:00
595 changed files with 35703 additions and 59781 deletions

View File

@ -1,4 +1,8 @@
[[MESSAGES CONTROL]
[MASTER]
load-plugins=pylint_flask_sqlalchemy,pylint_flask
[MESSAGES CONTROL]
# pylint and black disagree...
disable=bad-continuation

View File

@ -1,5 +1,4 @@
i
# ScoDoc - Gestion de la scolarité - Version ScoDoc 9
# ScoDoc - Gestion de la scolarité - Version ScoDoc 9
(c) Emmanuel Viennet 1999 - 2022 (voir LICENCE.txt).
@ -9,39 +8,34 @@ Documentation utilisateur: <https://scodoc.org>
## Version ScoDoc 9
La version ScoDoc 9 est parue en septembre 2021.
Elle représente une évolution majeure du projet, maintenant basé sur
Flask (au lieu de Zope) et sur **python 3.9+**.
La version ScoDoc 9 est parue en septembre 2021. Elle représente une évolution
majeure du projet, maintenant basé sur Flask (au lieu de Zope) et sur **python
3.9+**.
La version 9.0 s'efforce de reproduire presque à l'identique le fonctionnement
La version 9.0 s'efforce de reproduire presque à l'identique le fonctionnement
de ScoDoc7, avec des composants logiciels différents (Debian 11, Python 3,
Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes).
### État actuel (dec 22)
- 9.4.x est en production
- le prochain jalon est 9.5. Voir branches sur gitea.
### État actuel (26 jan 22)
- 9.1.5x (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf:
- ancien module "Entreprises" (obsolète) et ajoute la gestion du BUT.
- 9.2 (branche dev92) est la version de développement.
### Lignes de commandes
Voir [https://scodoc.org/GuideConfig](le guide de configuration).
## Organisation des fichiers
L'installation comporte les fichiers de l'application, sous `/opt/scodoc/`, et
les fichiers locaux (archives, photos, configurations, logs) sous
`/opt/scodoc-data`. Par ailleurs, il y a évidemment les bases de données
postgresql et la configuration du système Linux.
postgresql et la configuration du système Linux.
### Fichiers locaux
Sous `/opt/scodoc-data`, fichiers et répertoires appartenant à l'utilisateur `scodoc`.
Ils ne doivent pas être modifiés à la main, sauf certains fichiers de configuration sous
Sous `/opt/scodoc-data`, fichiers et répertoires appartenant à l'utilisateur `scodoc`.
Ils ne doivent pas être modifiés à la main, sauf certains fichiers de configuration sous
`/opt/scodoc-data/config`.
Le répertoire `/opt/scodoc-data` doit être régulièrement sauvegardé.
@ -62,7 +56,7 @@ Principaux contenus:
Installer ScoDoc 9 normalement ([voir la doc](https://scodoc.org/GuideInstallDebian11)).
Puis remplacer `/opt/scodoc` par un clone du git.
Puis remplacer `/opt/scodoc` par un clone du git.
sudo su
mv /opt/scodoc /opt/off-scodoc # ou ce que vous voulez
@ -76,7 +70,7 @@ Puis remplacer `/opt/scodoc` par un clone du git.
# Et donner ce répertoire à l'utilisateur scodoc:
chown -R scodoc.scodoc /opt/scodoc
Il faut ensuite installer l'environnement et le fichier de configuration:
# Le plus simple est de piquer le virtualenv configuré par l'installeur:
@ -100,14 +94,14 @@ Avant le premier lancement, créer cette base ainsi:
flask db upgrade
Cette commande n'est nécessaire que la première fois (le contenu de la base
est effacé au début de chaque test, mais son schéma reste) et aussi si des
est effacé au début de chaque test, mais son schéma reste) et aussi si des
migrations (changements de schéma) ont eu lieu dans le code.
Certains tests ont besoin d'un département déjà créé, qui n'est pas créé par les
Certains tests ont besoin d'un département déjà créé, qui n'est pas créé par les
scripts de tests:
Lancer au préalable:
flask delete-dept TEST00 && flask create-dept TEST00
flask delete-dept -fy TEST00 && flask create-dept TEST00
Puis dérouler les tests unitaires:
@ -117,24 +111,24 @@ Ou avec couverture (`pip install pytest-cov`)
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
#### Utilisation des tests unitaires pour initialiser la base de dev
On peut aussi utiliser les tests unitaires pour mettre la base
de données de développement dans un état connu, par exemple pour éviter de
recréer à la main étudiants et semestres quand on développe.
Il suffit de positionner une variable d'environnement indiquant la BD
utilisée par les tests:
On peut aussi utiliser les tests unitaires pour mettre la base de données de
développement dans un état connu, par exemple pour éviter de recréer à la main
étudiants et semestres quand on développe.
Il suffit de positionner une variable d'environnement indiquant la BD utilisée
par les tests:
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
(si elle n'existe pas, voir plus loin pour la créer) puis de les lancer
normalement, par exemple:
normalement, par exemple:
pytest tests/unit/test_sco_basic.py
Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins)
un utilisateur:
Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins) un
utilisateur:
flask user-password admin
@ -178,12 +172,10 @@ Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bie
pip install snakeviz
puis
puis
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
# Paquet Debian 11
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
@ -191,5 +183,4 @@ important est `postinst`qui se charge de configurer le système (install ou
upgrade de scodoc9).
La préparation d'une release se fait à l'aide du script
`tools/build_release.sh`.
`tools/build_release.sh`.

View File

@ -19,19 +19,26 @@ from flask import abort, flash, has_request_context, jsonify
from flask import render_template
from flask.json import JSONEncoder
from flask.logging import default_handler
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_bootstrap import Bootstrap
from flask_caching import Cache
from flask_login import LoginManager, current_user
from flask_mail import Mail
from flask_bootstrap import Bootstrap
from flask_migrate import Migrate
from flask_moment import Moment
from flask_caching import Cache
from flask_sqlalchemy import SQLAlchemy
from jinja2 import select_autoescape
import sqlalchemy
from flask_cas import CAS
from app.scodoc.sco_exceptions import (
AccessDenied,
ScoBugCatcher,
ScoException,
ScoGenError,
ScoInvalidCSRF,
ScoValueError,
APIInvalidParams,
)
@ -60,11 +67,20 @@ cache = Cache(
def handle_sco_value_error(exc):
return render_template("sco_value_error.html", exc=exc), 404
return render_template("sco_value_error.j2", exc=exc), 404
def handle_access_denied(exc):
return render_template("error_access_denied.html", exc=exc), 403
return render_template("error_access_denied.j2", exc=exc), 403
def handle_invalid_csrf(exc):
"""Form submit with invalid CSRF token"""
# logout user and go back to login page with an error message
from app import auth
auth.logic.logout()
return render_template("error_csrf.j2", exc=exc), 404
def internal_server_error(exc):
@ -74,7 +90,7 @@ def internal_server_error(exc):
return (
render_template(
"error_500.html",
"error_500.j2",
SCOVERSION=sco_version.SCOVERSION,
date=datetime.datetime.now().isoformat(),
exc=exc,
@ -92,9 +108,12 @@ def handle_sco_bug(exc):
"""Un bug, en général rare, sur lequel les dev cherchent des
informations pour le corriger.
"""
Thread(
target=_async_dump, args=(current_app._get_current_object(), request.url)
).start()
if current_app.config["TESTING"] or current_app.config["DEBUG"]:
raise ScoException # for development servers only
else:
Thread(
target=_async_dump, args=(current_app._get_current_object(), request.url)
).start()
return internal_server_error(exc)
@ -119,7 +138,7 @@ def handle_invalid_usage(error):
# JSON ENCODING
class ScoDocJSONEncoder(JSONEncoder):
def default(self, o):
if isinstance(o, datetime.datetime):
if isinstance(o, (datetime.datetime, datetime.date)):
return o.isoformat()
return super().default(o)
@ -127,7 +146,7 @@ class ScoDocJSONEncoder(JSONEncoder):
def render_raw_html(template_filename: str, **args) -> str:
"""Load and render an HTML file _without_ using Flask
Necessary for 503 error mesage, when DB is down and Flask may be broken.
Necessary for 503 error message, when DB is down and Flask may be broken.
"""
template_path = os.path.join(
current_app.config["SCODOC_DIR"],
@ -142,7 +161,7 @@ def render_raw_html(template_filename: str, **args) -> str:
def postgresql_server_error(e):
"""Erreur de connection au serveur postgresql (voir notesdb.open_db_connection)"""
return render_raw_html("error_503.html", SCOVERSION=sco_version.SCOVERSION), 503
return render_raw_html("error_503.j2", SCOVERSION=sco_version.SCOVERSION), 503
class LogRequestFormatter(logging.Formatter):
@ -221,14 +240,16 @@ class ReverseProxied(object):
def create_app(config_class=DevConfig):
app = Flask(__name__, static_url_path="/ScoDoc/static", static_folder="static")
from app.auth import cas
CAS(app, url_prefix="/cas", configuration_function=cas.set_cas_configuration)
app.wsgi_app = ReverseProxied(app.wsgi_app)
app.json_encoder = ScoDocJSONEncoder
app.logger.setLevel(logging.INFO)
# Evite de logguer toutes les requetes dans notre log
logging.getLogger("werkzeug").disabled = True
app.config.from_object(config_class)
# Evite de logguer toutes les requetes dans notre log
logging.getLogger("werkzeug").disabled = True
app.logger.setLevel(app.config["LOG_LEVEL"])
# Vérifie/crée lien sym pour les URL statiques
link_filename = f"{app.root_path}/static/links/{sco_version.SCOVERSION}"
@ -240,6 +261,7 @@ def create_app(config_class=DevConfig):
migrate.init_app(app, db)
login.init_app(app)
mail.init_app(app)
app.extensions["mail"].debug = 0 # disable copy of mails to stderr
bootstrap.init_app(app)
moment.init_app(app)
cache.init_app(app)
@ -250,6 +272,7 @@ def create_app(config_class=DevConfig):
app.register_error_handler(ScoGenError, handle_sco_value_error)
app.register_error_handler(ScoValueError, handle_sco_value_error)
app.register_error_handler(ScoBugCatcher, handle_sco_bug)
app.register_error_handler(ScoInvalidCSRF, handle_invalid_csrf)
app.register_error_handler(AccessDenied, handle_access_denied)
app.register_error_handler(500, internal_server_error)
app.register_error_handler(503, postgresql_server_error)
@ -271,6 +294,9 @@ def create_app(config_class=DevConfig):
from app.api import api_bp
from app.api import api_web_bp
# Enable autoescaping of all templates, including .j2
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
# https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp)
# https://scodoc.fr/ScoDoc/RT/Scolarite/...
@ -370,6 +396,15 @@ def create_app(config_class=DevConfig):
sco_bulletins_generator.register_bulletin_class(BulletinGeneratorExample)
from app.auth.cas import set_cas_configuration
with app.app_context():
try:
set_cas_configuration(app)
except sqlalchemy.exc.ProgrammingError:
# Si la base n'a pas été upgradée (arrive durrant l'install)
# il se peut que la table scodoc_site_config n'existe pas encore.
pass
return app
@ -435,8 +470,6 @@ def initialize_scodoc_database(erase=False, create_all=False):
SQL tables and functions.
If erase is True, _erase_ all database content.
"""
from app import models
# - ERASE (the truncation sql function has been defined above)
if erase:
truncate_database()
@ -463,6 +496,26 @@ def truncate_database():
except:
db.session.rollback()
raise
# Remet les compteurs (séquences sql) à zéro
db.session.execute(
"""
CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$
DECLARE
statements CURSOR FOR
SELECT sequence_name
FROM information_schema.sequences
ORDER BY sequence_name ;
BEGIN
FOR stmt IN statements LOOP
EXECUTE 'ALTER SEQUENCE ' || quote_ident(stmt.sequence_name) || ' RESTART;';
END LOOP;
END;
$$ LANGUAGE plpgsql;
SELECT reset_sequences('scodoc');
"""
)
db.session.commit()
def clear_scodoc_cache():
@ -480,12 +533,10 @@ def clear_scodoc_cache():
# --------- Logging
def log(msg: str, silent_test=True):
def log(msg: str):
"""log a message.
If Flask app, use configured logger, else stderr.
"""
if silent_test and current_app and current_app.config["TESTING"]:
return
try:
dept = getattr(g, "scodoc_dept", "")
msg = f" ({dept}) {msg}"
@ -510,10 +561,9 @@ def log_call_stack():
# Alarms by email:
def send_scodoc_alarm(subject, txt):
from app.scodoc import sco_preferences
from app import email
sender = sco_preferences.get_preference("email_from_addr")
sender = email.get_from_addr()
email.send_email(subject, sender, ["exception@scodoc.org"], txt)
@ -530,3 +580,22 @@ def scodoc_flash_status_messages():
f"Mode test: mails redirigés vers {email_test_mode_address}",
category="warning",
)
def critical_error(msg):
"""Handle a critical error: flush all caches, display message to the user"""
import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}")
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
clear_scodoc_cache()
raise ScoValueError(
f"""
Une erreur est survenue.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
{msg}
"""
)

View File

@ -2,13 +2,17 @@
"""
from flask import Blueprint
from flask import request
from flask import request, g, jsonify
from app import db
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException
api_bp = Blueprint("api", __name__)
api_web_bp = Blueprint("apiweb", __name__)
# HTTP ERROR STATUS
API_CLIENT_ERROR = 400 # erreur dans les paramètres fournis par le client
@api_bp.errorhandler(ScoException)
@api_bp.errorhandler(404)
@ -31,9 +35,26 @@ def requested_format(default_format="json", allowed_formats=None):
return None
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
"""
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemstre) -> join_cls
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
"""
query = model_cls.query.filter_by(id=model_id)
if g.scodoc_dept and join_cls is not None:
query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id)
unique: model_cls = query.first_or_404()
return jsonify(unique.to_dict(format_api=True))
from app.api import tokens
from app.api import (
absences,
assiduites,
billets_absences,
departements,
etudiants,
@ -41,6 +62,7 @@ from app.api import (
formations,
formsemestres,
jury,
justificatifs,
logos,
partitions,
users,

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Absences
@ -8,7 +8,7 @@
from flask import jsonify
from app.api import api_bp as bp
from app.api import api_bp as bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models import Identite

646
app/api/assiduites.py Normal file
View File

@ -0,0 +1,646 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask import g, jsonify, request
from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.decorators import permission_required, scodoc
from app.models import Assiduite, FormSemestre, Identite, ModuleImpl
from app.auth.models import User
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
@bp.route("/assiduite/<int:assiduite_id>")
@api_web_bp.route("/assiduite/<int:assiduite_id>")
@scodoc
@permission_required(Permission.ScoView)
def assiduite(assiduite_id: int = None):
"""Retourne un objet assiduité à partir de son id
Exemple de résultat:
{
"assiduite_id": 1,
"etudid": 2,
"moduleimpl_id": 3,
"date_debut": "2022-10-31T08:00+01:00",
"date_fin": "2022-10-31T10:00+01:00",
"etat": "retard",
"desc": "une description",
"user_id: 1 or null,
"est_just": False or True,
}
"""
return get_model_api_object(Assiduite, assiduite_id, Identite)
@bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>/count", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/count/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def count_assiduites(etudid: int = None, with_query: bool = False):
"""
Retourne le nombre d'assiduités d'un étudiant
chemin : /assiduites/<int:etudid>/count
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/count/query?
Les différents filtres :
Type (type de comptage -> journee, demi, heure, nombre d'assiduite):
query?type=(journee, demi, heure) -> une seule valeur parmis les trois
ex: .../query?type=heure
Comportement par défaut : compte le nombre d'assiduité enregistrée
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemestre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
filtered: dict[str, object] = {}
metric: str = "all"
if with_query:
metric, filtered = _count_manager(request)
return jsonify(
scass.get_assiduites_stats(
assiduites=etud.assiduites, metric=metric, filtered=filtered
)
)
@bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/assiduites/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/assiduites/<int:etudid>/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def assiduites(etudid: int = None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /assiduites/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /assiduites/<int:etudid>/query?
Les différents filtres :
Etat (etat de l'étudiant -> absent, present ou retard):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=present,retard
Date debut
(date de début de l'assiduité, sont affichés les assiduités
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin de l'assiduité, sont affichés les assiduités
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
Moduleimpl_id (l'id du module concerné par l'assiduité):
query?moduleimpl_id=[- int ou vide -]
ex: query?moduleimpl_id=1234
query?moduleimpl_od=
Formsemstre_id (l'id du formsemestre concerné par l'assiduité)
query?formsemstre_id=[int]
ex query?formsemestre_id=3
user_id (l'id de l'auteur de l'assiduité)
query?user_id=[int]
ex query?user_id=3
est_just (si l'assiduité est justifié (fait aussi filtre par abs/retard))
query?est_just=[bool]
query?est_just=f
query?est_just=t
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
assiduites_query = etud.assiduites
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: list[dict] = []
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.append(data)
return jsonify(data_set)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>", defaults={"with_query": False}
)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/query",
defaults={"with_query": True},
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
def assiduites_formsemestre(formsemestre_id: int, with_query: bool = False):
"""Retourne toutes les assiduités du formsemestre"""
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
assiduites_query = scass.filter_by_formsemestre(Assiduite.query, formsemestre)
if with_query:
assiduites_query = _filter_manager(request, assiduites_query)
data_set: list[dict] = []
for ass in assiduites_query.all():
data = ass.to_dict(format_api=True)
data_set.append(data)
return jsonify(data_set)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count",
defaults={"with_query": False},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count",
defaults={"with_query": False},
)
@bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
defaults={"with_query": True},
)
@api_web_bp.route(
"/assiduites/formsemestre/<int:formsemestre_id>/count/query",
defaults={"with_query": True},
)
@login_required
@scodoc
@permission_required(Permission.ScoView)
def count_assiduites_formsemestre(
formsemestre_id: int = None, with_query: bool = False
):
"""Comptage des assiduités du formsemestre"""
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
if formsemestre is None:
return json_error(404, "le paramètre 'formsemestre_id' n'existe pas")
etuds = formsemestre.etuds.all()
etuds_id = [etud.id for etud in etuds]
assiduites_query = Assiduite.query.filter(Assiduite.etudid.in_(etuds_id))
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
metric: str = "all"
filtered: dict = {}
if with_query:
metric, filtered = _count_manager(request)
return jsonify(scass.get_assiduites_stats(assiduites_query, metric, filtered))
@bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/assiduite/<int:etudid>/create", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_create(etudid: int = None):
"""
Création d'une assiduité pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"moduleimpl_id": int,
"desc":str,
}
...
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
db.session.commit()
return jsonify({"errors": errors, "success": success})
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatAssiduite.contains(etat):
errors.append("param 'etat': invalide")
etat = scu.EtatAssiduite.get(etat)
# cas 2 : date_debut
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin, convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
# cas 4 : moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
# cas 5 : desc
desc: str = data.get("desc", None)
if errors:
err: str = ", ".join(errors)
return (404, err)
# TOUT EST OK
try:
nouv_assiduite: Assiduite = Assiduite.create_assiduite(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
moduleimpl=moduleimpl,
description=desc,
user_id=current_user.id,
)
db.session.add(nouv_assiduite)
db.session.commit()
return (200, {"assiduite_id": nouv_assiduite.id})
except ScoValueError as excp:
return (
404,
excp.args[0],
)
@bp.route("/assiduite/delete", methods=["POST"])
@api_web_bp.route("/assiduite/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_delete():
"""
Suppression d'une assiduité à partir de son id
Forme des données envoyées :
[
<assiduite_id:int>,
...
]
"""
assiduites_list: list[int] = request.get_json(force=True)
if not isinstance(assiduites_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
output = {"errors": {}, "success": {}}
for i, ass in enumerate(assiduites_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
else:
output["success"][f"{i}"] = {"OK": True}
db.session.commit()
return jsonify(output)
def _delete_singular(assiduite_id: int, database):
assiduite_unique: Assiduite = Assiduite.query.filter_by(id=assiduite_id).first()
if assiduite_unique is None:
return (404, "Assiduite non existante")
database.session.delete(assiduite_unique)
return (200, "OK")
@bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@api_web_bp.route("/assiduite/<int:assiduite_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def assiduite_edit(assiduite_id: int):
"""
Edition d'une assiduité à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"moduleimpl_id"?: int
"desc"?: str
"est_just"?: bool
}
"""
assiduite_unique: Assiduite = Assiduite.query.filter_by(
id=assiduite_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatAssiduite.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
assiduite_unique.etat = etat
# Cas 2 : Moduleimpl_id
moduleimpl_id = data.get("moduleimpl_id", False)
moduleimpl: ModuleImpl = None
if moduleimpl_id is not False:
if moduleimpl_id is not None:
moduleimpl = ModuleImpl.query.filter_by(id=int(moduleimpl_id)).first()
if moduleimpl is None:
errors.append("param 'moduleimpl_id': invalide")
else:
if not moduleimpl.est_inscrit(
Identite.query.filter_by(id=assiduite_unique.etudid).first()
):
errors.append("param 'moduleimpl_id': etud non inscrit")
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
else:
assiduite_unique.moduleimpl_id = moduleimpl_id
# Cas 3 : desc
desc = data.get("desc", False)
if desc is not False:
assiduite_unique.desc = desc
# Cas 4 : est_just
est_just = data.get("est_just")
if est_just is not None:
if not isinstance(est_just, bool):
errors.append("param 'est_just' : booléen non reconnu")
else:
assiduite_unique.est_just = est_just
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(assiduite_unique)
db.session.commit()
return jsonify({"OK": True})
# -- Utils --
def _count_manager(requested) -> tuple[str, dict]:
"""
Retourne la/les métriques à utiliser ainsi que le filtre donnés en query de la requête
"""
filtered: dict = {}
# cas 1 : etat assiduite
etat = requested.args.get("etat")
if etat is not None:
filtered["etat"] = etat
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
if deb is not None:
filtered["date_debut"] = deb
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if fin is not None:
filtered["date_fin"] = fin
# cas 4 : moduleimpl_id
module = requested.args.get("moduleimpl_id", False)
try:
if module is False:
raise ValueError
if module != "":
module = int(module)
else:
module = None
except ValueError:
module = False
if module is not False:
filtered["moduleimpl_id"] = module
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id is not None:
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
filtered["formsemestre"] = formsemestre
# cas 6 : type
metric = requested.args.get("metric", "all")
# cas 7 : est_just
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
filtered["est_just"] = True
elif est_just.lower() in falses:
filtered["est_just"] = False
# cas 8 : user_id
user_id = requested.args.get("user_id", False)
if user_id is not False:
filtered["user_id"] = user_id
return (metric, filtered)
def _filter_manager(requested, assiduites_query: Assiduite):
"""
Retourne les assiduites entrées filtrées en fonction de la request
"""
# cas 1 : etat assiduite
etat = requested.args.get("etat")
if etat is not None:
assiduites_query = scass.filter_assiduites_by_etat(assiduites_query, etat)
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
assiduites_query: Assiduite = scass.filter_by_date(
assiduites_query, Assiduite, deb, fin
)
# cas 4 : moduleimpl_id
module = requested.args.get("moduleimpl_id", False)
try:
if module is False:
raise ValueError
if module != "":
module = int(module)
else:
module = None
except ValueError:
module = False
if module is not False:
assiduites_query = scass.filter_by_module_impl(assiduites_query, module)
# cas 5 : formsemestre_id
formsemestre_id = requested.args.get("formsemestre_id")
if formsemestre_id is not None:
formsemestre: FormSemestre = None
formsemestre_id = int(formsemestre_id)
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first()
assiduites_query = scass.filter_by_formsemestre(assiduites_query, formsemestre)
# cas 6 : est_just
est_just: str = requested.args.get("est_just")
if est_just is not None:
trues: tuple[str] = ("v", "t", "vrai", "true")
falses: tuple[str] = ("f", "faux", "false")
if est_just.lower() in trues:
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
assiduites_query, True
)
elif est_just.lower() in falses:
assiduites_query: Assiduite = scass.filter_assiduites_by_est_just(
assiduites_query, False
)
# cas 8 : user_id
user_id = requested.args.get("user_id", False)
if user_id is not False:
assiduites_query: Assiduite = scass.filter_by_user_id(assiduites_query, user_id)
return assiduites_query

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -11,7 +11,6 @@
from flask import g, jsonify, request
from flask_login import login_required
import app
from app import db
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
@ -48,12 +47,9 @@ def billets_absence_create():
justified = data.get("justified", False)
if None in (etudid, abs_begin, abs_end):
return json_error(
404, message="Paramètre manquant: etudid, abs_bein, abs_end requis"
404, message="Paramètre manquant: etudid, abs_begin, abs_end requis"
)
query = Identite.query.filter_by(etudid=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud = query.first_or_404()
etud = Identite.get_etud(etudid)
billet = BilletAbsence(
etudid=etud.id,
abs_begin=abs_begin,

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -10,13 +10,14 @@
Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/),
mais évidemment pas sur l'API web (/ScoDoc/<dept>/api).
"""
from datetime import datetime
from flask import jsonify, request
from flask_login import login_required
import app
from app import db, log
from app.api import api_bp as bp
from app import db
from app.api import api_bp as bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required
from app.models import Departement, FormSemestre
@ -42,7 +43,7 @@ def get_departement(dept_ident: str) -> Departement:
@permission_required(Permission.ScoView)
def departements_list():
"""Liste les départements"""
return jsonify([dept.to_dict() for dept in Departement.query])
return jsonify([dept.to_dict(with_dept_name=True) for dept in Departement.query])
@bp.route("/departements_ids")
@ -66,13 +67,14 @@ def departement(acronym: str):
{
"id": 1,
"acronym": "TAPI",
"dept_name" : "TEST",
"description": null,
"visible": true,
"date_creation": "Fri, 15 Apr 2022 12:19:28 GMT"
}
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
return jsonify(dept.to_dict())
return jsonify(dept.to_dict(with_dept_name=True))
@bp.route("/departement/id/<int:dept_id>")
@ -103,12 +105,12 @@ def departement_create():
data = request.get_json(force=True) # may raise 400 Bad Request
acronym = str(data.get("acronym", ""))
if not acronym:
return json_error(404, "missing acronym")
return json_error(API_CLIENT_ERROR, "missing acronym")
visible = bool(data.get("visible", True))
try:
dept = departements.create_dept(acronym, visible=visible)
except ScoValueError as exc:
return json_error(404, exc.args[0] if exc.args else "")
return json_error(500, exc.args[0] if exc.args else "")
return jsonify(dept.to_dict())
@ -128,7 +130,7 @@ def departement_edit(acronym):
data = request.get_json(force=True) # may raise 400 Bad Request
visible = bool(data.get("visible", None))
if visible is None:
return json_error(404, "missing argument: visible")
return json_error(API_CLIENT_ERROR, "missing argument: visible")
visible = bool(visible)
dept.visible = visible
db.session.add(dept)
@ -256,15 +258,18 @@ def dept_formsemestres_courants(acronym: str):
]
"""
dept = Departement.query.filter_by(acronym=acronym).first_or_404()
date_courante = request.args.get("date_courante")
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = app.db.func.now()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= app.db.func.now(),
FormSemestre.date_fin >= app.db.func.now(),
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
return jsonify([d.to_dict(convert_objects=True) for d in formsemestres])
return jsonify([d.to_dict_api() for d in formsemestres])
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants")
@ -277,12 +282,16 @@ def dept_formsemestres_courants_by_id(dept_id: int):
"""
# Le département, spécifié par un id ou un acronyme
dept = Departement.query.get_or_404(dept_id)
date_courante = request.args.get("date_courante")
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = app.db.func.now()
# Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= app.db.func.now(),
FormSemestre.date_fin >= app.db.func.now(),
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
return jsonify([d.to_dict(convert_objects=True) for d in formsemestres])
return jsonify([d.to_dict_api() for d in formsemestres])

View File

@ -1,14 +1,15 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
API : accès aux étudiants
"""
from datetime import datetime
from flask import g, jsonify
from flask import abort, g, jsonify, request
from flask_login import current_user
from flask_login import login_required
from sqlalchemy import desc, or_
@ -75,11 +76,16 @@ def etudiants_courants(long=False):
"""
allowed_depts = current_user.get_depts_with_permission(Permission.ScoView)
date_courante = request.args.get("date_courante")
if date_courante:
test_date = datetime.fromisoformat(date_courante)
else:
test_date = app.db.func.now()
etuds = Identite.query.filter(
Identite.id == FormSemestreInscription.etudid,
FormSemestreInscription.formsemestre_id == FormSemestre.id,
FormSemestre.date_debut <= app.db.func.now(),
FormSemestre.date_fin >= app.db.func.now(),
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
if not None in allowed_depts:
# restreint aux départements autorisés:
@ -204,160 +210,88 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None)
@bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin",
methods=["GET"],
defaults={"version": "long", "pdf": False},
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin",
)
@bp.route(
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin",
methods=["GET"],
defaults={"version": "long", "pdf": False},
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>",
)
@bp.route(
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin",
methods=["GET"],
defaults={"version": "long", "pdf": False},
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
defaults={"pdf": True},
)
@bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/pdf",
methods=["GET"],
defaults={"version": "long", "pdf": True},
)
@bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/short",
methods=["GET"],
defaults={"version": "short", "pdf": False},
)
@bp.route(
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/short",
methods=["GET"],
defaults={"version": "short", "pdf": False},
)
@bp.route(
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/short",
methods=["GET"],
defaults={"version": "short", "pdf": False},
)
@bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
methods=["GET"],
defaults={"version": "short", "pdf": True},
)
@bp.route(
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
methods=["GET"],
defaults={"version": "short", "pdf": True},
)
@bp.route(
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
methods=["GET"],
defaults={"version": "short", "pdf": True},
)
@bp.route(
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/pdf",
methods=["GET"],
defaults={"version": "long", "pdf": True},
)
@bp.route(
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/pdf",
methods=["GET"],
defaults={"version": "long", "pdf": True},
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf/nosig",
defaults={"pdf": True, "with_img_signatures_pdf": False},
)
@api_web_bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin",
methods=["GET"],
defaults={"version": "long", "pdf": False},
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin",
)
@api_web_bp.route(
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin",
methods=["GET"],
defaults={"version": "long", "pdf": False},
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>",
)
@api_web_bp.route(
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin",
methods=["GET"],
defaults={"version": "long", "pdf": False},
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
defaults={"pdf": True},
)
@api_web_bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/pdf",
methods=["GET"],
defaults={"version": "long", "pdf": True},
)
@api_web_bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/short",
methods=["GET"],
defaults={"version": "short", "pdf": False},
)
@api_web_bp.route(
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/short",
methods=["GET"],
defaults={"version": "short", "pdf": False},
)
@api_web_bp.route(
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/short",
methods=["GET"],
defaults={"version": "short", "pdf": False},
)
@api_web_bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
methods=["GET"],
defaults={"version": "short", "pdf": True},
)
@api_web_bp.route(
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
methods=["GET"],
defaults={"version": "short", "pdf": True},
)
@api_web_bp.route(
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin/short/pdf",
methods=["GET"],
defaults={"version": "short", "pdf": True},
"/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf/nosig",
defaults={"pdf": True, "with_img_signatures_pdf": False},
)
@scodoc
@permission_required(Permission.ScoView)
def etudiant_bulletin_semestre(
formsemestre_id,
etudid: int = None,
nip: str = None,
ine: str = None,
version="long",
def bulletin(
code_type: str = "etudid",
code: str = None,
formsemestre_id: int = None,
version: str = "long",
pdf: bool = False,
with_img_signatures_pdf: bool = True,
):
"""
Retourne le bulletin d'un étudiant en fonction de son id et d'un semestre donné
formsemestre_id : l'id d'un formsemestre
etudid : l'etudid d'un étudiant
nip : le code nip d'un étudiant
ine : le code ine d'un étudiant
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
code_type : "etudid", "nip" ou "ine"
code : valeur du code INE, NIP ou etudid, selon code_type.
version : type de bulletin (par défaut, "long"): short, long, long_mat
pdf : si spécifié, bulletin au format PDF (et non JSON).
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin
"""
if version == "pdf":
version = "long"
pdf = True
# return f"{code_type}={code}, version={version}, pdf={pdf}"
formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
dept = Departement.query.filter_by(id=formsemestre.dept_id).first_or_404()
if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre non trouve")
if etudid is not None:
query = Identite.query.filter_by(id=etudid)
elif nip is not None:
query = Identite.query.filter_by(code_nip=nip, dept_id=dept.id)
elif ine is not None:
query = Identite.query.filter_by(code_ine=ine, dept_id=dept.id)
else:
return json_error(404, message="parametre manquant")
return json_error(404, "formsemestre inexistant")
app.set_sco_dept(dept.acronym)
if code_type == "nip":
query = Identite.query.filter_by(code_nip=code, dept_id=dept.id)
elif code_type == "etudid":
try:
etudid = int(code)
except ValueError:
return json_error(404, "invalid etudid type")
query = Identite.query.filter_by(id=etudid)
elif code_type == "ine":
query = Identite.query.filter_by(code_ine=code, dept_id=dept.id)
else:
return json_error(404, "invalid code_type")
etud = query.first()
if etud is None:
return json_error(404, message="etudiant inexistant")
app.set_sco_dept(dept.acronym)
if pdf:
pdf_response, _ = do_formsemestre_bulletinetud(
formsemestre, etud.id, version=version, format="pdf"
formsemestre,
etud,
version=version,
format="pdf",
with_img_signatures_pdf=with_img_signatures_pdf,
)
return pdf_response
return sco_bulletins.get_formsemestre_bulletin_etud_json(
formsemestre, etud, version=version
)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -15,13 +15,50 @@ import app
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
from app.models import Evaluation, ModuleImpl, FormSemestre
from app.scodoc import sco_evaluation_db
from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu
@bp.route("/evaluation/<int:evaluation_id>")
@api_web_bp.route("/evaluation/<int:evaluation_id>")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def evaluation(evaluation_id: int):
"""Description d'une évaluation.
{
'coefficient': 1.0,
'date_debut': '2016-01-04T08:30:00',
'date_fin': '2016-01-04T12:30:00',
'description': 'TP NI9219 Température',
'evaluation_type': 0,
'id': 15797,
'moduleimpl_id': 1234,
'note_max': 20.0,
'numero': 3,
'poids': {
'UE1.1': 1.0,
'UE1.2': 1.0,
'UE1.3': 1.0
},
'publish_incomplete': False,
'visi_bulletin': True
}
"""
query = Evaluation.query.filter_by(id=evaluation_id)
if g.scodoc_dept:
query = (
query.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
e = query.first_or_404()
return jsonify(e.to_dict_api())
@bp.route("/moduleimpl/<int:moduleimpl_id>/evaluations")
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/evaluations")
@login_required
@ -33,39 +70,16 @@ def evaluations(moduleimpl_id: int):
moduleimpl_id : l'id d'un moduleimpl
Exemple de résultat :
[
{
"moduleimpl_id": 1,
"jour": "20/04/2022",
"heure_debut": "08h00",
"description": "eval1",
"coefficient": 1.0,
"publish_incomplete": false,
"numero": 0,
"id": 1,
"heure_fin": "09h00",
"note_max": 20.0,
"visibulletin": true,
"evaluation_type": 0,
"evaluation_id": 1,
"jouriso": "2022-04-20",
"duree": "1h",
"descrheure": " de 08h00 à 09h00",
"matin": 1,
"apresmidi": 0
},
...
]
Exemple de résultat : voir /evaluation
"""
query = Evaluation.query.filter_by(id=moduleimpl_id)
query = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id)
if g.scodoc_dept:
query = (
query.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
)
return jsonify([d.to_dict() for d in query])
return jsonify([e.to_dict_api() for e in query])
@bp.route("/evaluation/<int:evaluation_id>/notes")

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -11,7 +11,7 @@ from flask import g, jsonify, request
from flask_login import login_required
import app
from app.api import api_bp as bp, api_web_bp
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
from app.comp import res_sem
@ -22,6 +22,8 @@ from app.models import (
Evaluation,
FormSemestre,
FormSemestreEtape,
FormSemestreInscription,
Identite,
ModuleImpl,
NotesNotes,
)
@ -30,6 +32,7 @@ from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import ModuleType
import app.scodoc.sco_utils as scu
from app.tables.recap import TableRecap
@bp.route("/formsemestre/<int:formsemestre_id>")
@ -95,11 +98,14 @@ def formsemestres_query():
annee_scolaire : année de début de l'année scolaire
dept_acronym : acronyme du département (eg "RT")
dept_id : id du département
ine ou nip: code d'un étudiant: ramène alors tous les semestres auxquels il est inscrit.
"""
etape_apo = request.args.get("etape_apo")
annee_scolaire = request.args.get("annee_scolaire")
dept_acronym = request.args.get("dept_acronym")
dept_id = request.args.get("dept_id")
nip = request.args.get("nip")
ine = request.args.get("ine")
formsemestres = FormSemestre.query
if g.scodoc_dept:
formsemestres = formsemestres.filter_by(dept_id=g.scodoc_dept_id)
@ -107,7 +113,7 @@ def formsemestres_query():
try:
annee_scolaire_int = int(annee_scolaire)
except ValueError:
return json_error(404, "invalid annee_scolaire: not int")
return json_error(API_CLIENT_ERROR, "invalid annee_scolaire: not int")
debut_annee = scu.date_debut_anne_scolaire(annee_scolaire_int)
fin_annee = scu.date_fin_anne_scolaire(annee_scolaire_int)
formsemestres = formsemestres.filter(
@ -119,22 +125,36 @@ def formsemestres_query():
try:
dept_id = int(dept_id)
except ValueError:
return json_error(404, "invalid dept_id: not int")
return json_error(404, "invalid dept_id: integer expected")
formsemestres = formsemestres.filter_by(dept_id=dept_id)
if etape_apo is not None:
formsemestres = formsemestres.join(FormSemestreEtape).filter(
FormSemestreEtape.etape_apo == etape_apo
)
inscr_joined = False
if nip is not None:
formsemestres = (
formsemestres.join(FormSemestreInscription)
.join(Identite)
.filter_by(code_nip=nip)
)
inscr_joined = True
if ine is not None:
if not inscr_joined:
formsemestres = formsemestres.join(FormSemestreInscription).join(Identite)
formsemestres = formsemestres.filter_by(code_ine=ine)
return jsonify([formsemestre.to_dict_api() for formsemestre in formsemestres])
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def bulletins(formsemestre_id: int):
def bulletins(formsemestre_id: int, version: str = "long"):
"""
Retourne les bulletins d'un formsemestre donné
@ -145,12 +165,16 @@ def bulletins(formsemestre_id: int):
query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
formsemestre: FormSemestre = query.first()
if formsemestre is None:
return json_error(404, "formsemestre non trouve")
app.set_sco_dept(formsemestre.departement.acronym)
data = []
for etu in formsemestre.etuds:
bul_etu = get_formsemestre_bulletin_etud_json(formsemestre, etu)
bul_etu = get_formsemestre_bulletin_etud_json(
formsemestre, etu, version=version
)
data.append(bul_etu.json)
return jsonify(data)
@ -381,7 +405,7 @@ def etat_evals(formsemestre_id: int):
for evaluation_id in modimpl_results.evaluations_etat:
eval_etat = modimpl_results.evaluations_etat[evaluation_id]
evaluation = Evaluation.query.get_or_404(evaluation_id)
eval_dict = evaluation.to_dict()
eval_dict = evaluation.to_dict_api()
eval_dict["etat"] = eval_etat.to_dict()
eval_dict["nb_inscrits"] = modimpl_results.nb_inscrits_module
@ -444,7 +468,7 @@ def formsemestre_resultat(formsemestre_id: int):
"""
format_spec = request.args.get("format", None)
if format_spec is not None and format_spec != "raw":
return json_error(404, "invalid format specification")
return json_error(API_CLIENT_ERROR, "invalid format specification")
convert_values = format_spec != "raw"
query = FormSemestre.query.filter_by(id=formsemestre_id)
@ -453,16 +477,14 @@ def formsemestre_resultat(formsemestre_id: int):
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
app.set_sco_dept(formsemestre.departement.acronym)
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
rows, footer_rows, titles, column_ids = res.get_table_recap(
convert_values=convert_values,
include_evaluations=False,
mode_jury=False,
allow_html=False,
table = TableRecap(
res, convert_values=convert_values, include_evaluations=False, mode_jury=False
)
# Supprime les champs inutiles (mise en forme)
table = [{k: row[k] for k in row if not k[0] == "_"} for row in rows]
# Ajoute les groupes
rows = table.to_list()
# Ajoute le groupe de chaque partition:
etud_groups = sco_groups.get_formsemestre_etuds_groups(formsemestre_id)
for row in table:
for row in rows:
row["partitions"] = etud_groups.get(row["etudid"], {})
return jsonify(table)
return jsonify(rows)

View File

@ -1,24 +1,22 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""
ScoDoc 9 API : jury
ScoDoc 9 API : jury WIP
"""
from flask import g, jsonify, request
from flask import jsonify
from flask_login import login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.decorators import scodoc, permission_required
from app.scodoc.sco_exceptions import ScoException
from app.scodoc.sco_utils import json_error
from app.but import jury_but_recap
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.but import jury_but_results
from app.models import FormSemestre
from app.scodoc.sco_permissions import Permission
@ -33,7 +31,7 @@ def decisions_jury(formsemestre_id: int):
formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id)
if formsemestre.formation.is_apc():
app.set_sco_dept(formsemestre.departement.acronym)
rows = jury_but_recap.get_jury_but_results(formsemestre)
rows = jury_but_results.get_jury_but_results(formsemestre)
return jsonify(rows)
else:
raise ScoException("non implemente")

591
app/api/justificatifs.py Normal file
View File

@ -0,0 +1,591 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask import g, jsonify, request
from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object
from app.decorators import permission_required, scodoc
from app.models import Identite, Justificatif
from app.models.assiduites import compute_assiduites_justified
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
# Partie Modèle
@bp.route("/justificatif/<int:justif_id>")
@api_web_bp.route("/justificatif/<int:justif_id>")
@scodoc
@permission_required(Permission.ScoView)
def justificatif(justif_id: int = None):
"""Retourne un objet justificatif à partir de son id
Exemple de résultat:
{
"justif_id": 1,
"etudid": 2,
"date_debut": "2022-10-31T08:00+01:00",
"date_fin": "2022-10-31T10:00+01:00",
"etat": "valide",
"fichier": "archive_id",
"raison": "une raison",
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
"""
return get_model_api_object(Justificatif, justif_id, Identite)
@bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/<int:etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/<int:etudid>/query", defaults={"with_query": True})
@login_required
@scodoc
@permission_required(Permission.ScoView)
def justificatifs(etudid: int = None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /justificatifs/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /justificatifs/<int:etudid>/query?
Les différents filtres :
Etat (etat du justificatif -> validé, non validé, modifé, en attente):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=validé,modifié
Date debut
(date de début du justificatif, sont affichés les justificatifs
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin du justificatif, sont affichés les justificatifs
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
user_id (l'id de l'auteur du justificatif)
query?user_id=[int]
ex query?user_id=3
"""
query = Identite.query.filter_by(id=etudid)
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first_or_404(etudid)
justificatifs_query = etud.justificatifs
if with_query:
justificatifs_query = _filter_manager(request, justificatifs_query)
data_set: list[dict] = []
for just in justificatifs_query.all():
data = just.to_dict(format_api=True)
data_set.append(data)
return jsonify(data_set)
@bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_create(etudid: int = None):
"""
Création d'un justificatif pour l'étudiant (etudid)
La requête doit avoir un content type "application/json":
[
{
"date_debut": str,
"date_fin": str,
"etat": str,
},
{
"date_debut": str,
"date_fin": str,
"etat": str,
"raison":str,
}
...
]
"""
etud: Identite = Identite.query.filter_by(id=etudid).first_or_404()
create_list: list[object] = request.get_json(force=True)
if not isinstance(create_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
errors: dict[int, str] = {}
success: dict[int, object] = {}
for i, data in enumerate(create_list):
code, obj = _create_singular(data, etud)
if code == 404:
errors[i] = obj
else:
success[i] = obj
compute_assiduites_justified(Justificatif.query.filter_by(etudid=etudid), True)
return jsonify({"errors": errors, "success": success})
def _create_singular(
data: dict,
etud: Identite,
) -> tuple[int, object]:
errors: list[str] = []
# -- vérifications de l'objet json --
# cas 1 : ETAT
etat = data.get("etat", None)
if etat is None:
errors.append("param 'etat': manquant")
elif not scu.EtatJustificatif.contains(etat):
errors.append("param 'etat': invalide")
etat = scu.EtatJustificatif.get(etat)
# cas 2 : date_debut
date_debut = data.get("date_debut", None)
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut, convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 3 : date_fin
date_fin = data.get("date_fin", None)
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin, convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
# cas 4 : raison
raison: str = data.get("raison", None)
if errors:
err: str = ", ".join(errors)
return (404, err)
# TOUT EST OK
try:
nouv_justificatif: Justificatif = Justificatif.create_justificatif(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
raison=raison,
user_id=current_user.id,
)
db.session.add(nouv_justificatif)
db.session.commit()
return (
200,
{
"justif_id": nouv_justificatif.id,
"couverture": scass.justifies(nouv_justificatif),
},
)
except ScoValueError as excp:
return (
404,
excp.args[0],
)
@bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_edit(justif_id: int):
"""
Edition d'un justificatif à partir de son id
La requête doit avoir un content type "application/json":
{
"etat"?: str,
"raison"?: str
"date_debut"?: str
"date_fin"?: str
}
"""
justificatif_unique: Justificatif = Justificatif.query.filter_by(
id=justif_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
avant_ids: list[int] = scass.justifies(justificatif_unique)
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatJustificatif.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
justificatif_unique.etat = etat
# Cas 2 : raison
raison = data.get("raison", False)
if raison is not False:
justificatif_unique.raison = raison
deb, fin = None, None
# cas 3 : date_debut
date_debut = data.get("date_debut", False)
if date_debut is not False:
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut.replace(" ", "+"), convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
if justificatif_unique.date_fin >= deb:
errors.append("param 'date_debut': date de début située après date de fin ")
# cas 4 : date_fin
date_fin = data.get("date_fin", False)
if date_fin is not False:
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin.replace(" ", "+"), convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
if justificatif_unique.date_debut <= fin:
errors.append("param 'date_fin': date de fin située avant date de début ")
# Mise à jour des dates
deb = deb if deb is not None else justificatif_unique.date_debut
fin = fin if fin is not None else justificatif_unique.date_fin
justificatif_unique.date_debut = deb
justificatif_unique.date_fin = fin
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(justificatif_unique)
db.session.commit()
return jsonify(
{
"couverture": {
"avant": avant_ids,
"après": compute_assiduites_justified(
Justificatif.query.filter_by(etudid=justificatif_unique.etudid),
True,
),
}
}
)
@bp.route("/justificatif/delete", methods=["POST"])
@api_web_bp.route("/justificatif/delete", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_delete():
"""
Suppression d'un justificatif à partir de son id
Forme des données envoyées :
[
<justif_id:int>,
...
]
"""
justificatifs_list: list[int] = request.get_json(force=True)
if not isinstance(justificatifs_list, list):
return json_error(404, "Le contenu envoyé n'est pas une liste")
output = {"errors": {}, "success": {}}
for i, ass in enumerate(justificatifs_list):
code, msg = _delete_singular(ass, db)
if code == 404:
output["errors"][f"{i}"] = msg
else:
output["success"][f"{i}"] = {"OK": True}
db.session.commit()
return jsonify(output)
def _delete_singular(justif_id: int, database):
justificatif_unique: Justificatif = Justificatif.query.filter_by(
id=justif_id
).first()
if justificatif_unique is None:
return (404, "Justificatif non existant")
archive_name: str = justificatif_unique.fichier
if archive_name is not None:
archiver: JustificatifArchiver = JustificatifArchiver()
archiver.delete_justificatif(justificatif_unique.etudid, archive_name)
database.session.delete(justificatif_unique)
return (200, "OK")
# Partie archivage
@bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_import(justif_id: int = None):
"""
Importation d'un fichier (création d'archive)
"""
if len(request.files) == 0:
return json_error(404, "Il n'y a pas de fichier joint")
file = list(request.files.values())[0]
if file.filename == "":
return json_error(404, "Il n'y a pas de fichier joint")
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
archiver: JustificatifArchiver = JustificatifArchiver()
try:
fname: str
archive_name, fname = archiver.save_justificatif(
etudid=justificatif_unique.etudid,
filename=file.filename,
data=file.stream.read(),
archive_name=archive_name,
)
justificatif_unique.fichier = archive_name
db.session.add(justificatif_unique)
db.session.commit()
return jsonify({"filename": fname})
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_export(justif_id: int = None, filename: str = None):
"""
Retourne un fichier d'une archive d'un justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
if archive_name is None:
return json_error(404, "le justificatif ne possède pas de fichier")
archiver: JustificatifArchiver = JustificatifArchiver()
try:
return archiver.get_justificatif_file(
archive_name, justificatif_unique.etudid, filename
)
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_remove(justif_id: int = None):
"""
Supression d'un fichier ou d'une archive
# TOTALK: Doc, expliquer les noms coté server
{
"remove": <"all"/"list">
"filenames"?: [
<filename:str>,
...
]
}
"""
data: dict = request.get_json(force=True)
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
if archive_name is None:
return json_error(404, "le justificatif ne possède pas de fichier")
remove: str = data.get("remove")
if remove is None or remove not in ("all", "list"):
return json_error(404, "param 'remove': Valeur invalide")
archiver: JustificatifArchiver = JustificatifArchiver()
etudid: int = justificatif_unique.etudid
try:
if remove == "all":
archiver.delete_justificatif(etudid=etudid, archive_name=archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
else:
for fname in data.get("filenames", []):
archiver.delete_justificatif(
etudid=etudid,
archive_name=archive_name,
filename=fname,
)
if len(archiver.list_justificatifs(archive_name, etudid)) == 0:
archiver.delete_justificatif(etudid, archive_name)
justificatif_unique.fichier = None
db.session.add(justificatif_unique)
db.session.commit()
except ScoValueError as err:
return json_error(404, err.args[0])
return jsonify({"response": "removed"})
@bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_list(justif_id: int = None):
"""
Liste les fichiers du justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
archive_name: str = justificatif_unique.fichier
filenames: list[str] = []
archiver: JustificatifArchiver = JustificatifArchiver()
if archive_name is not None:
filenames = archiver.list_justificatifs(
archive_name, justificatif_unique.etudid
)
return jsonify(filenames)
# Partie justification
@bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_justifies(justif_id: int = None):
"""
Liste assiduite_id justifiées par le justificatif
"""
query = Justificatif.query.filter_by(id=justif_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
justificatif_unique: Justificatif = query.first_or_404()
assiduites_list: list[int] = scass.justifies(justificatif_unique)
return jsonify(assiduites_list)
# -- Utils --
def _filter_manager(requested, justificatifs_query):
"""
Retourne les justificatifs entrés filtrés en fonction de la request
"""
# cas 1 : etat justificatif
etat = requested.args.get("etat")
if etat is not None:
justificatifs_query = scass.filter_justificatifs_by_etat(
justificatifs_query, etat
)
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
justificatifs_query: Justificatif = scass.filter_by_date(
justificatifs_query, Justificatif, deb, fin
)
user_id = requested.args.get("user_id", False)
if user_id is not False:
justificatif_query: Justificatif = scass.filter_by_user_id(
justificatif_query, user_id
)
return justificatifs_query

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -48,6 +48,7 @@ from app.scodoc.sco_permissions import Permission
@scodoc
@permission_required(Permission.ScoSuperAdmin)
def api_get_glob_logos():
"""Liste tous les logos"""
logos = list_logos()[None]
return jsonify(list(logos.keys()))

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -12,13 +12,14 @@ from flask_login import login_required
import app
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.decorators import scodoc, permission_required
from app.scodoc.sco_utils import json_error
from app.models import FormSemestre, FormSemestreInscription, Identite
from app.models import GroupDescr, Partition
from app.models import GroupDescr, Partition, Scolog
from app.models.groups import group_membership
from app.scodoc import sco_cache
from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
@ -137,7 +138,7 @@ def etud_in_group_query(group_id: int):
"""Étudiants du groupe, filtrés par état"""
etat = request.args.get("etat")
if etat not in {None, scu.INSCRIT, scu.DEMISSION, scu.DEF}:
return json_error(404, "etat: valeur invalide")
return json_error(API_CLIENT_ERROR, "etat: valeur invalide")
query = GroupDescr.query.filter_by(id=group_id)
if g.scodoc_dept:
query = (
@ -170,24 +171,15 @@ def set_etud_group(etudid: int, group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if etud.id not in {e.id for e in group.partition.formsemestre.etuds}:
return json_error(404, "etud non inscrit au formsemestre du groupe")
groups = (
GroupDescr.query.filter_by(partition_id=group.partition.id)
.join(group_membership)
.filter_by(etudid=etudid)
sco_groups.change_etud_group_in_partition(
etudid, group_id, group.partition.to_dict()
)
ok = False
for other_group in groups:
if other_group.id == group_id:
ok = True
else:
other_group.etuds.remove(etud)
if not ok:
group.etuds.append(etud)
log(f"set_etud_group({etud}, {group})")
db.session.commit()
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return jsonify({"group_id": group_id, "etudid": etudid})
@ -207,9 +199,19 @@ def group_remove_etud(group_id: int, etudid: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if etud in group.etuds:
group.etuds.remove(etud)
db.session.commit()
Scolog.logdb(
method="group_remove_etud",
etudid=etud.id,
msg=f"Retrait du groupe {group.group_name} de {group.partition.partition_name}",
commit=True,
)
# Update parcours
group.partition.formsemestre.update_inscriptions_parcours_from_groups()
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return jsonify({"group_id": group_id, "etudid": etudid})
@ -232,6 +234,8 @@ def partition_remove_etud(partition_id: int, etudid: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
groups = (
GroupDescr.query.filter_by(partition_id=partition_id)
.join(group_membership)
@ -239,7 +243,15 @@ def partition_remove_etud(partition_id: int, etudid: int):
)
for group in groups:
group.etuds.remove(etud)
Scolog.logdb(
method="partition_remove_etud",
etudid=etud.id,
msg=f"Retrait du groupe {group.group_name} de {group.partition.partition_name}",
commit=True,
)
db.session.commit()
# Update parcours
partition.formsemestre.update_inscriptions_parcours_from_groups()
app.set_sco_dept(partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
return jsonify({"partition_id": partition_id, "etudid": etudid})
@ -262,14 +274,16 @@ def group_create(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.groups_editable:
return json_error(404, "partition non editable")
return json_error(403, "partition non editable")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is None:
return json_error(404, "missing group name or invalid data format")
return json_error(API_CLIENT_ERROR, "missing group name or invalid data format")
if not GroupDescr.check_name(partition, group_name):
return json_error(404, "invalid group_name")
return json_error(API_CLIENT_ERROR, "invalid group_name")
group_name = group_name.strip()
group = GroupDescr(group_name=group_name, partition_id=partition_id)
@ -294,8 +308,10 @@ def group_delete(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable:
return json_error(404, "partition non editable")
return json_error(403, "partition non editable")
formsemestre_id = group.partition.formsemestre_id
log(f"deleting {group}")
db.session.delete(group)
@ -318,14 +334,16 @@ def group_edit(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable:
return json_error(404, "partition non editable")
return json_error(403, "partition non editable")
data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name")
if group_name is not None:
group_name = group_name.strip()
if not GroupDescr.check_name(group.partition, group_name, existing=True):
return json_error(404, "invalid group_name")
return json_error(API_CLIENT_ERROR, "invalid group_name")
group.group_name = group_name
db.session.add(group)
db.session.commit()
@ -358,17 +376,23 @@ def partition_create(formsemestre_id: int):
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
if not formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
data = request.get_json(force=True) # may raise 400 Bad Request
partition_name = data.get("partition_name")
if partition_name is None:
return json_error(404, "missing partition_name or invalid data format")
return json_error(
API_CLIENT_ERROR, "missing partition_name or invalid data format"
)
if partition_name == scu.PARTITION_PARCOURS:
return json_error(404, f"invalid partition_name {scu.PARTITION_PARCOURS}")
return json_error(
API_CLIENT_ERROR, f"invalid partition_name {scu.PARTITION_PARCOURS}"
)
if not Partition.check_name(formsemestre, partition_name):
return json_error(404, "invalid partition_name")
return json_error(API_CLIENT_ERROR, "invalid partition_name")
numero = data.get("numero", 0)
if not isinstance(numero, int):
return json_error(404, "invalid type for numero")
return json_error(API_CLIENT_ERROR, "invalid type for numero")
args = {
"formsemestre_id": formsemestre_id,
"partition_name": partition_name.strip(),
@ -379,7 +403,7 @@ def partition_create(formsemestre_id: int):
boolean_field, False if boolean_field != "groups_editable" else True
)
if not isinstance(value, bool):
return json_error(404, f"invalid type for {boolean_field}")
return json_error(API_CLIENT_ERROR, f"invalid type for {boolean_field}")
args[boolean_field] = value
partition = Partition(**args)
@ -406,12 +430,14 @@ def formsemestre_order_partitions(formsemestre_id: int):
if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_id)
if not formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
partition_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(partition_ids, int) and not all(
isinstance(x, int) for x in partition_ids
):
return json_error(
404,
API_CLIENT_ERROR,
message="paramètre liste des partitions invalide",
)
for p_id, numero in zip(partition_ids, range(len(partition_ids))):
@ -443,12 +469,14 @@ def partition_order_groups(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
group_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(group_ids, int) and not all(
isinstance(x, int) for x in group_ids
):
return json_error(
404,
API_CLIENT_ERROR,
message="paramètre liste de groupe invalide",
)
for group_id, numero in zip(group_ids, range(len(group_ids))):
@ -484,24 +512,28 @@ def partition_edit(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
data = request.get_json(force=True) # may raise 400 Bad Request
modified = False
partition_name = data.get("partition_name")
#
if partition_name is not None and partition_name != partition.partition_name:
if partition.is_parcours():
return json_error(404, f"can't rename {scu.PARTITION_PARCOURS}")
return json_error(
API_CLIENT_ERROR, f"can't rename {scu.PARTITION_PARCOURS}"
)
if not Partition.check_name(
partition.formsemestre, partition_name, existing=True
):
return json_error(404, "invalid partition_name")
return json_error(API_CLIENT_ERROR, "invalid partition_name")
partition.partition_name = partition_name.strip()
modified = True
numero = data.get("numero")
if numero is not None and numero != partition.numero:
if not isinstance(numero, int):
return json_error(404, "invalid type for numero")
return json_error(API_CLIENT_ERROR, "invalid type for numero")
partition.numero = numero
modified = True
@ -509,9 +541,11 @@ def partition_edit(partition_id: int):
value = data.get(boolean_field)
if value is not None and value != getattr(partition, boolean_field):
if not isinstance(value, bool):
return json_error(404, f"invalid type for {boolean_field}")
return json_error(API_CLIENT_ERROR, f"invalid type for {boolean_field}")
if boolean_field == "groups_editable" and partition.is_parcours():
return json_error(404, f"can't change {scu.PARTITION_PARCOURS}")
return json_error(
API_CLIENT_ERROR, f"can't change {scu.PARTITION_PARCOURS}"
)
setattr(partition, boolean_field, value)
modified = True
@ -542,8 +576,12 @@ def partition_delete(partition_id: int):
if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.partition_name:
return json_error(404, "ne peut pas supprimer la partition par défaut")
return json_error(
API_CLIENT_ERROR, "ne peut pas supprimer la partition par défaut"
)
is_parcours = partition.is_parcours()
formsemestre: FormSemestre = partition.formsemestre
log(f"deleting partition {partition}")

View File

@ -18,6 +18,8 @@ def get_token():
@token_auth.login_required
def revoke_token():
"révoque le jeton de l'utilisateur courant"
token_auth.current_user().revoke_token()
user = token_auth.current_user()
user.revoke_token()
db.session.commit()
log(f"API: revoking token for {user}")
return "", 204

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : outils

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -12,8 +12,8 @@
from flask import g, jsonify, request
from flask_login import current_user, login_required
from app import db, log
from app.api import api_bp as bp, api_web_bp
from app import db
from app.api import api_bp as bp, api_web_bp, API_CLIENT_ERROR
from app.scodoc.sco_utils import json_error
from app.auth.models import User, Role, UserRole
from app.auth.models import is_valid_password
@ -187,7 +187,7 @@ def user_password(uid: int):
if not password:
return json_error(404, "user_password: missing password")
if not is_valid_password(password):
return json_error(400, "user_password: invalid password")
return json_error(API_CLIENT_ERROR, "user_password: invalid password")
allowed_depts = current_user.get_depts_with_permission(Permission.ScoUsersAdmin)
if (None not in allowed_depts) and ((user.dept not in allowed_depts)):
return json_error(403, "user_password: departement non autorise")

View File

@ -6,3 +6,4 @@ from flask import Blueprint
bp = Blueprint("auth", __name__)
from app.auth import routes
from app.auth import cas

251
app/auth/cas.py Normal file
View File

@ -0,0 +1,251 @@
# -*- coding: UTF-8 -*
"""
auth.cas.py
"""
import datetime
import flask
from flask import current_app, flash, url_for
from flask_login import current_user, login_user
from app import db
from app.auth import bp
from app.auth.models import User
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_excel
from app.scodoc.sco_exceptions import ScoValueError, AccessDenied
import app.scodoc.sco_utils as scu
# after_cas_login/after_cas_logout : routes appelées par redirect depuis le serveur CAS.
@bp.route("/after_cas_login")
def after_cas_login():
"Called by CAS after CAS authentication"
# Ici on a les infos dans flask.session["CAS_ATTRIBUTES"]
if ScoDocSiteConfig.is_cas_enabled() and ("CAS_ATTRIBUTES" in flask.session):
# Lookup user:
cas_id = flask.session["CAS_ATTRIBUTES"].get(
"cas:" + ScoDocSiteConfig.get("cas_attribute_id"),
flask.session.get("CAS_USERNAME"),
)
if cas_id is not None:
user: User = User.query.filter_by(cas_id=cas_id).first()
if user and user.active:
if user.cas_allow_login:
current_app.logger.info(f"CAS: login {user.user_name}")
if login_user(user):
flask.session[
"scodoc_cas_login_date"
] = datetime.datetime.now().isoformat()
user.cas_last_login = datetime.datetime.utcnow()
db.session.add(user)
db.session.commit()
return flask.redirect(url_for("scodoc.index"))
else:
current_app.logger.info(
f"CAS login denied for {user.user_name} (not allowed to use CAS)"
)
else:
current_app.logger.info(
f"""CAS login denied for {
user.user_name if user else ""
} cas_id={cas_id} (unknown or inactive)"""
)
else:
current_app.logger.info(
f"""CAS attribute '{ScoDocSiteConfig.get("cas_attribute_id")}' not found !
(check your ScoDoc config)"""
)
# Echec:
flash("échec de l'authentification")
return flask.redirect(url_for("auth.login"))
@bp.route("/after_cas_logout")
def after_cas_logout():
"Called by CAS after CAS logout"
flash("Vous êtes déconnecté")
current_app.logger.info("after_cas_logout")
return flask.redirect(url_for("scodoc.index"))
def cas_error_callback(message):
"Called by CAS when an error occurs, with a message"
raise ScoValueError(f"Erreur authentification CAS: {message}")
def set_cas_configuration(app: flask.app.Flask = None):
"""Force la configuration du module flask_cas à partir des paramètres de
la config de ScoDoc.
Appelé au démarrage et à chaque modif des paramètres.
"""
app = app or current_app
if ScoDocSiteConfig.is_cas_enabled():
current_app.logger.debug("CAS: set_cas_configuration")
app.config["CAS_SERVER"] = ScoDocSiteConfig.get("cas_server")
app.config["CAS_LOGIN_ROUTE"] = ScoDocSiteConfig.get("cas_login_route", "/cas")
app.config["CAS_LOGOUT_ROUTE"] = ScoDocSiteConfig.get(
"cas_logout_route", "/cas/logout"
)
app.config["CAS_VALIDATE_ROUTE"] = ScoDocSiteConfig.get(
"cas_validate_route", "/cas/serviceValidate"
)
app.config["CAS_AFTER_LOGIN"] = "auth.after_cas_login"
app.config["CAS_AFTER_LOGOUT"] = "auth.after_cas_logout"
app.config["CAS_ERROR_CALLBACK"] = cas_error_callback
app.config["CAS_SSL_VERIFY"] = ScoDocSiteConfig.get("cas_ssl_verify")
app.config["CAS_SSL_CERTIFICATE"] = ScoDocSiteConfig.get("cas_ssl_certificate")
else:
app.config.pop("CAS_SERVER", None)
app.config.pop("CAS_AFTER_LOGIN", None)
app.config.pop("CAS_AFTER_LOGOUT", None)
app.config.pop("CAS_SSL_VERIFY", None)
app.config.pop("CAS_SSL_CERTIFICATE", None)
CAS_USER_INFO_IDS = (
"user_name",
"nom",
"prenom",
"email",
"roles_string",
"active",
"dept",
"cas_id",
"cas_allow_login",
"cas_allow_scodoc_login",
"email_institutionnel",
)
CAS_USER_INFO_COMMENTS = (
"""user_name:
L'identifiant (login).
""",
"",
"",
"",
"Pour info: 0 si compte inactif",
"""Pour info: roles:
chaînes séparées par _:
1. Le rôle (Ens, Secr ou Admin)
2. Le département (en majuscule)
""",
"""dept:
Le département d'appartenance de l'utilisateur. Vide si l'utilisateur
intervient dans plusieurs départements.
""",
"""cas_id:
identifiant de l'utilisateur sur CAS (requis pour CAS).
""",
"""cas_allow_login:
autorise la connexion via CAS (optionnel, faux par défaut)
""",
"""cas_allow_scodoc_login
autorise connexion via ScoDoc même si CAS obligatoire (optionnel, faux par défaut)
""",
"""email_institutionnel
optionnel, le mail officiel de l'utilisateur.
Maximum 120 caractères.""",
)
def cas_users_generate_excel_sample() -> bytes:
"""generate an excel document suitable to import users CAS information"""
style = sco_excel.excel_make_style(bold=True)
titles = CAS_USER_INFO_IDS
titles_styles = [style] * len(titles)
# Extrait tous les utilisateurs (tous dept et statuts)
rows = []
for user in User.query.order_by(User.user_name):
u_dict = user.to_dict()
rows.append([u_dict.get(k) for k in CAS_USER_INFO_IDS])
return sco_excel.excel_simple_table(
lines=rows,
titles=titles,
titles_styles=titles_styles,
sheet_name="Utilisateurs ScoDoc",
comments=CAS_USER_INFO_COMMENTS,
)
def cas_users_import_excel_file(datafile) -> int:
"""
Import users CAS configuration from Excel file.
May change cas_id, cas_allow_login, cas_allow_scodoc_login
and active.
:param datafile: stream to be imported
:return: nb de comptes utilisateurs modifiés
"""
from app.scodoc import sco_import_users
if not current_user.is_administrator():
raise AccessDenied(f"invalid user ({current_user}) must be SuperAdmin")
current_app.logger.info("cas_users_import_excel_file by {current_user}")
users_infos = sco_import_users.read_users_excel_file(
datafile, titles=CAS_USER_INFO_IDS
)
return cas_users_import_data(users_infos=users_infos)
def cas_users_import_data(users_infos: list[dict]) -> int:
"""Import informations configuration CAS
users est une liste de dict, on utilise seulement les champs:
- user_name : la clé, l'utilisateur DOIT déjà exister
- cas_id : l'ID CAS a enregistrer.
- cas_allow_login
- cas_allow_scodoc_login
Les éventuels autres champs sont ignorés.
Return: nb de comptes modifiés.
"""
nb_modif = 0
users = []
for info in users_infos:
user: User = User.query.filter_by(user_name=info["user_name"]).first()
if not user:
db.session.rollback() # au cas où auto-flush
raise ScoValueError(f"""Utilisateur '{info["user_name"]}' inexistant""")
modif = False
new_cas_id = info["cas_id"].strip()
if new_cas_id != (user.cas_id or ""):
# check unicity
other = User.query.filter_by(cas_id=new_cas_id).first()
if other and other.id != user.id:
db.session.rollback() # au cas où auto-flush
raise ScoValueError(f"cas_id {new_cas_id} dupliqué")
user.cas_id = info["cas_id"].strip() or None
modif = True
val = scu.to_bool(info["cas_allow_login"])
if val != user.cas_allow_login:
user.cas_allow_login = val
modif = True
val = scu.to_bool(info["cas_allow_scodoc_login"])
if val != user.cas_allow_scodoc_login:
user.cas_allow_scodoc_login = val
modif = True
val = scu.to_bool(info["active"])
if val != (user.active or False):
user.active = val
modif = True
if modif:
nb_modif += 1
# Record modifications
for user in users:
try:
db.session.add(user)
except Exception as exc:
db.session.rollback()
raise ScoValueError(
"Erreur (1) durant l'importation des modifications"
) from exc
try:
db.session.commit()
except Exception as exc:
db.session.rollback()
raise ScoValueError(
"Erreur (2) durant l'importation des modifications"
) from exc
return nb_modif

View File

@ -1,15 +1,20 @@
# -*- coding: UTF-8 -*
from flask import render_template, current_app
from flask_babel import _
from app.email import send_email
from flask import render_template
from app.auth.models import User
from app.email import get_from_addr, send_email
def send_password_reset_email(user):
def send_password_reset_email(user: User):
"""Send message allowing to reset password"""
recipients = user.get_emails()
if not recipients:
return
token = user.get_reset_password_token()
send_email(
"[ScoDoc] Réinitialisation de votre mot de passe",
sender=current_app.config["SCODOC_MAIL_FROM"],
recipients=[user.email],
sender=get_from_addr(),
recipients=recipients,
text_body=render_template("email/reset_password.txt", user=user, token=token),
html_body=render_template("email/reset_password.html", user=user, token=token),
html_body=render_template("email/reset_password.j2", user=user, token=token),
)

View File

@ -1,13 +1,12 @@
# -*- coding: UTF-8 -*
"""Formulaires authentification
TODO: à revoir complètement pour reprendre ZScoUsers et les pages d'authentification
"""
from urllib.parse import urlparse, urljoin
from flask import request, url_for, redirect
from flask_wtf import FlaskForm
from wtforms import BooleanField, HiddenField, PasswordField, StringField, SubmitField
from wtforms.fields.simple import FileField
from wtforms.validators import ValidationError, DataRequired, Email, EqualTo
from app.auth.models import User, is_valid_password
@ -98,3 +97,12 @@ class ResetPasswordForm(FlaskForm):
class DeactivateUserForm(FlaskForm):
submit = SubmitField("Modifier l'utilisateur")
cancel = SubmitField(label="Annuler", render_kw={"formnovalidate": True})
class CASUsersImportConfigForm(FlaskForm):
user_config_file = FileField(
label="Fichier Excel à réimporter",
description="""fichier avec les paramètres CAS renseignés""",
)
submit = SubmitField("Importer le fichier utilisateurs")
cancel = SubmitField(label="Annuler", render_kw={"formnovalidate": True})

View File

@ -5,12 +5,14 @@
import http
import flask
from flask import g, redirect, request, url_for
from flask import current_app, g, redirect, request, url_for
from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth
import flask_login
from app import login
from app.scodoc.sco_utils import json_error
from app.auth.models import User
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_utils import json_error
basic_auth = HTTPBasicAuth()
token_auth = HTTPTokenAuth()
@ -83,3 +85,15 @@ def unauthorized():
if request.blueprint == "api" or request.blueprint == "apiweb":
return json_error(http.HTTPStatus.UNAUTHORIZED, "Non autorise (logic)")
return redirect(url_for("auth.login"))
def logout() -> flask.Response:
"""Logout the current user: If CAS session, logout from CAS. Redirect."""
if flask_login.current_user:
user_name = getattr(flask_login.current_user, "user_name", "anonymous")
current_app.logger.info(f"logout user {user_name}")
flask_login.logout_user()
if ScoDocSiteConfig.is_cas_enabled() and flask.session.get("scodoc_cas_login_date"):
flask.session.pop("scodoc_cas_login_date", None)
return redirect(url_for("cas.logout"))
return redirect(url_for("scodoc.index"))

View File

@ -19,9 +19,10 @@ from werkzeug.security import generate_password_hash, check_password_hash
import jwt
from app import db, log, login
from app import db, email, log, login
from app.models import Departement
from app.models import SHORT_STR_LEN
from app.models import SHORT_STR_LEN, USERNAME_STR_LEN
from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_roles_default import SCO_ROLES_DEFAULTS
@ -31,7 +32,7 @@ from app.scodoc import sco_etud # a deplacer dans scu
VALID_LOGIN_EXP = re.compile(r"^[a-zA-Z0-9@\\\-_\.]+$")
def is_valid_password(cleartxt):
def is_valid_password(cleartxt) -> bool:
"""Check password.
returns True if OK.
"""
@ -48,17 +49,45 @@ def is_valid_password(cleartxt):
return False
def invalid_user_name(user_name: str) -> bool:
"Check that user_name (aka login) is invalid"
return (
(len(user_name) < 2)
or (len(user_name) >= USERNAME_STR_LEN)
or not VALID_LOGIN_EXP.match(user_name)
)
class User(UserMixin, db.Model):
"""ScoDoc users, handled by Flask / SQLAlchemy"""
id = db.Column(db.Integer, primary_key=True)
user_name = db.Column(db.String(64), index=True, unique=True)
user_name = db.Column(db.String(USERNAME_STR_LEN), index=True, unique=True)
"le login"
email = db.Column(db.String(120))
nom = db.Column(db.String(64))
prenom = db.Column(db.String(64))
"email à utiliser par ScoDoc"
email_institutionnel = db.Column(db.String(120))
"email dans l'établissement, facultatif"
nom = db.Column(db.String(USERNAME_STR_LEN))
prenom = db.Column(db.String(USERNAME_STR_LEN))
dept = db.Column(db.String(SHORT_STR_LEN), index=True)
"acronyme du département de l'utilisateur"
active = db.Column(db.Boolean, default=True, index=True)
"si faux, compte utilisateur désactivé"
cas_id = db.Column(db.Text(), index=True, unique=True, nullable=True)
"uid sur le CAS (id, mail ou autre attribut, selon config.cas_attribute_id)"
cas_allow_login = db.Column(
db.Boolean, default=False, server_default="false", nullable=False
)
"Peut-on se logguer via le CAS ?"
cas_allow_scodoc_login = db.Column(
db.Boolean, default=False, server_default="false", nullable=False
)
"""Si CAS forcé (cas_force), peut-on se logguer sur ScoDoc directement ?
(le rôle ScoSuperAdmin peut toujours, mettre à True pour les utilisateur API)
"""
cas_last_login = db.Column(db.DateTime, nullable=True)
"""date du dernier login via CAS"""
password_hash = db.Column(db.String(128))
password_scodoc7 = db.Column(db.String(42))
@ -67,6 +96,8 @@ class User(UserMixin, db.Model):
date_created = db.Column(db.DateTime, default=datetime.utcnow)
date_expiration = db.Column(db.DateTime, default=None)
passwd_temp = db.Column(db.Boolean, default=False)
"""champ obsolete. Si connexion alors que passwd_temp est vrai,
efface mot de passe et redirige vers accueil."""
token = db.Column(db.Text(), index=True, unique=True)
token_expiration = db.Column(db.DateTime)
@ -86,7 +117,7 @@ class User(UserMixin, db.Model):
self.roles = []
self.user_roles = []
# check login:
if kwargs.get("user_name") and not VALID_LOGIN_EXP.match(kwargs["user_name"]):
if kwargs.get("user_name") and invalid_user_name(kwargs["user_name"]):
raise ValueError(f"invalid user_name: {kwargs['user_name']}")
super(User, self).__init__(**kwargs)
# Ajoute roles:
@ -103,7 +134,8 @@ class User(UserMixin, db.Model):
# current_app.logger.info("creating user with roles={}".format(self.roles))
def __repr__(self):
return f"<User {self.user_name} id={self.id} dept={self.dept}{' (inactive)' if not self.active else ''}>"
return f"""<User {self.user_name} id={self.id} dept={self.dept}{
' (inactive)' if not self.active else ''}>"""
def __str__(self):
return self.user_name
@ -115,30 +147,56 @@ class User(UserMixin, db.Model):
self.password_hash = generate_password_hash(password)
else:
self.password_hash = None
# La création d'un mot de passe efface l'éventuel mot de passe historique
self.password_scodoc7 = None
self.passwd_temp = False
def check_password(self, password):
def check_password(self, password: str) -> bool:
"""Check given password vs current one.
Returns `True` if the password matched, `False` otherwise.
"""
if not self.active: # inactived users can't login
current_app.logger.warning(
f"auth: login attempt from inactive account {self}"
)
return False
if (not self.password_hash) and self.password_scodoc7:
# Special case: user freshly migrated from ScoDoc7
if scu.check_scodoc7_password(self.password_scodoc7, password):
current_app.logger.warning(
f"migrating legacy ScoDoc7 password for {self}"
)
self.set_password(password)
self.password_scodoc7 = None
db.session.add(self)
db.session.commit()
return True
if self.passwd_temp:
# Anciens comptes ScoDoc 7 non migrés
# désactive le compte par sécurité.
current_app.logger.warning(f"auth: desactivating legacy account {self}")
self.active = False
self.passwd_temp = True
db.session.add(self)
db.session.commit()
send_notif_desactivation_user(self)
return False
# if CAS activated and forced, allow only super-user and users with cas_allow_scodoc_login
if ScoDocSiteConfig.is_cas_enabled() and ScoDocSiteConfig.get("cas_force"):
if (not self.is_administrator()) and not self.cas_allow_scodoc_login:
return False
if not self.password_hash: # user without password can't login
if self.password_scodoc7:
# Special case: user freshly migrated from ScoDoc7
return self._migrate_scodoc7_password(password)
return False
return check_password_hash(self.password_hash, password)
def _migrate_scodoc7_password(self, password) -> bool:
"""After migration, rehash password."""
if scu.check_scodoc7_password(self.password_scodoc7, password):
current_app.logger.warning(
f"auth: migrating legacy ScoDoc7 password for {self}"
)
self.set_password(password)
self.password_scodoc7 = None
db.session.add(self)
db.session.commit()
return True
return False
def get_reset_password_token(self, expires_in=600):
"Un token pour réinitialiser son mot de passe"
return jwt.encode(
@ -155,7 +213,7 @@ class User(UserMixin, db.Model):
token, current_app.config["SECRET_KEY"], algorithms=["HS256"]
)
except jwt.exceptions.ExpiredSignatureError:
log(f"verify_reset_password_token: token expired")
log("verify_reset_password_token: token expired")
except:
return None
try:
@ -184,6 +242,12 @@ class User(UserMixin, db.Model):
"dept": self.dept,
"id": self.id,
"active": self.active,
"cas_id": self.cas_id,
"cas_allow_login": self.cas_allow_login,
"cas_allow_scodoc_login": self.cas_allow_scodoc_login,
"cas_last_login": self.cas_last_login.isoformat() + "Z"
if self.cas_last_login
else None,
"status_txt": "actif" if self.active else "fermé",
"last_seen": self.last_seen.isoformat() + "Z" if self.last_seen else None,
"nom": (self.nom or ""), # sco8
@ -200,22 +264,39 @@ class User(UserMixin, db.Model):
}
if include_email:
data["email"] = self.email or ""
data["email_institutionnel"] = self.email_institutionnel or ""
return data
def from_dict(self, data, new_user=False):
def from_dict(self, data: dict, new_user=False):
"""Set users' attributes from given dict values.
Roles must be encoded as "roles_string", like "Ens_RT, Secr_CJ"
"""
for field in ["nom", "prenom", "dept", "active", "email", "date_expiration"]:
for field in [
"nom",
"prenom",
"dept",
"active",
"email",
"email_institutionnel",
"date_expiration",
"cas_id",
]:
if field in data:
setattr(self, field, data[field] or None)
# required boolean fields
for field in [
"cas_allow_login",
"cas_allow_scodoc_login",
]:
setattr(self, field, scu.to_bool(data.get(field, False)))
if new_user:
if "user_name" in data:
# never change name of existing users
self.user_name = data["user_name"]
if "password" in data:
self.set_password(data["password"])
if not VALID_LOGIN_EXP.match(self.user_name):
if invalid_user_name(self.user_name):
raise ValueError(f"invalid user_name: {self.user_name}")
# Roles: roles_string is "Ens_RT, Secr_RT, ..."
if "roles_string" in data:
@ -241,7 +322,7 @@ class User(UserMixin, db.Model):
@staticmethod
def check_token(token):
"""Retreive user for given token, chek token's validity
"""Retreive user for given token, check token's validity
and returns the user object.
"""
user = User.query.filter_by(token=token).first()
@ -255,6 +336,15 @@ class User(UserMixin, db.Model):
return self._departement.id
return None
def get_emails(self):
"List mail adresses to contact this user"
mails = []
if self.email:
mails.append(self.email)
if self.email_institutionnel:
mails.append(self.email_institutionnel)
return mails
# Permissions management:
def has_permission(self, perm: int, dept=False):
"""Check if user has permission `perm` in given `dept`.
@ -310,7 +400,7 @@ class User(UserMixin, db.Model):
"""string repr. of user's roles (with depts)
e.g. "Ens_RT, Ens_Info, Secr_CJ"
"""
return ",".join(
return ", ".join(
f"{r.role.name or ''}_{r.dept or ''}"
for r in self.user_roles
if r is not None
@ -339,24 +429,17 @@ class User(UserMixin, db.Model):
"""nomplogin est le nom en majuscules suivi du prénom et du login
e.g. Dupont Pierre (dupont)
"""
if self.nom:
n = sco_etud.format_nom(self.nom)
else:
n = self.user_name.upper()
return "%s %s (%s)" % (
n,
sco_etud.format_prenom(self.prenom),
self.user_name,
)
nom = sco_etud.format_nom(self.nom) if self.nom else self.user_name.upper()
return f"{nom} {sco_etud.format_prenom(self.prenom)} ({self.user_name})"
@staticmethod
def get_user_id_from_nomplogin(nomplogin: str) -> Optional[int]:
"""Returns id from the string "Dupont Pierre (dupont)"
or None if user does not exist
"""
m = re.match(r".*\((.*)\)", nomplogin.strip())
if m:
user_name = m.group(1)
match = re.match(r".*\((.*)\)", nomplogin.strip())
if match:
user_name = match.group(1)
u = User.query.filter_by(user_name=user_name).first()
if u:
return u.id
@ -393,6 +476,8 @@ class User(UserMixin, db.Model):
class AnonymousUser(AnonymousUserMixin):
"Notre utilisateur anonyme"
def has_permission(self, perm, dept=None):
return False
@ -509,7 +594,7 @@ class UserRole(db.Model):
)
def __repr__(self):
return "<UserRole u={} r={} dept={}>".format(self.user, self.role, self.dept)
return f"<UserRole u={self.user} r={self.role} dept={self.dept}>"
@staticmethod
def role_dept_from_string(role_dept: str):
@ -517,18 +602,21 @@ class UserRole(db.Model):
role_dept, of the forme "Role_Dept".
role is a Role instance, dept is a string, or None.
"""
fields = role_dept.split("_", 1) # maxsplit=1, le dept peut contenir un "_"
fields = role_dept.strip().split("_", 1)
# maxsplit=1, le dept peut contenir un "_"
if len(fields) != 2:
current_app.logger.warning(
f"role_dept_from_string: Invalid role_dept '{role_dept}'"
f"auth: role_dept_from_string: Invalid role_dept '{role_dept}'"
)
raise ScoValueError("Invalid role_dept")
role_name, dept = fields
dept = dept.strip() if dept else ""
if dept == "":
dept = None
role = Role.query.filter_by(name=role_name).first()
if role is None:
raise ScoValueError("role %s does not exists" % role_name)
raise ScoValueError(f"role {role_name} does not exists")
return (role, dept)
@ -545,3 +633,22 @@ def get_super_admin():
)
assert admin_user
return admin_user
def send_notif_desactivation_user(user: User):
"""Envoi un message mail de notification à l'admin et à l'adresse du compte désactivé"""
recipients = user.get_emails() + [current_app.config.get("SCODOC_ADMIN_MAIL")]
txt = [
f"""Le compte ScoDoc '{user.user_name}' associé à votre adresse <{user.email}>""",
"""a été désactivé par le système car son mot de passe n'était pas valide.\n""",
"""Contactez votre responsable pour le ré-activer.\n""",
"""Ceci est un message automatique, ne pas répondre.""",
]
txt = "\n".join(txt)
email.send_email(
f"ScoDoc: désactivation automatique du compte {user.user_name}",
email.get_from_addr(),
recipients,
txt,
)
return txt

View File

@ -3,54 +3,88 @@
auth.routes.py
"""
import flask
from flask import current_app, flash, render_template
from flask import redirect, url_for, request
from flask_login import login_user, logout_user, current_user
from flask_login import login_user, current_user
from sqlalchemy import func
from app import db
from app.auth import bp
from app.auth import bp, cas, logic
from app.auth.forms import (
CASUsersImportConfigForm,
LoginForm,
UserCreationForm,
ResetPasswordRequestForm,
ResetPasswordForm,
ResetPasswordRequestForm,
UserCreationForm,
)
from app.auth.models import Role
from app.auth.models import User
from app.auth.models import Role, User, invalid_user_name
from app.auth.email import send_password_reset_email
from app.decorators import admin_required
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_utils as scu
_ = lambda x: x # sans babel
_l = _
@bp.route("/login", methods=["GET", "POST"])
def login():
"ScoDoc Login form"
if current_user.is_authenticated:
return redirect(url_for("scodoc.index"))
def _login_form():
"""le formulaire de login, avec un lien CAS s'il est configuré."""
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(user_name=form.user_name.data).first()
# note: ceci est la première requête SQL déclenchée par un utilisateur arrivant
if invalid_user_name(form.user_name.data):
user = None
else:
user = User.query.filter_by(user_name=form.user_name.data).first()
if user is None or not user.check_password(form.password.data):
current_app.logger.info("login: invalid (%s)", form.user_name.data)
flash(_("Nom ou mot de passe invalide"))
return redirect(url_for("auth.login"))
login_user(user, remember=form.remember_me.data)
current_app.logger.info("login: success (%s)", form.user_name.data)
return form.redirect("scodoc.index")
message = request.args.get("message", "")
return render_template(
"auth/login.html", title=_("Sign In"), form=form, message=message
"auth/login.j2",
title=_("Sign In"),
form=form,
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
)
@bp.route("/login", methods=["GET", "POST"])
def login():
"""ScoDoc Login form
Si paramètre cas_force, redirige vers le CAS.
"""
if current_user.is_authenticated:
return redirect(url_for("scodoc.index"))
if ScoDocSiteConfig.get("cas_force"):
current_app.logger.info("login: forcing CAS")
return redirect(url_for("cas.login"))
return _login_form()
@bp.route("/login_scodoc", methods=["GET", "POST"])
def login_scodoc():
"""ScoDoc Login form.
Formulaire login, sans redirection immédiate sur CAS si ce dernier est configuré.
Sans CAS, ce formulaire est identique à /login
"""
if current_user.is_authenticated:
return redirect(url_for("scodoc.index"))
return _login_form()
@bp.route("/logout")
def logout():
"Logout current user and redirect to home page"
logout_user()
return redirect(url_for("scodoc.index"))
def logout() -> flask.Response:
"Logout a scodoc user. If CAS session, logout from CAS. Redirect."
return logic.logout()
@bp.route("/create_user", methods=["GET", "POST"])
@ -63,11 +97,9 @@ def create_user():
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash("User {} created".format(user.user_name))
flash(f"Utilisateur {user.user_name} créé")
return redirect(url_for("scodoc.index"))
return render_template(
"auth/register.html", title="Création utilisateur", form=form
)
return render_template("auth/register.j2", title="Création utilisateur", form=form)
@bp.route("/reset_password_request", methods=["GET", "POST"])
@ -98,13 +130,16 @@ def reset_password_request():
)
return redirect(url_for("auth.login"))
return render_template(
"auth/reset_password_request.html", title=_("Reset Password"), form=form
"auth/reset_password_request.j2",
title=_("Reset Password"),
form=form,
is_cas_enabled=ScoDocSiteConfig.is_cas_enabled(),
)
@bp.route("/reset_password/<token>", methods=["GET", "POST"])
def reset_password(token):
"Reset passord après demande par mail"
"Reset password après demande par mail"
if current_user.is_authenticated:
return redirect(url_for("scodoc.index"))
user: User = User.verify_reset_password_token(token)
@ -116,7 +151,7 @@ def reset_password(token):
db.session.commit()
flash(_("Votre mot de passe a été changé."))
return redirect(url_for("auth.login"))
return render_template("auth/reset_password.html", form=form, user=user)
return render_template("auth/reset_password.j2", form=form, user=user)
@bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"])
@ -126,3 +161,34 @@ def reset_standard_roles_permissions():
Role.reset_standard_roles_permissions()
flash("rôles standards réinitialisés !")
return redirect(url_for("scodoc.configuration"))
@bp.route("/cas_users_generate_excel_sample")
@admin_required
def cas_users_generate_excel_sample():
"une feuille excel pour importation config CAS"
data = cas.cas_users_generate_excel_sample()
return scu.send_file(data, "ImportConfigCAS", scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE)
@bp.route("/cas_users_import_config", methods=["GET", "POST"])
@admin_required
def cas_users_import_config():
"""Import utilisateurs depuis feuille Excel"""
form = CASUsersImportConfigForm()
if form.validate_on_submit():
if form.cancel.data: # cancel button
return redirect(url_for("scodoc.configuration"))
datafile = request.files[form.user_config_file.name]
nb_modif = cas.cas_users_import_excel_file(datafile)
current_app.logger.info(f"cas_users_import_config: {nb_modif} comptes modifiés")
flash(f"Config. CAS de {nb_modif} comptes modifiée.")
return redirect(url_for("scodoc.configuration"))
return render_template(
"auth/cas_users_import_config.j2",
title=_("Importation configuration CAS utilisateurs"),
form=form,
)
return

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -8,15 +8,15 @@
Edition associations UE <-> Ref. Compétence
"""
from flask import g, url_for
from app import db, log
from app.models import Formation, UniteEns
from app.models.but_refcomp import ApcNiveau
from app.scodoc import sco_codes_parcours
from app.models import ApcReferentielCompetences, Formation, UniteEns
from app.scodoc import codes_cursus
def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
"""Form. HTML pour associer une UE à un niveau de compétence"""
if ue.type != sco_codes_parcours.UE_STANDARD:
def form_ue_choix_niveau(ue: UniteEns) -> str:
"""Form. HTML pour associer une UE à un niveau de compétence.
Le menu select lui meême est vide et rempli en JS par appel à get_ue_niveaux_options_html
"""
if ue.type != codes_cursus.UE_STANDARD:
return ""
ref_comp = ue.formation.referentiel_competence
if ref_comp is None:
@ -27,11 +27,70 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
}">associer un référentiel de compétence</a>
</div>
</div>"""
annee = 1 if ue.semestre_idx is None else (ue.semestre_idx + 1) // 2 # 1, 2, 3
niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
# Les parcours:
parcours_options = []
for parcour in ref_comp.parcours:
parcours_options.append(
f"""<option value="{parcour.id}" {
'selected' if ue.parcour == parcour else ''}
>{parcour.libelle} ({parcour.code})
</option>"""
)
newline = "\n"
return f"""
<div class="ue_choix_niveau">
<form class="form_ue_choix_niveau">
<div class="cont_ue_choix_niveau">
<div>
<b>Parcours&nbsp;:</b>
<select class="select_parcour"
onchange="set_ue_parcour(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_parcours", scodoc_dept=g.scodoc_dept)
}">
<option value="" {
'selected' if ue.parcour is None else ''
}>Tous</option>
{newline.join(parcours_options)}
</select>
</div>
<div>
<b>Niveau de compétence&nbsp;:</b>
<select class="select_niveau_ue"
onchange="set_ue_niveau_competence(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
}">
</select>
</div>
</div>
</form>
</div>
"""
def get_ue_niveaux_options_html(ue: UniteEns) -> str:
"""fragment html avec les options du menu de sélection du
niveau de compétences associé à une UE.
Si l'UE n'a pas de parcours associé: présente les niveaux
de tous les parcours.
Si l'UE a un parcours: seulement les niveaux de ce parcours.
"""
ref_comp: ApcReferentielCompetences = ue.formation.referentiel_competence
if ref_comp is None:
return ""
# Les niveaux:
annee = ue.annee() # 1, 2, 3
parcours, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(
annee, parcour=ue.parcour
)
# Les niveaux déjà associés à d'autres UE du même semestre
autres_ues = formation.ues.filter_by(semestre_idx=ue.semestre_idx)
autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)
niveaux_autres_ues = {
oue.niveau_competence_id for oue in autres_ues if oue.id != ue.id
}
@ -39,18 +98,14 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
if niveaux_by_parcours["TC"]: # TC pour Tronc Commun
options.append("""<optgroup label="Tronc commun">""")
for n in niveaux_by_parcours["TC"]:
if n.id in niveaux_autres_ues:
disabled = "disabled"
else:
disabled = ""
options.append(
f"""<option value="{n.id}" {'selected'
if ue.niveau_competence == n else ''}
{disabled}>{n.annee} {n.competence.titre_long}
f"""<option value="{n.id}" {
'selected' if ue.niveau_competence == n else ''}
>{n.annee} {n.competence.titre_long}
niveau {n.ordre}</option>"""
)
options.append("""</optgroup>""")
for parcour in ref_comp.parcours:
for parcour in parcours:
if len(niveaux_by_parcours[parcour.id]):
options.append(f"""<optgroup label="Parcours {parcour.libelle}">""")
for n in niveaux_by_parcours[parcour.id]:
@ -65,46 +120,7 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
niveau {n.ordre}</option>"""
)
options.append("""</optgroup>""")
options_str = "\n".join(options)
return f"""
<div class="ue_choix_niveau">
<form class="form_ue_choix_niveau">
<b>Niveau de compétence associé:</b>
<select onchange="set_ue_niveau_competence(this);"
data-ue_id="{ue.id}"
data-setter="{
url_for( "notes.set_ue_niveau_competence", scodoc_dept=g.scodoc_dept)
}">
<option value="" {'selected' if ue.niveau_competence is None else ''}>aucun</option>
{options_str}
</select>
</form>
</div>
"""
def set_ue_niveau_competence(ue_id: int, niveau_id: int):
"""Associe le niveau et l'UE"""
ue = UniteEns.query.get_or_404(ue_id)
autres_ues = ue.formation.ues.filter_by(semestre_idx=ue.semestre_idx)
niveaux_autres_ues = {
oue.niveau_competence_id for oue in autres_ues if oue.id != ue.id
}
if niveau_id in niveaux_autres_ues:
log(
f"set_ue_niveau_competence: denying association of {ue} to already associated {niveau_id}"
)
return "", 409 # conflict
if niveau_id == "":
niveau = ""
# suppression de l'association
ue.niveau_competence = None
else:
niveau = ApcNiveau.query.get_or_404(niveau_id)
ue.niveau_competence = niveau
db.session.add(ue)
db.session.commit()
log(f"set_ue_niveau_competence( {ue}, {niveau} )")
return "", 204
return (
f"""<option value="" {'selected' if ue.niveau_competence is None else ''}>aucun</option>"""
+ "\n".join(options)
)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -19,10 +19,10 @@ from app.models.ues import UniteEns
from app.scodoc import sco_bulletins, sco_utils as scu
from app.scodoc import sco_bulletins_json
from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_groups
from app.scodoc import sco_preferences
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
from app.scodoc.codes_cursus import UE_SPORT, DEF
from app.scodoc.sco_utils import fmt_note
@ -80,6 +80,9 @@ class BulletinBUT:
"""
res = self.res
if (etud.id, ue.id) in self.res.dispense_ues:
return {}
if ue.type == UE_SPORT:
modimpls_spo = [
modimpl
@ -154,7 +157,7 @@ class BulletinBUT:
for _, ue_capitalisee in self.res.validations.ue_capitalisees.loc[
[etud.id]
].iterrows():
if sco_codes_parcours.code_ue_validant(ue_capitalisee.code):
if codes_cursus.code_ue_validant(ue_capitalisee.code):
ue = UniteEns.query.get(ue_capitalisee.ue_id) # XXX cacher ?
# déjà capitalisé ? montre la meilleure
if ue.acronyme in d:
@ -184,6 +187,8 @@ class BulletinBUT:
)
if ue_capitalisee.formsemestre_id
else None,
"ressources": {}, # sans détail en BUT
"saes": {},
}
if self.prefs["bul_show_ects"]:
d[ue.acronyme]["ECTS"] = {
@ -239,6 +244,7 @@ class BulletinBUT:
self.etud_eval_results(etud, e)
for e in modimpl.evaluations
if (e.visibulletin or version == "long")
and (e.id in modimpl_results.evaluations_etat)
and (
modimpl_results.evaluations_etat[e.id].is_complete
or self.prefs["bul_show_all_evals"]
@ -256,10 +262,11 @@ class BulletinBUT:
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
try:
etud_ues_ids = self.res.etud_ues_ids(etud.id)
poids = {
ue.acronyme: modimpls_evals_poids[ue.id][e.id]
for ue in self.res.ues
if ue.type != UE_SPORT
if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
}
except KeyError:
poids = collections.defaultdict(lambda: 0.0)
@ -356,7 +363,7 @@ class BulletinBUT:
"formsemestre_id": formsemestre.id,
"etat_inscription": etat_inscription,
"options": sco_preferences.bulletin_option_affichage(
formsemestre.id, self.prefs
formsemestre, self.prefs
),
}
if not published:
@ -380,7 +387,7 @@ class BulletinBUT:
"injustifie": nbabs - nbabsjust,
"total": nbabs,
}
decisions_ues = self.res.get_etud_decision_ues(etud.id) or {}
decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {}
if self.prefs["bul_show_ects"]:
ects_tot = res.etud_ects_tot_sem(etud.id)
ects_acquis = res.get_etud_ects_valides(etud.id, decisions_ues)
@ -460,6 +467,7 @@ class BulletinBUT:
"ressources": {},
"saes": {},
"ues": {},
"ues_capitalisees": {},
}
)
@ -467,6 +475,7 @@ class BulletinBUT:
def bulletin_etud_complet(self, etud: Identite, version="long") -> dict:
"""Bulletin dict complet avec toutes les infos pour les bulletins BUT pdf
(pas utilisé pour json/html)
Résultat compatible avec celui de sco_bulletins.formsemestre_bulletinetud_dict
"""
d = self.bulletin_etud(
@ -495,7 +504,7 @@ class BulletinBUT:
# --- Decision Jury
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
etud.id,
self.res.formsemestre.id,
self.res.formsemestre,
format="html",
show_date_inscr=self.prefs["bul_show_date_inscr"],
show_decisions=self.prefs["bul_show_decision"],

View File

@ -1,10 +1,24 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Génération bulletin BUT au format PDF standard
La génération du bulletin PDF suit le chemin suivant:
- vue formsemestre_bulletinetud -> sco_bulletins.formsemestre_bulletinetud
bul_dict = bulletin_but.BulletinBUT(formsemestre).bulletin_etud_complet(etud)
- sco_bulletins_generator.make_formsemestre_bulletinetud(infos)
- instance de BulletinGeneratorStandardBUT(infos)
- BulletinGeneratorStandardBUT.generate(format="pdf")
sco_bulletins_generator.BulletinGenerator.generate()
.generate_pdf()
.bul_table() (ci-dessous)
"""
from reportlab.lib.colors import blue
from reportlab.lib.units import cm, mm
@ -12,7 +26,7 @@ from reportlab.platypus import Paragraph, Spacer
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc import gen_tables
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc import sco_utils as scu
@ -65,7 +79,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
return objects
def but_table_synthese_ues(self, title_bg=(182, 235, 255)):
def but_table_synthese_ues(
self, title_bg=(182, 235, 255), title_ue_cap_bg=(150, 207, 147)
):
"""La table de synthèse; pour chaque UE, liste des ressources et SAÉs avec leurs notes
et leurs coefs.
Renvoie: colkeys, P, pdf_style, colWidths
@ -74,6 +90,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
- pdf_style : commandes table Platypus
- largeurs de colonnes pour PDF
"""
# nb: self.infos a ici été donné par BulletinBUT.bulletin_etud_complet()
col_widths = {
"titre": None,
"min": 1.5 * cm,
@ -95,6 +112,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
col_keys += ["coef", "moyenne"]
# Couleur fond:
title_bg = tuple(x / 255.0 for x in title_bg)
title_ue_cap_bg = tuple(x / 255.0 for x in title_ue_cap_bg)
# elems pour générer table avec gen_table (liste de dicts)
rows = [
# Ligne de titres
@ -141,9 +159,17 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
blue,
),
]
for ue_acronym, ue in self.infos["ues"].items():
self.ue_rows(rows, ue_acronym, ue, title_bg)
ues = self.infos["ues"]
ues_capitalisees = self.infos.get("ues_capitalisees", {})
ues_tup = sorted(
list(ues.items()) + list(ues_capitalisees.items()),
key=lambda x: x[1]["numero"],
)
for ue_acronym, ue in ues_tup:
is_capitalized = "date_capitalisation" in ue
self._ue_rows(
rows, ue_acronym, ue, title_ue_cap_bg if is_capitalized else title_bg
)
# Global pdf style commands:
pdf_style = [
@ -152,20 +178,18 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
]
return col_keys, rows, pdf_style, col_widths
def ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple):
def _ue_rows(self, rows: list, ue_acronym: str, ue: dict, title_bg: tuple):
"Décrit une UE dans la table synthèse: titre, sous-titre et liste modules"
if (ue["type"] == UE_SPORT) and len(ue.get("modules", [])) == 0:
# ne mentionne l'UE que s'il y a des modules
return
# 1er ligne titre UE
moy_ue = ue.get("moyenne")
moy_ue = ue.get("moyenne", "-")
if isinstance(moy_ue, dict):
moy_ue = moy_ue.get("value", "-") if moy_ue is not None else "-"
t = {
"titre": f"{ue_acronym} - {ue['titre']}",
"moyenne": Paragraph(
f"""<para align=right><b>{moy_ue.get("value", "-")
if moy_ue is not None else "-"
}</b></para>"""
),
"moyenne": Paragraph(f"""<para align=right><b>{moy_ue}</b></para>"""),
"_css_row_class": "note_bold",
"_pdf_row_markup": ["b"],
"_pdf_style": [
@ -196,25 +220,40 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
# case Bonus/Malus/Rang "bmr"
fields_bmr = []
try:
value = float(ue["bonus"])
value = float(ue.get("bonus", 0.0))
if value != 0:
fields_bmr.append(f"Bonus: {ue['bonus']}")
except ValueError:
pass
try:
value = float(ue["malus"])
value = float(ue.get("malus", 0.0))
if value != 0:
fields_bmr.append(f"Malus: {ue['malus']}")
except ValueError:
pass
if self.preferences["bul_show_ue_rangs"]:
fields_bmr.append(
f"Rang: {ue['moyenne']['rang']} / {ue['moyenne']['total']}"
moy_ue = ue.get("moyenne", "-")
if isinstance(moy_ue, dict): # UE non capitalisées
if self.preferences["bul_show_ue_rangs"]:
fields_bmr.append(
f"Rang: {ue['moyenne']['rang']} / {ue['moyenne']['total']}"
)
ue_min, ue_max, ue_moy = (
ue["moyenne"]["min"],
ue["moyenne"]["max"],
ue["moyenne"]["moy"],
)
else: # UE capitalisée
ue_min, ue_max, ue_moy = "", "", moy_ue
date_capitalisation = ue.get("date_capitalisation")
if date_capitalisation:
fields_bmr.append(
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
)
t = {
"titre": " - ".join(fields_bmr),
"coef": ects_txt,
"_coef_pdf": Paragraph(f"""<para align=left>{ects_txt}</para>"""),
"_coef_pdf": Paragraph(f"""<para align=right>{ects_txt}</para>"""),
"_coef_colspan": 2,
"_pdf_style": [
("BACKGROUND", (0, 0), (-1, 0), title_bg),
@ -222,9 +261,9 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
# ligne au dessus du bonus/malus, gris clair
("LINEABOVE", (0, 0), (-1, 0), self.PDF_LINEWIDTH, (0.7, 0.7, 0.7)),
],
"min": ue["moyenne"]["min"],
"max": ue["moyenne"]["max"],
"moy": ue["moyenne"]["moy"],
"min": ue_min,
"max": ue_max,
"moy": ue_moy,
}
rows.append(t)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -43,13 +43,13 @@ from app.but import bulletin_but
from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc import sco_abs
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud
from app.scodoc import sco_photos
from app.scodoc import sco_preferences
from app.scodoc import sco_xml
from app.scodoc.sco_xml import quote_xml_attr
def bulletin_but_xml_compat(
@ -65,11 +65,10 @@ def bulletin_but_xml_compat(
from app.scodoc import sco_bulletins
log(
"bulletin_but_xml_compat( formsemestre_id=%s, etudid=%s )"
% (formsemestre_id, etudid)
f"bulletin_but_xml_compat( formsemestre_id={formsemestre_id}, etudid={etudid} )"
)
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
etud: Identite = Identite.query.get_or_404(etudid)
etud = Identite.get_etud(etudid)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
results = bulletin_but.ResultatsSemestreBUT(formsemestre)
nb_inscrits = results.get_inscriptions_counts()[scu.INSCRIT]
# etat_inscription = etud.inscription_etat(formsemestre.id)
@ -108,13 +107,13 @@ def bulletin_but_xml_compat(
etudid=str(etudid),
code_nip=etud.code_nip or "",
code_ine=etud.code_ine or "",
nom=scu.quote_xml_attr(etud.nom),
prenom=scu.quote_xml_attr(etud.prenom),
civilite=scu.quote_xml_attr(etud.civilite_str),
sexe=scu.quote_xml_attr(etud.civilite_str), # compat
photo_url=scu.quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)),
email=scu.quote_xml_attr(etud.get_first_email() or ""),
emailperso=scu.quote_xml_attr(etud.get_first_email("emailperso") or ""),
nom=quote_xml_attr(etud.nom),
prenom=quote_xml_attr(etud.prenom),
civilite=quote_xml_attr(etud.civilite_str),
sexe=quote_xml_attr(etud.civilite_str), # compat
photo_url=quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)),
email=quote_xml_attr(etud.get_first_email() or ""),
emailperso=quote_xml_attr(etud.get_first_email("emailperso") or ""),
)
)
# Disponible pour publication ?
@ -153,13 +152,13 @@ def bulletin_but_xml_compat(
x_ue = Element(
"ue",
id=str(ue.id),
numero=scu.quote_xml_attr(ue.numero),
acronyme=scu.quote_xml_attr(ue.acronyme or ""),
titre=scu.quote_xml_attr(ue.titre or ""),
code_apogee=scu.quote_xml_attr(ue.code_apogee or ""),
numero=quote_xml_attr(ue.numero),
acronyme=quote_xml_attr(ue.acronyme or ""),
titre=quote_xml_attr(ue.titre or ""),
code_apogee=quote_xml_attr(ue.code_apogee or ""),
)
doc.append(x_ue)
if ue.type != sco_codes_parcours.UE_SPORT:
if ue.type != codes_cursus.UE_SPORT:
v = results.etud_moy_ue[ue.id][etud.id]
vmin = results.etud_moy_ue[ue.id].min()
vmax = results.etud_moy_ue[ue.id].max()
@ -192,11 +191,9 @@ def bulletin_but_xml_compat(
code=str(modimpl.module.code or ""),
coefficient=str(coef),
numero=str(modimpl.module.numero or 0),
titre=scu.quote_xml_attr(modimpl.module.titre or ""),
abbrev=scu.quote_xml_attr(modimpl.module.abbrev or ""),
code_apogee=scu.quote_xml_attr(
modimpl.module.code_apogee or ""
),
titre=quote_xml_attr(modimpl.module.titre or ""),
abbrev=quote_xml_attr(modimpl.module.abbrev or ""),
code_apogee=quote_xml_attr(modimpl.module.code_apogee or ""),
)
# XXX TODO rangs et effectifs
# --- notes de chaque eval:
@ -215,7 +212,7 @@ def bulletin_but_xml_compat(
coefficient=str(e.coefficient),
# pas les poids en XML compat
evaluation_type=str(e.evaluation_type),
description=scu.quote_xml_attr(e.description),
description=quote_xml_attr(e.description),
# notes envoyées sur 20, ceci juste pour garder trace:
note_max_origin=str(e.note_max),
)
@ -255,14 +252,14 @@ def bulletin_but_xml_compat(
):
infos, dpv = sco_bulletins.etud_descr_situation_semestre(
etudid,
formsemestre_id,
formsemestre,
format="xml",
show_uevalid=sco_preferences.get_preference(
"bul_show_uevalid", formsemestre_id
),
)
x_situation = Element("situation")
x_situation.text = scu.quote_xml_attr(infos["situation"])
x_situation.text = quote_xml_attr(infos["situation"])
doc.append(x_situation)
if dpv:
decision = dpv["decisions"][0]
@ -297,9 +294,9 @@ def bulletin_but_xml_compat(
Element(
"decision_ue",
ue_id=str(ue["ue_id"]),
numero=scu.quote_xml_attr(ue["numero"]),
acronyme=scu.quote_xml_attr(ue["acronyme"]),
titre=scu.quote_xml_attr(ue["titre"]),
numero=quote_xml_attr(ue["numero"]),
acronyme=quote_xml_attr(ue["acronyme"]),
titre=quote_xml_attr(ue["titre"]),
code=decision["decisions_ue"][ue_id]["code"],
)
)
@ -322,7 +319,7 @@ def bulletin_but_xml_compat(
"appreciation",
date=ndb.DateDMYtoISO(appr["date"]),
)
x_appr.text = scu.quote_xml_attr(appr["comment"])
x_appr.text = quote_xml_attr(appr["comment"])
doc.append(x_appr)
if is_appending:

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -13,7 +13,7 @@ Classe raccordant avec ScoDoc 7:
avec la même interface.
"""
import collections
from typing import Union
from flask import g, url_for
@ -44,15 +44,17 @@ from app.models.formations import Formation
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import sco_codes_parcours as sco_codes
from app.scodoc.sco_codes_parcours import RED, UE_STANDARD
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import RED, UE_STANDARD
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException, ScoValueError
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc import sco_cursus_dut
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
"""Pour compat ScoDoc 7: à revoir pour le BUT"""
def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT):
super().__init__(etud, formsemestre_id, res)
# Ajustements pour le BUT
@ -65,3 +67,140 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
def parcours_validated(self):
"True si le parcours est validé"
return False # XXX TODO
class EtudCursusBUT:
"""L'état de l'étudiant dans son cursus BUT
Liste des niveaux validés/à valider
"""
def __init__(self, etud: Identite, formation: Formation):
"""formation indique la spécialité préparée"""
# Vérifie que l'étudiant est bien inscrit à un sem. de cette formation
if formation.id not in (
ins.formsemestre.formation.id for ins in etud.formsemestre_inscriptions
):
raise ScoValueError(
f"{etud.nomprenom} non inscrit dans {formation.titre} v{formation.version}"
)
if not formation.referentiel_competence:
raise ScoNoReferentielCompetences(formation=formation)
#
self.etud = etud
self.formation = formation
self.inscriptions = sorted(
[
ins
for ins in etud.formsemestre_inscriptions
if ins.formsemestre.formation.referentiel_competence
and (
ins.formsemestre.formation.referentiel_competence.id
== formation.referentiel_competence.id
)
],
key=lambda s: (s.formsemestre.semestre_id, s.formsemestre.date_debut),
)
"Liste des inscriptions aux sem. de la formation, triées par indice et chronologie"
self.parcour: ApcParcours = self.inscriptions[-1].parcour
"Le parcours à valider: celui du DERNIER semestre suivi (peut être None)"
self.niveaux_by_annee = {}
"{ annee : liste des niveaux à valider }"
self.niveaux: dict[int, ApcNiveau] = {}
"cache les niveaux"
for annee in (1, 2, 3):
niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours(
annee, self.parcour
)[1]
# groupe les niveaux de tronc commun et ceux spécifiques au parcour
self.niveaux_by_annee[annee] = niveaux_d["TC"] + (
niveaux_d[self.parcour.id] if self.parcour else []
)
self.niveaux.update(
{niveau.id: niveau for niveau in self.niveaux_by_annee[annee]}
)
# Probablement inutile:
# # Cherche les validations de jury enregistrées pour chaque niveau
# self.validations_by_niveau = collections.defaultdict(lambda: [])
# " { niveau_id : [ ApcValidationRCUE ] }"
# for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
# self.validations_by_niveau[validation_rcue.niveau().id].append(
# validation_rcue
# )
# self.validation_by_niveau = {
# niveau_id: sorted(
# validations, key=lambda v: sco_codes.BUT_CODES_ORDERED[v.code]
# )[0]
# for niveau_id, validations in self.validations_by_niveau.items()
# }
# "{ niveau_id : meilleure validation pour ce niveau }"
self.validation_par_competence_et_annee = {}
"""{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }"""
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id
).get(validation_rcue.annee())
# prend la "meilleure" validation
if (not previous_validation) or (
sco_codes.BUT_CODES_ORDERED[validation_rcue.code]
> sco_codes.BUT_CODES_ORDERED[previous_validation["code"]]
):
self.validation_par_competence_et_annee[niveau.competence.id][
niveau.annee
] = validation_rcue
self.competences = {
competence.id: competence
for competence in (
self.parcour.query_competences()
if self.parcour
else self.formation.referentiel_competence.get_competences_tronc_commun()
)
}
"cache { competence_id : competence }"
def to_dict(self):
"""
{
competence_id : {
annee : meilleure_validation
}
}
"""
return {
competence.id: {
annee: self.validation_par_competence_et_annee.get(
competence.id, {}
).get(annee)
for annee in ("BUT1", "BUT2", "BUT3")
}
for competence in self.competences.values()
}
# XXX TODO OPTIMISATION ACCESS TABLE JURY
def to_dict_codes(self) -> dict[int, dict[str, int]]:
"""
{
competence_id : {
annee : { validation}
}
}
validation est un petit dict avec niveau_id, etc.
"""
d = {}
for competence in self.competences.values():
d[competence.id] = {}
for annee in ("BUT1", "BUT2", "BUT3"):
validation_rcue: ApcValidationRCUE = (
self.validation_par_competence_et_annee.get(competence.id, {}).get(
annee
)
)
d[competence.id][annee] = (
validation_rcue.to_dict_codes() if validation_rcue else None
)
return d

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -8,7 +8,7 @@
"""
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired
from flask_wtf.file import FileField, FileAllowed
from wtforms import SelectField, SubmitField

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
from xml.etree import ElementTree

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -26,78 +26,34 @@ def _descr_cursus_but(etud: Identite) -> str:
# prend simplement tous les semestre de type APC, ce qui sera faux si
# l'étudiant change de spécialité au sein du même département
# (ce qui ne peut normalement pas se produire)
indices = sorted(
inscriptions = sorted(
[
ins.formsemestre.semestre_id
if ins.formsemestre.semestre_id is not None
else -1
ins
for ins in etud.formsemestre_inscriptions
if ins.formsemestre.formation.is_apc()
]
],
key=lambda i: i.formsemestre.date_debut,
)
indices = [
ins.formsemestre.semestre_id if ins.formsemestre.semestre_id is not None else -1
for ins in inscriptions
]
return ", ".join(f"S{indice}" for indice in indices)
def pvjury_table_but(formsemestre_id: int, format="html"):
def pvjury_page_but(formsemestre_id: int, fmt="html"):
"""Page récapitulant les décisions de jury BUT
formsemestre peut être pair ou impair
"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
assert formsemestre.formation.is_apc()
title = "Procès-verbal de jury BUT annuel"
if format == "html":
line_sep = "<br/>"
title = "Procès-verbal de jury BUT"
if fmt == "html":
line_sep = "<br>"
else:
line_sep = "\n"
# remplace pour le BUT la fonction sco_pvjury.pvjury_table
annee_but = (formsemestre.semestre_id + 1) // 2
titles = {
"nom": "Nom",
"cursus": "Cursus",
"ues": "UE validées",
"niveaux": "Niveaux de compétences validés",
"decision_but": f"Décision BUT{annee_but}",
"diplome": "Résultat au diplôme",
"devenir": "Devenir",
"observations": "Observations",
}
rows = []
for etudid in formsemestre.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid)
try:
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if deca.annee_but != annee_but: # wtf ?
log(
f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}"
)
continue
except ScoValueError:
deca = None
row = {
"nom": etud.etat_civil_pv(line_sep=line_sep),
"_nom_order": etud.sort_key,
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_target": url_for(
"scolar.ficheEtud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
),
"cursus": _descr_cursus_but(etud),
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
if deca
else "-",
"decision_but": deca.code_valide if deca else "",
"devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else "",
}
rows.append(row)
rows.sort(key=lambda x: x["_nom_order"])
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep)
# Style excel... passages à la ligne sur \n
xls_style_base = sco_excel.excel_make_style()
@ -109,10 +65,11 @@ def pvjury_table_but(formsemestre_id: int, format="html"):
columns_ids=titles.keys(),
html_caption=title,
html_class="pvjury_table_but table_leftalign",
html_title=f"""<div style="margin-bottom: 8px;"><span style="font-size: 120%; font-weight: bold;">{title}</span>
html_title=f"""<div style="margin-bottom: 8px;"><span
style="font-size: 120%; font-weight: bold;">{title}</span>
<span style="padding-left: 20px;">
<a href="{url_for("notes.pvjury_table_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, format="xlsx")}"
<a href="{url_for("notes.pvjury_page_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, fmt="xlsx")}"
class="stdlink">version excel</a></span></div>
""",
@ -136,4 +93,78 @@ def pvjury_table_but(formsemestre_id: int, format="html"):
},
xls_style_base=xls_style_base,
)
return tab.make_page(format=format, javascripts=["js/etud_info.js"], init_qtip=True)
return tab.make_page(format=fmt, javascripts=["js/etud_info.js"], init_qtip=True)
def pvjury_table_but(
formsemestre: FormSemestre,
etudids: list[int] = None,
line_sep: str = "\n",
only_diplome=False,
anonymous=False,
with_paragraph_nom=False,
) -> tuple[list[dict], dict]:
"""Table avec résultats jury BUT pour PV.
Si etudids est None, prend tous les étudiants inscrits.
"""
# remplace pour le BUT la fonction sco_pv_forms.pvjury_table
annee_but = (formsemestre.semestre_id + 1) // 2
titles = {
"nom": "Code" if anonymous else "Nom",
"cursus": "Cursus",
"ects": "ECTS",
"ues": "UE validées",
"niveaux": "Niveaux de compétences validés",
"decision_but": f"Décision BUT{annee_but}",
"diplome": "Résultat au diplôme",
"devenir": "Devenir",
"observations": "Observations",
}
rows = []
formsemestre_etudids = formsemestre.etuds_inscriptions.keys()
if etudids is None:
etudids = formsemestre_etudids
for etudid in etudids:
if not etudid in formsemestre_etudids:
continue # garde fou
etud = Identite.get_etud(etudid)
try:
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if deca.annee_but != annee_but: # wtf ?
log(
f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}"
)
continue
except ScoValueError:
deca = None
row = {
"nom": etud.code_ine or etud.code_nip or etud.id
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
else etud.etat_civil_pv(
line_sep=line_sep, with_paragraph=with_paragraph_nom
),
"_nom_order": etud.sort_key,
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_target": url_for(
"scolar.ficheEtud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
),
"cursus": _descr_cursus_but(etud),
"ects": f"{deca.formsemestre_ects():g}",
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
if deca
else "-",
"decision_but": deca.code_valide if deca else "",
"devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else "",
}
if deca.valide_diplome() or not only_diplome:
rows.append(row)
rows.sort(key=lambda x: x["_nom_order"])
return rows, titles

View File

@ -1,525 +0,0 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT: table recap annuelle et liens saisie
"""
import time
import numpy as np
from flask import g, url_for
from app.but import jury_but
from app.but.jury_but import (
DecisionsProposeesAnnee,
DecisionsProposeesRCUE,
DecisionsProposeesUE,
)
from app.comp.res_but import ResultatsSemestreBUT
from app.comp import res_sem
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc import html_sco_header
from app.scodoc.sco_codes_parcours import (
BUT_BARRE_RCUE,
BUT_BARRE_UE,
BUT_BARRE_UE8,
BUT_RCUE_SUFFISANT,
)
from app.scodoc import sco_formsemestre_status
from app.scodoc import sco_pvjury
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_saisie_jury_but(
formsemestre2: FormSemestre,
read_only: bool = False,
selected_etudid: int = None,
mode="jury",
) -> str:
"""formsemestre est un semestre PAIR
Si readonly, ne montre pas le lien "saisir la décision"
=> page html complète
Si mode == "recap", table recap des codes, sans liens de saisie.
"""
# Quick & Dirty
# pour chaque etud de res2 trié
# S1: UE1, ..., UEn
# S2: UE1, ..., UEn
#
# UE1_s1, UE1_s2, moy_rcue, UE2... , Nbrcue_validables, Nbrcue<8, passage_de_droit, valide_moitie_rcue
#
# Pour chaque etud de res2 trié
# DecisionsProposeesAnnee(etud, formsemestre2)
# Pour le 1er etud, faire un check_ues_ready_jury(self) -> page d'erreur
# -> rcue .ue_1, .ue_2 -> stroe moy ues, rcue.moy_rcue, etc
if formsemestre2.semestre_id % 2 != 0:
raise ScoValueError("Cette page ne fonctionne que sur les semestres pairs")
if formsemestre2.formation.referentiel_competence is None:
raise ScoValueError(
"""
<p>Pas de référentiel de compétences associé à la formation !</p>
<p>Pour associer un référentiel, passer par le menu <b>Semestre /
Voir la formation... </b> et suivre le lien <em>"associer à un référentiel
de compétences"</em>
"""
)
rows, titles, column_ids = get_jury_but_table(
formsemestre2, read_only=read_only, mode=mode
)
if not rows:
return (
'<div class="table_recap"><div class="message">aucun étudiant !</div></div>'
)
filename = scu.sanitize_filename(
f"""jury-but-{formsemestre2.titre_num()}-{time.strftime("%Y-%m-%d")}"""
)
klass = "table_jury_but_bilan" if mode == "recap" else ""
table_html = build_table_jury_but_html(
filename, rows, titles, column_ids, selected_etudid=selected_etudid, klass=klass
)
H = [
html_sco_header.sco_header(
page_title=f"{formsemestre2.sem_modalite()}: jury BUT annuel",
no_side_bar=True,
init_qtip=True,
javascripts=["js/etud_info.js", "js/table_recap.js"],
),
sco_formsemestre_status.formsemestre_status_head(
formsemestre_id=formsemestre2.id
),
]
if mode == "recap":
H.append(
f"""<h3>Décisions de jury enregistrées pour les étudiants de ce semestre</h3>
<div class="table_jury_but_links">
<div>
<ul>
<li><a href="{url_for(
"notes.pvjury_table_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}" class="stdlink">Tableau PV de jury</a>
</li>
<li><a href="{url_for(
"notes.formsemestre_lettres_individuelles",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}" class="stdlink">Courriers individuels (classeur pdf)</a>
</li>
</div>
</div>
"""
)
H.append(
f"""
{table_html}
<div class="table_jury_but_links">
"""
)
if (mode == "recap") and not read_only:
H.append(
f"""
<p><a class="stdlink" href="{url_for(
"notes.formsemestre_saisie_jury",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}">Saisie des décisions du jury</a>
</p>"""
)
else:
H.append(
f"""
<p><a class="stdlink" href="{url_for(
"notes.formsemestre_validation_auto_but",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}">Calcul automatique des décisions du jury</a>
</p>
<p><a class="stdlink" href="{url_for(
"notes.formsemestre_jury_but_recap",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre2.id)
}">Tableau récapitulatif des décisions du jury</a>
</p>
"""
)
H.append(
f"""
</div>
{html_sco_header.sco_footer()}
"""
)
return "\n".join(H)
def build_table_jury_but_html(
filename: str, rows, titles, column_ids, selected_etudid: int = None, klass=""
) -> str:
"""assemble la table html"""
footer_rows = [] # inutilisé pour l'instant
H = [
f"""<div class="table_recap"><table class="table_recap apc jury table_jury_but {klass}"
data-filename="{filename}">"""
]
# header
H.append(
f"""
<thead>
{scu.gen_row(column_ids, titles, "th")}
</thead>
"""
)
# body
H.append("<tbody>")
for row in rows:
H.append(f"{scu.gen_row(column_ids, row, selected_etudid=selected_etudid)}\n")
H.append("</tbody>\n")
# footer
H.append("<tfoot>")
idx_last = len(footer_rows) - 1
for i, row in enumerate(footer_rows):
H.append(f'{scu.gen_row(column_ids, row, "th" if i == idx_last else "td")}\n')
H.append(
"""
</tfoot>
</table>
</div>
"""
)
return "".join(H)
class RowCollector:
"""Une ligne de la table"""
def __init__(
self,
cells: dict = None,
titles: dict = None,
convert_values=True,
column_classes: dict = None,
):
self.titles = titles
self.row = cells or {} # col_id : str
self.column_classes = column_classes # col_id : str, css class
self.idx = 0
self.last_etud_cell_idx = 0
if convert_values:
self.fmt_note = scu.fmt_note
else:
self.fmt_note = lambda x: x
def __setitem__(self, key, value):
self.row[key] = value
def __getitem__(self, key):
return self.row[key]
def get_row_dict(self):
"La ligne, comme un dict"
# create empty cells
for col_id in self.titles:
if col_id not in self.row:
self.row[col_id] = ""
klass = self.column_classes.get(col_id)
if klass:
self.row[f"_{col_id}_class"] = klass
return self.row
def add_cell(
self,
col_id: str,
title: str,
content: str,
classes: str = "",
idx: int = None,
column_class="",
):
"""Add a row to our table. classes is a list of css class names"""
self.idx = idx if idx is not None else self.idx
self.row[col_id] = content
if classes:
self.row[f"_{col_id}_class"] = classes + f" c{self.idx}"
if not col_id in self.titles:
self.titles[col_id] = title
self.titles[f"_{col_id}_col_order"] = self.idx
if classes:
self.titles[f"_{col_id}_class"] = classes
self.column_classes[col_id] = column_class
self.idx += 1
def add_etud_cells(
self, etud: Identite, formsemestre: FormSemestre, with_links=True
):
"Les cells code, nom, prénom etc."
# --- Codes (seront cachés, mais exportés en excel)
self.add_cell("etudid", "etudid", etud.id, "codes")
self.add_cell("code_nip", "code_nip", etud.code_nip or "", "codes")
# --- Identité étudiant (adapté de res_comon/get_table_recap, à factoriser XXX TODO)
self.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail")
self.add_cell("nom_disp", "Nom", etud.nom_disp(), "identite_detail")
self["_nom_disp_order"] = etud.sort_key
self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court")
if with_links:
self["_nom_short_order"] = etud.sort_key
self["_nom_short_target"] = url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
etudid=etud.id,
)
self["_nom_short_target_attrs"] = f'class="etudinfo" id="{etud.id}"'
self["_nom_disp_target"] = self["_nom_short_target"]
self["_nom_disp_target_attrs"] = self["_nom_short_target_attrs"]
self.last_etud_cell_idx = self.idx
def add_ue_cells(self, dec_ue: DecisionsProposeesUE):
"cell de moyenne d'UE"
col_id = f"moy_ue_{dec_ue.ue.id}"
note_class = ""
val = dec_ue.moy_ue
if isinstance(val, float):
if val < BUT_BARRE_UE:
note_class = " moy_inf"
elif val >= BUT_BARRE_UE:
note_class = " moy_ue_valid"
if val < BUT_BARRE_UE8:
note_class = " moy_ue_warning" # notes très basses
self.add_cell(
col_id,
dec_ue.ue.acronyme,
self.fmt_note(val),
"col_ue" + note_class,
column_class="col_ue",
)
self.add_cell(
col_id + "_code",
dec_ue.ue.acronyme,
dec_ue.code_valide or "",
"col_ue_code recorded_code",
column_class="col_ue",
)
def add_rcue_cells(self, dec_rcue: DecisionsProposeesRCUE):
"2 cells: moyenne du RCUE, code enregistré"
rcue = dec_rcue.rcue
col_id = f"moy_rcue_{rcue.ue_1.niveau_competence_id}" # le niveau_id
note_class = ""
val = rcue.moy_rcue
if isinstance(val, float):
if val < BUT_BARRE_RCUE:
note_class = " moy_ue_inf"
elif val >= BUT_BARRE_RCUE:
note_class = " moy_ue_valid"
if val < BUT_RCUE_SUFFISANT:
note_class = " moy_ue_warning" # notes très basses
self.add_cell(
col_id,
f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
self.fmt_note(val),
"col_rcue" + note_class,
column_class="col_rcue",
)
self.add_cell(
col_id + "_code",
f"<div>{rcue.ue_1.acronyme}</div><div>{rcue.ue_2.acronyme}</div>",
dec_rcue.code_valide or "",
"col_rcue_code recorded_code",
column_class="col_rcue",
)
def add_nb_rcues_cell(self, deca: DecisionsProposeesAnnee):
"cell avec nb niveaux validables / total"
klass = " "
if deca.nb_rcues_under_8 > 0:
klass += "moy_ue_warning"
elif deca.nb_validables < deca.nb_competences:
klass += "moy_ue_inf"
else:
klass += "moy_ue_valid"
self.add_cell(
"rcues_validables",
"RCUEs",
f"""{deca.nb_validables}/{deca.nb_competences}"""
+ ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""),
"col_rcue col_rcues_validables" + klass,
)
self["_rcues_validables_data"] = {
"etudid": deca.etud.id,
"nomprenom": deca.etud.nomprenom,
}
if len(deca.rcues_annee) > 0:
# permet un tri par nb de niveaux validables + moyenne gen indicative S_pair
if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen:
moy = deca.res_pair.etud_moy_gen[deca.etud.id]
if np.isnan(moy):
moy_gen_d = "x"
else:
moy_gen_d = f"{int(moy*1000):05}"
else:
moy_gen_d = "x"
self["_rcues_validables_order"] = f"{deca.nb_validables:04d}-{moy_gen_d}"
else:
# etudiants sans RCUE: pas de semestre impair, ...
# les classe à la fin
self[
"_rcues_validables_order"
] = f"{deca.nb_validables:04d}-00000-{deca.etud.sort_key}"
def get_jury_but_table(
formsemestre2: FormSemestre, read_only: bool = False, mode="jury", with_links=True
) -> tuple[list[dict], list[str], list[str]]:
"""Construit la table des résultats annuels pour le jury BUT"""
res2: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre2)
titles = {} # column_id : title
column_classes = {}
rows = []
for etudid in formsemestre2.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre2)
row = RowCollector(titles=titles, column_classes=column_classes)
row.add_etud_cells(etud, formsemestre2, with_links=with_links)
row.idx = 100 # laisse place pour les colonnes de groupes
# --- Nombre de niveaux
row.add_nb_rcues_cell(deca)
# --- Les RCUEs
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
row.add_ue_cells(deca.decisions_ues[rcue.ue_1.id])
row.add_ue_cells(deca.decisions_ues[rcue.ue_2.id])
row.add_rcue_cells(dec_rcue)
# --- Les ECTS validés
ects_valides = 0.0
if deca.res_impair:
ects_valides += deca.res_impair.get_etud_ects_valides(etudid)
if deca.res_pair:
ects_valides += deca.res_pair.get_etud_ects_valides(etudid)
row.add_cell(
"ects_annee",
"ECTS",
f"""{int(ects_valides)}""",
"col_code_annee",
)
# --- Le code annuel existant
row.add_cell(
"code_annee",
"Année",
f"""{deca.code_valide or ''}""",
"col_code_annee",
)
# --- Le lien de saisie
if mode != "recap" and with_links:
row.add_cell(
"lien_saisie",
"",
f"""
<a href="{url_for(
'notes.formsemestre_validation_but',
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
formsemestre_id=formsemestre2.id,
)}" class="stdlink">
{"voir" if read_only else ("modif." if deca.code_valide else "saisie")}
décision</a>
"""
if deca.inscription_etat == scu.INSCRIT
else deca.inscription_etat,
"col_lien_saisie_but",
)
rows.append(row)
rows_dict = [row.get_row_dict() for row in rows]
if len(rows_dict) > 0:
res2.recap_add_partitions(rows_dict, titles, col_idx=row.last_etud_cell_idx + 1)
column_ids = [title for title in titles if not title.startswith("_")]
column_ids.sort(key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000))
rows_dict.sort(key=lambda row: row["_nom_disp_order"])
return rows_dict, titles, column_ids
def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
"""Liste des résultats jury BUT sous forme de dict, pour API"""
if formsemestre.formation.referentiel_competence is None:
# pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
return []
dpv = sco_pvjury.dict_pvjury(formsemestre.id)
rows = []
for etudid in formsemestre.etuds_inscriptions:
rows.append(get_jury_but_etud_result(formsemestre, dpv, etudid))
return rows
def get_jury_but_etud_result(
formsemestre: FormSemestre, dpv: dict, etudid: int
) -> dict:
"""Résultats de jury d'un étudiant sur un semestre pair de BUT"""
etud: Identite = Identite.query.get(etudid)
dec_etud = dpv["decisions_dict"][etudid]
if formsemestre.formation.is_apc():
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
else:
deca = None
row = {
"etudid": etud.id,
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
"is_apc": dpv["is_apc"], # BUT ou classic ?
"etat": dec_etud["etat"], # I ou D ou DEF
"nb_competences": deca.nb_competences if deca else 0,
}
# --- Les RCUEs
rcue_list = []
if deca:
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
rcue_dict = {
"ue_1": {
"ue_id": rcue.ue_1.id,
"moy": None
if (dec_ue1.moy_ue is None or np.isnan(dec_ue1.moy_ue))
else dec_ue1.moy_ue,
"code": dec_ue1.code_valide,
},
"ue_2": {
"ue_id": rcue.ue_2.id,
"moy": None
if (dec_ue2.moy_ue is None or np.isnan(dec_ue2.moy_ue))
else dec_ue2.moy_ue,
"code": dec_ue2.code_valide,
},
"moy": rcue.moy_rcue,
"code": dec_rcue.code_valide,
}
rcue_list.append(rcue_dict)
row["rcues"] = rcue_list
# --- Les UEs
ue_list = []
if dec_etud["decisions_ue"]:
for ue_id, ue_dec in dec_etud["decisions_ue"].items():
ue_dict = {
"ue_id": ue_id,
"code": ue_dec["code"],
"ects": ue_dec["ects"],
}
ue_list.append(ue_dict)
row["ues"] = ue_list
# --- Le semestre (pour les formations classiques)
if dec_etud["decision_sem"]:
row["semestre"] = {"code": dec_etud["decision_sem"].get("code")}
else:
row["semestre"] = {} # APC, ...
# --- Autorisations
row["autorisations"] = dec_etud["autorisations"]
return row

View File

@ -0,0 +1,94 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT et classiques: récupération des résults pour API
"""
import numpy as np
from app.but import jury_but
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_pv_dict
def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:
"""Liste des résultats jury BUT sous forme de dict, pour API"""
if formsemestre.formation.referentiel_competence is None:
# pas de ref. comp., donc pas de decisions de jury (ne lance pas d'exception)
return []
dpv = sco_pv_dict.dict_pvjury(formsemestre.id)
rows = []
for etudid in formsemestre.etuds_inscriptions:
rows.append(_get_jury_but_etud_result(formsemestre, dpv, etudid))
return rows
def _get_jury_but_etud_result(
formsemestre: FormSemestre, dpv: dict, etudid: int
) -> dict:
"""Résultats de jury d'un étudiant sur un semestre pair de BUT"""
etud = Identite.get_etud(etudid)
dec_etud = dpv["decisions_dict"][etudid]
if formsemestre.formation.is_apc():
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
else:
deca = None
row = {
"etudid": etud.id,
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
"is_apc": dpv["is_apc"], # BUT ou classic ?
"etat": dec_etud["etat"], # I ou D ou DEF
"nb_competences": deca.nb_competences if deca else 0,
}
# --- Les RCUEs
rcue_list = []
if deca:
for rcue in deca.rcues_annee:
dec_rcue = deca.dec_rcue_by_ue.get(rcue.ue_1.id)
if dec_rcue is not None: # None si l'UE n'est pas associée à un niveau
dec_ue1 = deca.decisions_ues[rcue.ue_1.id]
dec_ue2 = deca.decisions_ues[rcue.ue_2.id]
rcue_dict = {
"ue_1": {
"ue_id": rcue.ue_1.id,
"moy": None
if (dec_ue1.moy_ue is None or np.isnan(dec_ue1.moy_ue))
else dec_ue1.moy_ue,
"code": dec_ue1.code_valide,
},
"ue_2": {
"ue_id": rcue.ue_2.id,
"moy": None
if (dec_ue2.moy_ue is None or np.isnan(dec_ue2.moy_ue))
else dec_ue2.moy_ue,
"code": dec_ue2.code_valide,
},
"moy": rcue.moy_rcue,
"code": dec_rcue.code_valide,
}
rcue_list.append(rcue_dict)
row["rcues"] = rcue_list
# --- Les UEs
ue_list = []
if dec_etud["decisions_ue"]:
for ue_id, ue_dec in dec_etud["decisions_ue"].items():
ue_dict = {
"ue_id": ue_id,
"code": ue_dec["code"],
"ects": ue_dec["ects"],
}
ue_list.append(ue_dict)
row["ues"] = ue_list
# --- Le semestre (pour les formations classiques)
if dec_etud["decision_sem"]:
row["semestre"] = {"code": dec_etud["decision_sem"].get("code")}
else:
row["semestre"] = {} # APC, ...
# --- Autorisations
row["autorisations"] = dec_etud["autorisations"]
return row

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -15,20 +15,32 @@ from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but(formsemestre: FormSemestre) -> int:
"""Calcul automatique des décisions de jury sur une année BUT.
Returns: nombre d'étudiants "admis"
def formsemestre_validation_auto_but(
formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True
) -> int:
"""Calcul automatique des décisions de jury sur une "année" BUT.
- N'enregistre jamais de décisions de l'année scolaire précédente, même
si on a des RCUE "à cheval".
- Ne modifie jamais de décisions déjà enregistrées (sauf si no_overwrite est faux,
ce qui est utilisé pour certains tests unitaires).
- Normalement, only_adm est True et on n'enregistre que les décisions validantes
de droit: ADM ou CMP.
En revanche, si only_adm est faux, on enregistre la première décision proposée par ScoDoc
(mode à n'utiliser que pour les tests unitaires vérifiant la saisie des jurys)
Returns: nombre d'étudiants pour lesquels on a enregistré au moins un code.
"""
if not formsemestre.formation.is_apc():
raise ScoValueError("fonction réservée aux formations BUT")
nb_admis = 0
nb_etud_modif = 0
with sco_cache.DeferredSemCacheManager():
for etudid in formsemestre.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid)
etud = Identite.get_etud(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if deca.admis: # année réussie
deca.record_all()
nb_admis += 1
nb_etud_modif += deca.record_all(
no_overwrite=no_overwrite, only_validantes=only_adm
)
db.session.commit()
return nb_admis
return nb_etud_modif

View File

@ -1,18 +1,42 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT: affichage/formulaire
"""
from flask import g, url_for
from app.models.etudiants import Identite
from app.scodoc import sco_utils as scu
from app.but.jury_but import DecisionsProposeesAnnee, DecisionsProposeesUE
from app.models import FormSemestre, FormSemestreInscription, UniteEns
import re
import numpy as np
import flask
from flask import flash, render_template, url_for
from flask import g, request
from app import db
from app.but import jury_but
from app.but.jury_but import (
DecisionsProposeesAnnee,
DecisionsProposeesRCUE,
DecisionsProposeesUE,
)
from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
from app.models import (
ApcNiveau,
FormSemestre,
FormSemestreInscription,
Identite,
UniteEns,
ScolarAutorisationInscription,
ScolarFormSemestreValidation,
)
from app.models.config import ScoDocSiteConfig
from app.scodoc import html_sco_header
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
@ -20,35 +44,50 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
Si pas read_only, menus sélection codes jury.
"""
H = []
if deca.code_valide and not read_only:
erase_span = f"""<a href="{
url_for("notes.formsemestre_jury_but_erase",
scodoc_dept=g.scodoc_dept, formsemestre_id=deca.formsemestre_id,
etudid=deca.etud.id)}" class="stdlink">effacer décisions</a>"""
else:
erase_span = ""
H.append(
f"""<div class="but_section_annee">
<div>
<b>Décision de jury pour l'année :</b> {
_gen_but_select("code_annee", deca.codes, deca.code_valide,
disabled=True, klass="manual")
}
<span>({'non ' if deca.code_valide is None else ''}enregistrée)</span>
<span>{erase_span}</span>
if deca.jury_annuel:
H.append(
f"""
<div class="but_section_annee">
<div>
<b>Décision de jury pour l'année :</b> {
_gen_but_select("code_annee", deca.codes, deca.code_valide,
disabled=True, klass="manual")
}
<span>({deca.code_valide or 'non'} enregistrée)</span>
</div>
</div>
<div class="but_explanation">{deca.explanation}</div>
</div>"""
)
"""
)
formsemestre_1 = deca.formsemestre_impair
formsemestre_2 = deca.formsemestre_pair
# Ordonne selon les dates des 2 semestres considérés (pour les redoublants à cheval):
reverse_semestre = (
deca.formsemestre_pair
and deca.formsemestre_impair
and deca.formsemestre_pair.date_debut < deca.formsemestre_impair.date_debut
)
if reverse_semestre:
formsemestre_1, formsemestre_2 = formsemestre_2, formsemestre_1
H.append(
f"""
<div><b>Niveaux de compétences et unités d'enseignement :</b></div>
<div class="titre_niveaux">
<b>Niveaux de compétences et unités d'enseignement du BUT{deca.annee_but}</b>
</div>
<div class="but_explanation">{deca.explanation}</div>
<div class="but_annee">
<div class="titre"></div>
<div class="titre">S{1}</div>
<div class="titre">S{2}</div>
<div class="titre">{"S" +str(formsemestre_1.semestre_id)
if formsemestre_1 else "-"}
<span class="avertissement_redoublement">{formsemestre_1.annee_scolaire_str()
if formsemestre_1 else ""}</span>
</div>
<div class="titre">{"S"+str(formsemestre_2.semestre_id)
if formsemestre_2 else "-"}
<span class="avertissement_redoublement">{formsemestre_2.annee_scolaire_str()
if formsemestre_2 else ""}</span>
</div>
<div class="titre">RCUE</div>
"""
)
@ -58,42 +97,52 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
<div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div>
</div>"""
)
dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id)
if dec_rcue is None:
break
# Semestre impair
H.append(
_gen_but_niveau_ue(
dec_rcue.rcue.ue_1,
dec_rcue.rcue.moy_ue_1,
deca.decisions_ues[dec_rcue.rcue.ue_1.id],
disabled=read_only,
)
)
# Semestre pair
H.append(
_gen_but_niveau_ue(
dec_rcue.rcue.ue_2,
dec_rcue.rcue.moy_ue_2,
deca.decisions_ues[dec_rcue.rcue.ue_2.id],
disabled=read_only,
)
)
# RCUE
H.append(
f"""<div class="but_niveau_rcue
{'recorded' if dec_rcue.code_valide is not None else ''}
">
<div class="but_note">{scu.fmt_note(dec_rcue.rcue.moy_rcue)}</div>
<div class="but_code">{
_gen_but_select("code_rcue_"+str(niveau.id),
dec_rcue.codes,
dec_rcue.code_valide,
disabled=True, klass="manual"
dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) # peut être None
ues = [
ue
for ue in deca.ues_impair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_impair = ues[0] if ues else None
ues = [
ue
for ue in deca.ues_pair
if ue.niveau_competence and ue.niveau_competence.id == niveau.id
]
ue_pair = ues[0] if ues else None
# Les UEs à afficher,
# qui seront toujours en readonly sur le formsemestre de l'année précédente du redoublant
ues_ro = [
(
ue_impair,
(deca.a_cheval and deca.formsemestre_id != deca.formsemestre_impair.id),
),
(
ue_pair,
deca.a_cheval and deca.formsemestre_id != deca.formsemestre_pair.id,
),
]
# Ordonne selon les dates des 2 semestres considérés:
if reverse_semestre:
ues_ro[0], ues_ro[1] = ues_ro[1], ues_ro[0]
# Colonnes d'UE:
for ue, ue_read_only in ues_ro:
if ue:
H.append(
_gen_but_niveau_ue(
ue,
deca.decisions_ues[ue.id],
disabled=read_only or ue_read_only,
annee_prec=ue_read_only,
niveau_id=ue.niveau_competence.id,
)
)
}</div>
</div>"""
)
else:
H.append("""<div class="niveau_vide"></div>""")
# Colonne RCUE
H.append(_gen_but_rcue(dec_rcue, niveau))
H.append("</div>") # but_annee
return "\n".join(H)
@ -104,48 +153,373 @@ def _gen_but_select(
code_valide: str,
disabled: bool = False,
klass: str = "",
data: dict = {},
) -> str:
"Le menu html select avec les codes"
h = "\n".join(
# if disabled: # mauvaise idée car le disabled est traité en JS
# return f"""<div class="but_code {klass}">{code_valide}</div>"""
options_htm = "\n".join(
[
f"""<option value="{code}"
f"""<option value="{code}"
{'selected' if code == code_valide else ''}
class="{'recorded' if code == code_valide else ''}"
>{code}</option>"""
for code in codes
]
)
return f"""<select required name="{name}"
return f"""<select required name="{name}"
class="but_code {klass}"
data-orig_code="{code_valide or (codes[0] if codes else '')}"
data-orig_recorded="{code_valide or ''}"
onchange="change_menu_code(this);"
{"disabled" if disabled else ""}
>{h}</select>
{" ".join( f'data-{k}="{v}"' for (k,v) in data.items() )}
>{options_htm}</select>
"""
def _gen_but_niveau_ue(
ue: UniteEns, moy_ue: float, dec_ue: DecisionsProposeesUE, disabled=False
):
ue: UniteEns,
dec_ue: DecisionsProposeesUE,
disabled: bool = False,
annee_prec: bool = False,
niveau_id: int = None,
) -> str:
if dec_ue.ue_status and dec_ue.ue_status["is_capitalized"]:
moy_ue_str = f"""<span class="ue_cap">{
scu.fmt_note(dec_ue.moy_ue_with_cap)}</span>"""
scoplement = f"""<div class="scoplement">
<div>
<b>UE {ue.acronyme} capitalisée </b>
<span>le {dec_ue.ue_status["event_date"].strftime("%d/%m/%Y")}
</span>
</div>
<div>UE en cours
{ "sans notes" if np.isnan(dec_ue.moy_ue)
else
("avec moyenne <b>" + scu.fmt_note(dec_ue.moy_ue) + "</b>")
}
</div>
</div>
"""
else:
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
if dec_ue.code_valide:
scoplement = f"""<div class="scoplement">
<div>Code {dec_ue.code_valide} enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
</div>
</div>
"""
else:
scoplement = ""
return f"""<div class="but_niveau_ue {
'recorded' if dec_ue.code_valide is not None else ''}
{'annee_prec' if annee_prec else ''}
">
<div title="{ue.titre}">{ue.acronyme}</div>
<div class="but_note">{scu.fmt_note(moy_ue)}</div>
<div class="but_note with_scoplement">
<div>{moy_ue_str}</div>
{scoplement}
</div>
<div class="but_code">{
_gen_but_select("code_ue_"+str(ue.id),
dec_ue.codes,
dec_ue.code_valide, disabled=disabled
dec_ue.codes,
dec_ue.code_valide,
disabled=disabled,
klass=f"code_ue ue_rcue_{niveau_id}" if not disabled else ""
)
}</div>
</div>"""
#
def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
if dec_rcue is None:
return """
<div class="but_niveau_rcue niveau_vide with_scoplement">
<div></div>
<div class="scoplement">Pas de RCUE (UE non capitalisée ?)</div>
</div>
"""
scoplement = (
f"""<div class="scoplement">{
dec_rcue.validation.to_html()
}</div>"""
if dec_rcue.validation
else ""
)
# Déjà enregistré ?
niveau_rcue_class = ""
if dec_rcue.code_valide is not None and dec_rcue.codes:
if dec_rcue.code_valide == dec_rcue.codes[0]:
niveau_rcue_class = "recorded"
else:
niveau_rcue_class = "recorded_different"
return f"""
<div class="but_niveau_rcue {niveau_rcue_class}
">
<div class="but_note with_scoplement">
<div>{scu.fmt_note(dec_rcue.rcue.moy_rcue)}</div>
{scoplement}
</div>
<div class="but_code">
{_gen_but_select("code_rcue_"+str(niveau.id),
dec_rcue.codes,
dec_rcue.code_valide,
disabled=True,
klass="manual code_rcue",
data = { "niveau_id" : str(niveau.id)}
)}
</div>
</div>
"""
def jury_but_semestriel(
formsemestre: FormSemestre,
etud: Identite,
read_only: bool,
navigation_div: str = "",
) -> str:
"""Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)."""
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res)
inscription_etat = etud.inscription_etat(formsemestre.id)
semestre_terminal = (
formsemestre.semestre_id >= formsemestre.formation.get_cursus().NB_SEM
)
autorisations_passage = ScolarAutorisationInscription.query.filter_by(
etudid=etud.id,
origin_formsemestre_id=formsemestre.id,
).all()
# Par défaut: autorisé à passer dans le semestre suivant si sem. impair,
# ou si décision déjà enregistrée:
est_autorise_a_passer = (formsemestre.semestre_id % 2) or (
formsemestre.semestre_id + 1
) in (a.semestre_id for a in autorisations_passage)
decisions_ues = {
ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat)
for ue in ues
}
for dec_ue in decisions_ues.values():
dec_ue.compute_codes()
if request.method == "POST":
if not read_only:
for key in request.form:
code = request.form[key]
# Codes d'UE
code_match = re.match(r"^code_ue_(\d+)$", key)
if code_match:
ue_id = int(code_match.group(1))
dec_ue = decisions_ues.get(ue_id)
if not dec_ue:
raise ScoValueError(f"UE invalide ue_id={ue_id}")
dec_ue.record(code)
db.session.commit()
flash("codes enregistrés")
if not semestre_terminal:
if request.form.get("autorisation_passage"):
if not formsemestre.semestre_id + 1 in (
a.semestre_id for a in autorisations_passage
):
ScolarAutorisationInscription.delete_autorisation_etud(
etud.id, formsemestre.id
)
ScolarAutorisationInscription.autorise_etud(
etud.id,
formsemestre.formation.formation_code,
formsemestre.id,
formsemestre.semestre_id + 1,
)
db.session.commit()
flash(
f"""autorisation de passage en S{formsemestre.semestre_id + 1
} enregistrée"""
)
else:
if est_autorise_a_passer:
ScolarAutorisationInscription.delete_autorisation_etud(
etud.id, formsemestre.id
)
db.session.commit()
flash(
f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée"
)
return flask.redirect(
url_for(
"notes.formsemestre_validation_but",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
etudid=etud.id,
)
)
# GET
if formsemestre.semestre_id % 2 == 0:
warning = f"""<div class="warning">
Cet étudiant de S{formsemestre.semestre_id} ne peut pas passer
en jury BUT annuel car il lui manque le semestre précédent.
</div>"""
else:
warning = ""
H = [
html_sco_header.sco_header(
page_title=f"Validation BUT S{formsemestre.semestre_id}",
formsemestre_id=formsemestre.id,
etudid=etud.id,
cssstyles=("css/jury_but.css",),
javascripts=("js/jury_but.js",),
),
f"""
<div class="jury_but">
<div>
<div class="bull_head">
<div>
<div class="titre_parcours">Jury BUT S{formsemestre.id}
- Parcours {(parcour.libelle if parcour else False) or "non spécifié"}
</div>
<div class="nom_etud">{etud.nomprenom}</div>
</div>
<div class="bull_photo"><a href="{
url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
</div>
</div>
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
{warning}
</div>
<form method="post" id="jury_but">
""",
]
erase_span = ""
if not read_only:
# Requête toutes les validations (pas seulement celles du deca courant),
# au cas où: changement d'architecture, saisie en mode classique, ...
validations = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
).all()
if validations:
erase_span = f"""<a href="{
url_for("notes.formsemestre_jury_but_erase",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
etudid=etud.id, only_one_sem=1)
}" class="stdlink">effacer les décisions enregistrées</a>"""
else:
erase_span = (
"Cet étudiant n'a aucune décision enregistrée pour ce semestre."
)
H.append(
f"""
<div class="but_section_annee">
</div>
<div><b>Unités d'enseignement de S{formsemestre.semestre_id}:</b></div>
"""
)
if not ues:
H.append(
"""<div class="warning">Aucune UE ! Vérifiez votre programme de
formation, et l'association UEs / Niveaux de compétences</div>"""
)
else:
H.append(
"""
<div class="but_annee">
<div class="titre"></div>
<div class="titre"></div>
<div class="titre"></div>
<div class="titre"></div>
"""
)
for ue in ues:
dec_ue = decisions_ues[ue.id]
H.append("""<div class="but_niveau_titre"><div></div></div>""")
H.append(
_gen_but_niveau_ue(
ue,
dec_ue,
disabled=read_only,
)
)
H.append(
"""<div style=""></div>
<div class=""></div>"""
)
H.append("</div>") # but_annee
div_autorisations_passage = (
f"""
<div class="but_autorisations_passage">
<span>Autorisé à passer en&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
provisoire pour BUT 2022
"""
etud: Identite = Identite.query.get_or_404(etudid)
etud = Identite.get_etud(etudid)
inscriptions = (
FormSemestreInscription.query.join(FormSemestreInscription.formsemestre)
.filter(
@ -162,11 +536,10 @@ def infos_fiche_etud_html(etudid: int) -> str:
# temporaire quick & dirty: affiche le dernier
try:
deca = DecisionsProposeesAnnee(etud, formsemestres_but[-1])
if len(deca.rcues_annee) > 0:
return f"""<div class="infos_but">
return f"""<div class="infos_but">
{show_etud(deca, read_only=True)}
</div>
"""
"""
except ScoValueError:
pass

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -18,21 +18,11 @@ import pandas as pd
from flask import g
from app.models.formsemestre import FormSemestre
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.codes_cursus import CursusDUT, CursusDUTMono
from app.scodoc.sco_utils import ModuleType
def get_bonus_sport_class_from_name(dept_id):
"""La classe de bonus sport pour le département indiqué.
Note: en ScoDoc 9, le bonus sport est défini gloabelement et
ne dépend donc pas du département.
Résultat: une sous-classe de BonusSport
"""
raise NotImplementedError()
class BonusSport:
"""Calcul du bonus sport.
@ -65,7 +55,7 @@ class BonusSport:
def __init__(
self,
formsemestre: FormSemestre,
formsemestre: "FormSemestre",
sem_modimpl_moys: np.array,
ues: list,
modimpl_inscr_df: pd.DataFrame,
@ -362,18 +352,37 @@ class BonusAisneStQuentin(BonusSportAdditif):
class BonusAmiens(BonusSportAdditif):
"""Bonus IUT Amiens pour les modules optionnels (sport, culture, ...).
"""Bonus IUT Amiens pour les modules optionnels (sport, culture, ...)
Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,1 point
<p><b>À partir d'août 2022:</b></p>
<p>
Deux activités optionnelles sont possibles chaque semestre, et peuvent donner lieu à une bonification de 0,1 chacune sur la moyenne de chaque UE.
</p><p>
La note saisie peut valoir 0 (pas de bonus), 1 (bonus de 0,1 points) ou 2 (bonus de 0,2 points).
</p>
<p><b>Avant juillet 2022:</b></p>
<p>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,1 point
sur toutes les moyennes d'UE.
</p>
"""
name = "bonus_amiens"
displayed_name = "IUT d'Amiens"
seuil_moy_gen = 0.0 # tous les points sont comptés
proportion_point = 1e10
bonus_max = 0.1
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points sont comptés
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus, avec réglage différent suivant la date"""
if self.formsemestre.date_debut > datetime.date(2022, 8, 1):
self.proportion_point = 0.1
self.bonus_max = 0.2
else: # anciens semestres
self.proportion_point = 1e10
self.bonus_max = 0.1
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
# Finalement ils n'en veulent pas.
@ -421,6 +430,22 @@ class BonusAmiens(BonusSportAdditif):
# )
class BonusBesanconVesoul(BonusSportAdditif):
"""Bonus IUT Besançon - Vesoul pour les UE libres
<p>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,2 point
sur toutes les moyennes d'UE.
</p>
"""
name = "bonus_besancon_vesoul"
displayed_name = "IUT de Besançon - Vesoul"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points sont comptés
proportion_point = 1e10 # infini
bonus_max = 0.2
class BonusBethune(BonusSportMultiplicatif):
"""
Calcul bonus modules optionnels (sport, culture), règle IUT de Béthune.
@ -638,7 +663,10 @@ class BonusCalais(BonusSportAdditif):
dans la limite de 10 points. 6% de ces points cumulés s'ajoutent :
<ul>
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant.
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b> (ex : UE2.1BS, UE32BS)
</li>
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b>
(ex : UE2.1BS, UE32BS)
</li>
</ul>
"""
@ -649,11 +677,11 @@ class BonusCalais(BonusSportAdditif):
proportion_point = 0.06 # 6%
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
parcours = self.formsemestre.formation.get_parcours()
parcours = self.formsemestre.formation.get_cursus()
# Variantes de DUT ?
if (
isinstance(parcours, ParcoursDUT)
or parcours.TYPE_PARCOURS == ParcoursDUTMono.TYPE_PARCOURS
isinstance(parcours, CursusDUT)
or parcours.TYPE_CURSUS == CursusDUTMono.TYPE_CURSUS
): # DUT
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
else:
@ -808,7 +836,7 @@ class BonusLaRochelle(BonusSportAdditif):
"""Calcul bonus modules optionnels (sport, culture), règle IUT de La Rochelle.
<ul>
<li>Si la note de sport est comprise entre 0 et 10 : pas dajout de point.</li>
<li>Si la note de sport est comprise entre 0 et 10 : pas d'ajout de point.</li>
<li>Si la note de sport est comprise entre 10 et 20 :
<ul>
<li>Pour le BUT, application pour chaque UE du semestre :
@ -868,15 +896,15 @@ class BonusLeHavre(BonusSportAdditif):
<p>
Les enseignements optionnels de langue, préprofessionnalisation,
PIX (compétences numériques), l'entrepreneuriat étudiant, l'engagement
bénévole au sein dassociation dès lors quune grille dévaluation des
bénévole au sein d'association dès lors qu'une grille d'évaluation des
compétences existe ainsi que les activités sportives et culturelles
seront traités au niveau semestriel.
</p><p>
Le maximum de bonification quun étudiant peut obtenir sur sa moyenne
Le maximum de bonification qu'un étudiant peut obtenir sur sa moyenne
est plafonné à 0.5 point.
</p><p>
Lorsquun étudiant suit plus de deux matières qui donnent droit à
bonification, létudiant choisit les deux notes à retenir.
Lorsqu'un étudiant suit plus de deux matières qui donnent droit à
bonification, l'étudiant choisit les deux notes à retenir.
</p><p>
Les points bonus ne sont acquis que pour une note supérieure à 10/20.
</p><p>
@ -885,7 +913,7 @@ class BonusLeHavre(BonusSportAdditif):
Pour chaque matière (max. 2) donnant lieu à bonification :<br>
Bonification = (N-10) x 0,05,
N étant la note de lactivité sur 20.
N étant la note de l'activité sur 20.
</p>
"""
@ -1097,13 +1125,13 @@ class BonusOrleans(BonusSportAdditif):
"""Calcul bonus modules optionnels (sport, culture), règle IUT d'Orléans
<p><b>Cadre général :</b>
En reconnaissance de l'engagement des étudiants dans la vie associative,
sociale ou professionnelle, lIUT dOrléans accorde, sous conditions,
sociale ou professionnelle, l'IUT d'Orléans accorde, sous conditions,
une bonification aux étudiants inscrits qui en font la demande en début
dannée universitaire.
d'année universitaire.
</p>
<p>Cet engagement doit être régulier et correspondre à une activité réelle
et sérieuse qui bénéficie à toute la communauté étudiante de lIUT,
de lUniversité ou à lensemble de la collectivité.</p>
et sérieuse qui bénéficie à toute la communauté étudiante de l'IUT,
de l'Université ou à l'ensemble de la collectivité.</p>
<p><b>Bonification :</b>
Pour les DUT et LP, cette bonification interviendra sur la moyenne générale
des semestres pairs :
@ -1169,6 +1197,89 @@ class BonusRoanne(BonusSportAdditif):
proportion_point = 1
class BonusSceaux(BonusSportAdditif): # atypique
"""IUT de Sceaux
LIUT de Sceaux (Université de Paris-Saclay) propose aux étudiants un seul enseignement
non rattaché aux UE : loption Sport.
<p>
Cette option donne à létudiant qui la suit une bonification qui sapplique uniquement
si sa note est supérieure à 10.
</p>
<p>
Cette bonification sapplique sur lensemble des UE dun semestre de la façon suivante :
</p>
<p>
<tt>
[ (Note 10) / Nb UE du semestre ] / Total des coefficients de chaque UE
</tt>
</p>
<p>
Exemple : un étudiant qui a obtenu 16/20 à loption Sport en S1
(composé par exemple de 3 UE:UE1.1, UE1.2 et UE1.3)
aurait les bonifications suivantes :
</p>
<ul>
<li>UE1.1 (Total des coefficients : 15) Bonification UE1.1 = <tt>[ (16 10) / 3 ] /15
</tt>
</li>
<li>UE1.2 (Total des coefficients : 14) Bonification UE1.2 = <tt>[ (16 10) / 3 ] /14
</tt>
</li>
<li>UE1.3 (Total des coefficients : 12,5) Bonification UE1.3 = <tt>[ (16 10) / 3 ] /12,5
</tt>
</li>
</ul>
"""
name = "bonus_iut_sceaux"
displayed_name = "IUT de Sceaux"
proportion_point = 1.0
def __init__(
self,
formsemestre: "FormSemestre",
sem_modimpl_moys: np.array,
ues: list,
modimpl_inscr_df: pd.DataFrame,
modimpl_coefs: np.array,
etud_moy_gen,
etud_moy_ue,
):
# Pour ce bonus, il faut conserver:
# - le nombre d'UEs
self.nb_ues = len([ue for ue in ues if ue.type != UE_SPORT])
# - le total des coefs de chaque UE
# modimpl_coefs : DataFrame, lignes modimpl, col UEs (sans sport)
self.sum_coefs_ues = modimpl_coefs.sum() # Series, index ue_id
super().__init__(
formsemestre,
sem_modimpl_moys,
ues,
modimpl_inscr_df,
modimpl_coefs,
etud_moy_gen,
etud_moy_ue,
)
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""Calcul du bonus IUT de Sceaux 2023
sem_modimpl_moys_inscrits: les notes de sport
En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus)
En classic: ndarray (nb_etuds, nb_mod_sport)
Attention: si la somme des coefs de modules dans une UE est nulle, on a un bonus Inf
(moyenne d'UE cappée à 20).
"""
if (0 in sem_modimpl_moys_inscrits.shape) or (self.nb_ues == 0):
# pas d'étudiants ou pas d'UE ou pas de module...
return
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
if self.bonus_ues is not None:
self.bonus_ues = (self.bonus_ues / self.nb_ues) / self.sum_coefs_ues
class BonusStEtienne(BonusSportAdditif):
"""IUT de Saint-Etienne.
@ -1199,7 +1310,7 @@ class BonusStDenis(BonusSportAdditif):
bonus_max = 0.5
class BonusStNazaire(BonusSportMultiplicatif):
class BonusStNazaire(BonusSport):
"""IUT de Saint-Nazaire
Trois bonifications sont possibles : sport, culture et engagement citoyen
@ -1221,9 +1332,37 @@ class BonusStNazaire(BonusSportMultiplicatif):
name = "bonus_iutSN"
displayed_name = "IUT de Saint-Nazaire"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points comptent
amplitude = 0.01 / 4 # 4pt => 1%
factor_max = 0.1 # 10% max
# Modifié 2022-11-29: calculer chaque bonus
# (de 1 à 3 modules) séparément.
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""Calcul du bonus St Nazaire 2022
sem_modimpl_moys_inscrits: les notes de sport
En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus)
En classic: ndarray (nb_etuds, nb_mod_sport)
"""
if 0 in sem_modimpl_moys_inscrits.shape:
# pas d'étudiants ou pas d'UE ou pas de module...
return
# Prend les 3 premiers bonus trouvés
# ignore les coefficients
bonus_mod_moys = sem_modimpl_moys_inscrits[:, :3]
bonus_mod_moys = np.nan_to_num(bonus_mod_moys, copy=False)
factor = bonus_mod_moys * self.amplitude
# somme les bonus:
factor = factor.sum(axis=1)
# et limite à 10%:
factor.clip(0.0, self.factor_max, out=factor)
# Applique aux moyennes d'UE
if len(factor.shape) == 1: # classic
factor = factor[:, np.newaxis]
bonus = self.etud_moy_ue * factor
self.bonus_ues = bonus # DataFrame
# Les bonus multiplicatifs ne s'appliquent pas à la moyenne générale
self.bonus_moy_gen = None
class BonusTarbes(BonusIUTRennes1):
@ -1302,7 +1441,45 @@ class BonusIUTvannes(BonusSportAdditif):
classic_use_bonus_ues = False # seulement sur moy gen.
class BonusVilleAvray(BonusSport):
class BonusValenciennes(BonusDirect):
"""Article 7 des RCC de l'IUT de Valenciennes
<p>
Une bonification maximale de 0.25 point (1/4 de point) peut être ajoutée
à la moyenne de chaque Unité d'Enseignement pour :
</p>
<ul>
<li>l'engagement citoyen ;</li>
<li>la participation à un module de sport.</li>
</ul>
<p>
Une bonification accordée par la commission des sports de l'UPHF peut être attribuée
aux sportifs de haut niveau. Cette bonification est appliquée à l'ensemble des
Unités d'Enseignement. Ce bonus est :
</p>
<ul>
<li> 0.5 pour la catégorie <em>or</em> (sportif inscrit sur liste ministérielle
jeunesse et sport) ;
</li>
<li> 0.45 pour la catégorie <em>argent</em> (sportif en club professionnel) ;
</li>
<li> 0.40 pour le <em>bronze</em> (sportif de niveau départemental, régional ou national).
</li>
</ul>
<p>Le cumul de bonifications est possible mais ne peut excéder 0.5 point (un demi-point).
</p>
<p><em>Dans ScoDoc, saisir directement la valeur désirée du bonus
dans une évaluation notée sur 20.</em>
</p>
"""
name = "bonus_valenciennes"
displayed_name = "IUT de Valenciennes"
bonus_max = 0.5
class BonusVilleAvray(BonusSportAdditif):
"""Bonus modules optionnels (sport, culture), règle IUT Ville d'Avray.
Les étudiants de l'IUT peuvent suivre des enseignements optionnels
@ -1351,7 +1528,7 @@ class BonusIUTV(BonusSportAdditif):
name = "bonus_iutv"
displayed_name = "IUT de Villetaneuse"
pass # oui, c'est le bonus par défaut
# c'est le bonus par défaut: aucune méthode à surcharger
def get_bonus_class_dict(start=BonusSport, d=None):

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -9,10 +9,10 @@
import pandas as pd
from app import db
from app.models import FormSemestre, ScolarFormSemestreValidation, UniteEns
from app.models import FormSemestre, Identite, ScolarFormSemestreValidation, UniteEns
from app.comp.res_cache import ResultatsCache
from app.scodoc import sco_cache
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
class ValidationsSemestre(ResultatsCache):
@ -53,7 +53,7 @@ class ValidationsSemestre(ResultatsCache):
self.comp_decisions_jury()
def comp_decisions_jury(self):
"""Cherche les decisions du jury pour le semestre (pas les UE).
"""Cherche les decisions du jury pour le semestre (pas les RCUE).
Calcule les attributs:
decisions_jury = { etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }}
decision_jury_ues={ etudid :
@ -89,7 +89,7 @@ class ValidationsSemestre(ResultatsCache):
if decision.etudid not in decisions_jury_ues:
decisions_jury_ues[decision.etudid] = {}
# Calcul des ECTS associés à cette UE:
if sco_codes_parcours.code_ue_validant(decision.code) and decision.ue:
if codes_cursus.code_ue_validant(decision.code) and decision.ue:
ects = decision.ue.ects or 0.0 # 0 if None
else:
ects = 0.0
@ -102,6 +102,12 @@ class ValidationsSemestre(ResultatsCache):
self.decisions_jury_ues = decisions_jury_ues
def has_decision(self, etud: Identite) -> bool:
"""Vrai si etud a au moins une décision enregistrée depuis
ce semestre (quelle qu'elle soit)
"""
return (etud.id in self.decisions_jury_ues) or (etud.id in self.decisions_jury)
def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame:
"""Liste des UE capitalisées (ADM) utilisables dans ce formsemestre
@ -122,6 +128,10 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
event_date :
} ]
"""
# Note: pour récupérer aussi les UE validées en CMp ou ADJ, changer une ligne
# and ( SFV.code = 'ADM' or SFV.code = 'ADJ' or SFV.code = 'CMP' )
query = """
SELECT DISTINCT SFV.*, ue.ue_code
FROM

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -14,7 +14,7 @@ import pandas as pd
from app.comp import moy_ue
from app.models.formsemestre import FormSemestre
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_utils import ModuleType

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -35,14 +35,16 @@ moyenne générale d'une UE.
"""
import dataclasses
from dataclasses import dataclass
import numpy as np
import pandas as pd
import app
from app import db
from app.models import ModuleImpl, Evaluation, EvaluationUEPoids
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType
@ -83,6 +85,8 @@ class ModuleImplResults:
"{ evaluation.id : bool } indique si à prendre en compte ou non."
self.evaluations_etat = {}
"{ evaluation_id: EvaluationEtat }"
self.etudids_attente = set()
"etudids avec au moins une note ATT dans ce module"
self.en_attente = False
"Vrai si au moins une évaluation a une note en attente"
#
@ -143,7 +147,6 @@ class ModuleImplResults:
evals_notes = pd.DataFrame(index=self.etudids, dtype=float)
self.evaluations_completes = []
self.evaluations_completes_dict = {}
self.en_attente = False
for evaluation in moduleimpl.evaluations:
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi tous les inscrits (non dem) au semestre ont une note
@ -170,15 +173,20 @@ class ModuleImplResults:
eval_df, how="left", left_index=True, right_index=True
)
# Notes en attente: (ne prend en compte que les inscrits, non démissionnaires)
nb_att = sum(
evals_notes[str(evaluation.id)][list(inscrits_module)]
== scu.NOTES_ATTENTE
eval_notes_inscr = evals_notes[str(evaluation.id)][list(inscrits_module)]
eval_etudids_attente = set(
eval_notes_inscr.iloc[
(eval_notes_inscr == scu.NOTES_ATTENTE).to_numpy()
].index
)
self.etudids_attente |= eval_etudids_attente
self.evaluations_etat[evaluation.id] = EvaluationEtat(
evaluation_id=evaluation.id, nb_attente=nb_att, is_complete=is_complete
evaluation_id=evaluation.id,
nb_attente=len(eval_etudids_attente),
is_complete=is_complete,
)
if nb_att > 0:
self.en_attente = True
# au moins une note en ATT dans ce modimpl:
self.en_attente = bool(self.etudids_attente)
# Force columns names to integers (evaluation ids)
evals_notes.columns = pd.Index([int(x) for x in evals_notes.columns], dtype=int)
@ -217,12 +225,19 @@ class ModuleImplResults:
]
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
"""Coefficients des évaluations, met à zéro ceux des évals incomplètes.
"""Coefficients des évaluations.
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
sont zéro.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[e.coefficient for e in moduleimpl.evaluations],
[
e.coefficient
if e.evaluation_type == scu.EVALUATION_NORMALE
else 0.0
for e in moduleimpl.evaluations
],
dtype=float,
)
* self.evaluations_completes
@ -236,8 +251,8 @@ class ModuleImplResults:
]
def get_eval_notes_sur_20(self, moduleimpl: ModuleImpl) -> np.array:
"""Les notes des évaluations,
remplace les ATT, EXC, ABS, NaN par zéro et mets les notes sur 20.
"""Les notes de toutes les évaluations du module, complètes ou non.
Remplace les ATT, EXC, ABS, NaN par zéro et mets les notes sur 20.
Résultat: 2d array of floats, shape nb_etuds x nb_evaluations
"""
return np.where(
@ -368,7 +383,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
)
# Serie indiquant que l'étudiant utilise une note de rattarage sur l'une des UE:
# Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
)
@ -429,7 +444,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modules_coefficients: pd.DataFrame
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool:
"""Vérifie que les évaluations de ce moduleimpl sont bien conformes
au PN.
@ -438,7 +453,7 @@ def moduleimpl_is_conforme(
Arguments:
evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
modules_coefficients: DataFrame, cols module_id, lignes UEs
modimpl_coefs_df: DataFrame, cols: modimpl_id, lignes: UEs du formsemestre
NB: les UEs dans evals_poids sont sans le bonus sport
"""
nb_evals, nb_ues = evals_poids.shape
@ -446,18 +461,18 @@ def moduleimpl_is_conforme(
return True # modules vides conformes
if nb_ues == 0:
return False # situation absurde (pas d'UE)
if len(modules_coefficients) != nb_ues:
if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
if moduleimpl.module_id not in modules_coefficients:
if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modules_coefficients[moduleimpl.module_id] != 0).eq(module_evals_poids))
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
class ModuleImplResultsClassic(ModuleImplResults):
@ -476,7 +491,8 @@ class ModuleImplResultsClassic(ModuleImplResults):
if nb_etuds == 0:
return pd.Series()
evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1)
assert evals_coefs.shape == (nb_evals,)
if evals_coefs.shape != (nb_evals,):
app.critical_error("compute_module_moy: vals_coefs.shape != nb_evals")
evals_notes_20 = self.get_eval_notes_sur_20(modimpl)
# Les coefs des évals pour chaque étudiant: là où il a des notes
# non neutralisées

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -52,13 +52,16 @@ def compute_sem_moys_apc_using_coefs(
def compute_sem_moys_apc_using_ects(
etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None, skip_empty_ues=False
etud_moy_ue_df: pd.DataFrame,
ects_df: pd.DataFrame,
formation_id=None,
skip_empty_ues=False,
) -> pd.Series:
"""Calcule les moyennes générales indicatives de tous les étudiants
= moyenne des moyennes d'UE, pondérée par leurs ECTS.
etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid
ects: liste de floats ou None, 1 par UE
ects: DataFrame, col. ue_id, lignes etudid, valeur float ou None
Si skip_empty_ues: ne compte pas les UE non notées.
Sinon (par défaut), une UE non notée compte comme zéro.
@ -68,11 +71,11 @@ def compute_sem_moys_apc_using_ects(
try:
if skip_empty_ues:
# annule les coefs des UE sans notes (NaN)
ects = np.where(etud_moy_ue_df.isna(), 0.0, np.array(ects, dtype=float))
# ects est devenu nb_etuds x nb_ues
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1)
ects = np.where(etud_moy_ue_df.isna(), 0.0, ects_df.to_numpy())
else:
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / sum(ects)
ects = ects_df.to_numpy()
# ects est maintenant un array nb_etuds x nb_ues
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1)
except TypeError:
if None in ects:
formation = Formation.query.get(formation_id)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -32,12 +32,17 @@ import pandas as pd
from app import db
from app import models
from app.models import UniteEns, Module, ModuleImpl, ModuleUECoef
from app.models import (
FormSemestre,
Module,
ModuleImpl,
ModuleUECoef,
UniteEns,
)
from app.comp import moy_mod
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_preferences
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_utils import ModuleType
@ -56,7 +61,7 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
"""
ues = (
UniteEns.query.filter_by(formation_id=formation_id)
.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
.filter(UniteEns.type != codes_cursus.UE_SPORT)
.order_by(UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme)
)
modules = (
@ -64,14 +69,9 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
.filter(
(Module.module_type == ModuleType.RESSOURCE)
| (Module.module_type == ModuleType.SAE)
| (
(Module.ue_id == UniteEns.id)
& (UniteEns.type == sco_codes_parcours.UE_SPORT)
)
)
.order_by(
Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code
| ((Module.ue_id == UniteEns.id) & (UniteEns.type == codes_cursus.UE_SPORT))
)
.order_by(Module.semestre_id, Module.module_type, Module.numero, Module.code)
)
if semestre_idx is not None:
ues = ues.filter_by(semestre_idx=semestre_idx)
@ -140,7 +140,8 @@ def df_load_modimpl_coefs(
mod_coef.ue_id
] = mod_coef.coef
except IndexError:
# il peut y avoir en base des coefs sur des modules ou UE qui ont depuis été retirés de la formation
# il peut y avoir en base des coefs sur des modules ou UE
# qui ont depuis été retirés de la formation
pass
# Initialisation des poids non fixés:
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
@ -199,7 +200,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module)
if len(modimpls_notes):
if len(modimpls_notes) > 0:
cube = notes_sem_assemble_cube(modimpls_notes)
else:
nb_etuds = formsemestre.etuds.count()
@ -215,10 +216,11 @@ def compute_ue_moys_apc(
sem_cube: np.array,
etuds: list,
modimpls: list,
ues: list,
modimpl_inscr_df: pd.DataFrame,
modimpl_coefs_df: pd.DataFrame,
modimpl_mask: np.array,
dispense_ues: set[tuple[int, int]],
block: bool = False,
) -> pd.DataFrame:
"""Calcul de la moyenne d'UE en mode APC (BUT).
La moyenne d'UE est un nombre (note/20), ou NaN si pas de notes disponibles
@ -229,18 +231,17 @@ def compute_ue_moys_apc(
etuds : liste des étudiants (dim. 0 du cube)
modimpls : liste des module_impl (dim. 1 du cube)
ues : liste des UE (dim. 2 du cube)
modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
modimpl_inscr_df: matrice d'inscription aux modules du semestre (etud x modimpl)
modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport
modimpl_mask: liste de booléens, indiquants le module doit être pris ou pas.
(utilisé pour éliminer les bonus, et pourra servir à cacluler
sur des sous-ensembles de modules)
block: si vrai, ne calcule rien et renvoie des NaNs
Résultat: DataFrame columns UE (sans bonus), rows etudid
"""
nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape
nb_ues_tot = len(ues)
assert len(modimpls) == nb_modules
if nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0:
if block or nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0:
return pd.DataFrame(
index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index
)
@ -277,11 +278,16 @@ def compute_ue_moys_apc(
etud_moy_ue = np.sum(
modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1
) / np.sum(modimpl_coefs_etuds_no_nan, axis=1)
return pd.DataFrame(
etud_moy_ue_df = pd.DataFrame(
etud_moy_ue,
index=modimpl_inscr_df.index, # les etudids
columns=modimpl_coefs_df.index, # les UE sans les UE bonus sport
)
# Les "dispenses" sont très peu nombreuses et traitées en python:
for dispense_ue in dispense_ues:
etud_moy_ue_df[dispense_ue[1]][dispense_ue[0]] = 0.0
return etud_moy_ue_df
def compute_ue_moys_classic(
@ -291,6 +297,7 @@ def compute_ue_moys_classic(
modimpl_inscr_df: pd.DataFrame,
modimpl_coefs: np.array,
modimpl_mask: np.array,
block: bool = False,
) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]:
"""Calcul de la moyenne d'UE et de la moy. générale en mode classique (DUT, LMD, ...).
@ -312,6 +319,7 @@ def compute_ue_moys_classic(
modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
modimpl_coefs: vecteur des coefficients de modules
modimpl_mask: masque des modimpls à prendre en compte
block: si vrai, ne calcule rien et renvoie des NaNs
Résultat:
- moyennes générales: pd.Series, index etudid
@ -320,13 +328,14 @@ def compute_ue_moys_classic(
les coefficients effectifs de chaque UE pour chaque étudiant
(sommes de coefs de modules pris en compte)
"""
if (not len(modimpl_mask)) or (
sem_matrix.shape[0] == 0
if (
block or (len(modimpl_mask) == 0) or (sem_matrix.shape[0] == 0)
): # aucun module ou aucun étudiant
# etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df
val = np.nan if block else 0.0
return (
pd.Series(
[0.0] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index
[val] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index
),
pd.DataFrame(columns=[ue.id for ue in ues], index=modimpl_inscr_df.index),
pd.DataFrame(columns=[ue.id for ue in ues], index=modimpl_inscr_df.index),
@ -431,7 +440,7 @@ def compute_mat_moys_classic(
Résultat:
- moyennes: pd.Series, index etudid
"""
if (not len(modimpl_mask)) or (
if (0 == len(modimpl_mask)) or (
sem_matrix.shape[0] == 0
): # aucun module ou aucun étudiant
# etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df
@ -462,9 +471,10 @@ def compute_mat_moys_classic(
if modimpl_coefs_etuds_no_nan.dtype == object: # arrive sur des tableaux vides
modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float)
etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum(
axis=1
) / modimpl_coefs_etuds_no_nan.sum(axis=1)
with np.errstate(invalid="ignore"): # il peut y avoir des NaN
etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum(
axis=1
) / modimpl_coefs_etuds_no_nan.sum(axis=1)
return pd.Series(etud_moy_mat, index=modimpl_inscr_df.index)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -16,9 +16,11 @@ from app.comp.res_compat import NotesTableCompat
from app.comp.bonus_spo import BonusSport
from app.models import ScoDocSiteConfig
from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.models.ues import DispenseUE, UniteEns
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.scodoc import sco_preferences
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_utils import ModuleType
class ResultatsSemestreBUT(NotesTableCompat):
@ -39,6 +41,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
"""ndarray (etuds x modimpl x ue)"""
self.etuds_parcour_id = None
"""Parcours de chaque étudiant { etudid : parcour_id }"""
if not self.load_cached():
t0 = time.time()
self.compute()
@ -71,15 +74,18 @@ class ResultatsSemestreBUT(NotesTableCompat):
modimpl.module.ue.type != UE_SPORT
for modimpl in self.formsemestre.modimpls_sorted
]
self.dispense_ues = DispenseUE.load_formsemestre_dispense_ues_set(
self.formsemestre, self.modimpl_inscr_df.index, self.ues
)
self.etud_moy_ue = moy_ue.compute_ue_moys_apc(
self.sem_cube,
self.etuds,
self.formsemestre.modimpls_sorted,
self.ues,
self.modimpl_inscr_df,
self.modimpl_coefs_df,
modimpls_mask,
self.dispense_ues,
block=self.formsemestre.block_moyennes,
)
# Les coefficients d'UE ne sont pas utilisés en APC
self.etud_coef_ue_df = pd.DataFrame(
@ -114,6 +120,10 @@ class ResultatsSemestreBUT(NotesTableCompat):
# Nanifie les moyennes d'UE hors parcours pour chaque étudiant
self.etud_moy_ue *= self.ues_inscr_parcours_df
# Les ects (utilisés comme coefs) sont nuls pour les UE hors parcours:
ects = self.ues_inscr_parcours_df.fillna(0.0) * [
ue.ects for ue in self.ues if ue.type != UE_SPORT
]
# Moyenne générale indicative:
# (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte
@ -121,14 +131,19 @@ class ResultatsSemestreBUT(NotesTableCompat):
# self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_coefs(
# self.etud_moy_ue, self.modimpl_coefs_df
# )
self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_ects(
self.etud_moy_ue,
[ue.ects for ue in self.ues if ue.type != UE_SPORT],
formation_id=self.formsemestre.formation_id,
skip_empty_ues=sco_preferences.get_preference(
"but_moy_skip_empty_ues", self.formsemestre.id
),
)
if self.formsemestre.block_moyenne_generale or self.formsemestre.block_moyennes:
self.etud_moy_gen = pd.Series(
index=self.etud_moy_ue.index, dtype=float
) # NaNs
else:
self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_ects(
self.etud_moy_ue,
ects,
formation_id=self.formsemestre.formation_id,
skip_empty_ues=sco_preferences.get_preference(
"but_moy_skip_empty_ues", self.formsemestre.id
),
)
# --- UE capitalisées
self.apply_capitalisation()
@ -145,6 +160,8 @@ class ResultatsSemestreBUT(NotesTableCompat):
# moyenne sur les UE:
if len(self.sem_cube[etud_idx, mod_idx]):
return np.nanmean(self.sem_cube[etud_idx, mod_idx])
# note: si toutes les valeurs sont nan, on va déclencher ici
# un RuntimeWarning: Mean of empty slice
return np.nan
def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float:
@ -172,9 +189,15 @@ class ResultatsSemestreBUT(NotesTableCompat):
modimpls = [
modimpl
for modimpl in self.formsemestre.modimpls_sorted
if modimpl.module.ue.type != UE_SPORT
and (coefs[modimpl.id][ue.id] != 0)
and self.modimpl_inscr_df[modimpl.id][etudid]
if (
modimpl.module.ue.type != UE_SPORT
and (coefs[modimpl.id][ue.id] != 0)
and self.modimpl_inscr_df[modimpl.id][etudid]
)
or (
modimpl.module.module_type == ModuleType.MALUS
and modimpl.module.ue_id == ue.id
)
]
if not with_bonus:
return [
@ -204,27 +227,33 @@ class ResultatsSemestreBUT(NotesTableCompat):
}
self.etuds_parcour_id = etuds_parcour_id
ue_ids = [ue.id for ue in self.ues if ue.type != UE_SPORT]
# matrice de 1, inscrits par défaut à toutes les UE:
ues_inscr_parcours_df = pd.DataFrame(
1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
)
if self.formsemestre.formation.referentiel_competence is None:
return ues_inscr_parcours_df
if self.formsemestre.formation.referentiel_competence is None:
return pd.DataFrame(
1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
)
# matrice de NaN: inscrits par défaut à AUCUNE UE:
ues_inscr_parcours_df = pd.DataFrame(
np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
)
# Construit pour chaque parcours du référentiel l'ensemble de ses UE
# (considère aussi le cas des semestres sans parcours: None)
ue_by_parcours = {} # parcours_id : {ue_id:0|1}
for parcour in self.formsemestre.formation.referentiel_competence.parcours:
ue_by_parcours[parcour.id] = {
for (
parcour
) in self.formsemestre.formation.referentiel_competence.parcours.all() + [None]:
ue_by_parcours[None if parcour is None else parcour.id] = {
ue.id: 1.0
for ue in self.formsemestre.formation.query_ues_parcour(
parcour
).filter_by(semestre_idx=self.formsemestre.semestre_id)
}
#
for etudid in etuds_parcour_id:
parcour = etuds_parcour_id[etudid]
if parcour is not None:
ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[
etuds_parcour_id[etudid]
]
parcour_id = etuds_parcour_id[etudid]
if parcour_id in ue_by_parcours:
if ue_by_parcours[parcour_id]:
ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[parcour_id]
return ues_inscr_parcours_df
def etud_ues_ids(self, etudid: int) -> list[int]:
@ -233,3 +262,18 @@ class ResultatsSemestreBUT(NotesTableCompat):
"""
s = self.ues_inscr_parcours_df.loc[etudid]
return s.index[s.notna()]
def etud_has_decision(self, etudid):
"""True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
prend aussi en compte les autorisations de passage.
Sous-classée en BUT pour les RCUEs et années.
"""
return (
super().etud_has_decision(etudid)
or ApcValidationAnnee.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
or ApcValidationRCUE.query.filter_by(
formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
)

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -22,7 +22,7 @@ from app.models import ScoDocSiteConfig
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc.sco_utils import ModuleType
@ -90,6 +90,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
self.modimpl_inscr_df,
self.modimpl_coefs,
modimpl_standards_mask,
block=self.formsemestre.block_moyennes,
)
# --- Modules de MALUS sur les UEs et la moyenne générale
self.malus = moy_ue.compute_malus(
@ -229,7 +230,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
f"""* oups: calcul coef UE impossible\nformsemestre_id='{self.formsemestre.id
}'\netudid='{etudid}'\nue={ue}"""
)
etud: Identite = Identite.query.get(etudid)
etud = Identite.get_etud(etudid)
raise ScoValueError(
f"""<div class="scovalueerror"><p>Coefficient de l'UE capitalisée {ue.acronyme}
impossible à déterminer pour l'étudiant <a href="{

View File

@ -1,13 +1,13 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Résultats semestre: méthodes communes aux formations classiques et APC
"""
from collections import Counter
from collections import Counter, defaultdict
from collections.abc import Generator
from functools import cached_property
import numpy as np
@ -15,7 +15,6 @@ import pandas as pd
from flask import g, url_for
from app.auth.models import User
from app.comp import res_sem
from app.comp.res_cache import ResultatsCache
from app.comp.jury import ValidationsSemestre
@ -23,12 +22,11 @@ from app.comp.moy_mod import ModuleImplResults
from app.models import FormSemestre, FormSemestreUECoef
from app.models import Identite
from app.models import ModuleImpl, ModuleImplInscription
from app.models import ScolarAutorisationInscription
from app.models.ues import UniteEns
from app.scodoc.sco_cache import ResultatsSemestreCache
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM
from app.scodoc import sco_evaluation_db
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_groups
from app.scodoc import sco_utils as scu
# Il faut bien distinguer
@ -48,24 +46,27 @@ class ResultatsSemestre(ResultatsCache):
_cached_attrs = (
"bonus",
"bonus_ues",
"dispense_ues",
"etud_coef_ue_df",
"etud_moy_gen_ranks",
"etud_moy_gen",
"etud_moy_ue",
"modimpl_inscr_df",
"modimpls_results",
"etud_coef_ue_df",
"moyennes_matieres",
)
def __init__(self, formsemestre: FormSemestre):
super().__init__(formsemestre, ResultatsSemestreCache)
# BUT ou standard ? (apc == "approche par compétences")
self.is_apc = formsemestre.formation.is_apc()
self.is_apc: bool = formsemestre.formation.is_apc()
# Attributs "virtuels", définis dans les sous-classes
self.bonus: pd.Series = None # virtuel
"Bonus sur moy. gen. Series de float, index etudid"
self.bonus_ues: pd.DataFrame = None # virtuel
"DataFrame de float, index etudid, columns: ue.id"
self.dispense_ues: set[tuple[int, int]] = set()
"""set des dispenses d'UE: (etudid, ue_id), en APC seulement."""
# ResultatsSemestreBUT ou ResultatsSemestreClassic
self.etud_moy_ue = {}
"etud_moy_ue: DataFrame columns UE, rows etudid"
@ -83,6 +84,7 @@ class ResultatsSemestre(ResultatsCache):
"""Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef."""
self.validations = None
self.autorisations_inscription = None
self.moyennes_matieres = {}
"""Moyennes de matières, si calculées. { matiere_id : Series, index etudid }"""
@ -123,7 +125,8 @@ class ResultatsSemestre(ResultatsCache):
return [ue.id for ue in self.ues if ue.type != UE_SPORT]
def etud_ues(self, etudid: int) -> Generator[UniteEns]:
"""Liste des UE auxquelles l'étudiant est inscrit."""
"""Liste des UE auxquelles l'étudiant est inscrit
(sans bonus, en BUT prend en compte le parcours de l'étudiant)."""
return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid))
def etud_ects_tot_sem(self, etudid: int) -> float:
@ -171,13 +174,35 @@ class ResultatsSemestre(ResultatsCache):
if m.module.module_type == scu.ModuleType.SAE
]
def get_etudids_attente(self) -> set[int]:
"""L'ensemble des etudids ayant au moins une note en ATTente"""
return set().union(
*[mr.etudids_attente for mr in self.modimpls_results.values()]
)
# --- JURY...
def load_validations(self) -> ValidationsSemestre:
"""Load validations, set attribute and return value"""
def get_formsemestre_validations(self) -> ValidationsSemestre:
"""Load validations if not already stored, set attribute and return value"""
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
return self.validations
def get_autorisations_inscription(self) -> dict[int : list[int]]:
"""Les autorisations d'inscription venant de ce formsemestre.
Lit en base et cache le résultat.
Resultat: { etudid : [ indices de semestres ]}
Note: les etudids peuvent ne plus être inscrits ici.
Seuls ceux avec des autorisations enregistrées sont présents dans le résultat.
"""
if not self.autorisations_inscription:
autorisations = ScolarAutorisationInscription.query.filter_by(
origin_formsemestre_id=self.formsemestre.id
)
self.autorisations_inscription = defaultdict(list)
for aut in autorisations:
self.autorisations_inscription[aut.etudid].append(aut.semestre_id)
return self.autorisations_inscription
def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]:
"""Liste des UEs du semestre qui doivent être validées
@ -235,8 +260,8 @@ class ResultatsSemestre(ResultatsCache):
UE capitalisées.
"""
# Supposant qu'il y a peu d'UE capitalisées,
# on recalcule les moyennes gen des etuds ayant des UE capitalisée.
self.load_validations()
# on recalcule les moyennes gen des etuds ayant des UEs capitalisées.
self.get_formsemestre_validations()
ue_capitalisees = self.validations.ue_capitalisees
for etudid in ue_capitalisees.index:
recompute_mg = False
@ -274,7 +299,7 @@ class ResultatsSemestre(ResultatsCache):
def get_etud_etat(self, etudid: int) -> str:
"Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)"
ins = self.formsemestre.etuds_inscriptions.get(etudid, None)
ins = self.formsemestre.etuds_inscriptions.get(etudid)
if ins is None:
return ""
return ins.etat
@ -316,7 +341,7 @@ class ResultatsSemestre(ResultatsCache):
"""L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre.
"""
ue = UniteEns.query.get(ue_id) # TODO cacher nos UEs ?
ue = UniteEns.query.get(ue_id)
if ue.type == UE_SPORT:
return {
"is_capitalized": False,
@ -363,7 +388,7 @@ class ResultatsSemestre(ResultatsCache):
ue_capitalized = UniteEns.query.get(ue_cap["ue_id"])
coef_ue = ue_capitalized.ects
if coef_ue is None:
orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"])
orig_sem = FormSemestre.get_formsemestre(ue_cap["formsemestre_id"])
raise ScoValueError(
f"""L'UE capitalisée {ue_capitalized.acronyme}
du semestre {orig_sem.titre_annee()}
@ -428,588 +453,3 @@ class ResultatsSemestre(ResultatsCache):
# ici si l'étudiant est inscrit dans le semestre courant,
# somme des coefs des modules de l'UE auxquels il est inscrit
return self.compute_etud_ue_coef(etudid, ue)
# --- TABLEAU RECAP
def get_table_recap(
self,
convert_values=False,
include_evaluations=False,
mode_jury=False,
allow_html=True,
):
"""Table récap. des résultats.
allow_html: si vri, peut-mettre du HTML dans les valeurs
Result: tuple avec
- rows: liste de dicts { column_id : value }
- titles: { column_id : title }
- columns_ids: (liste des id de colonnes)
Si convert_values, transforme les notes en chaines ("12.34").
Les colonnes générées sont:
etudid
rang : rang indicatif (basé sur moy gen)
moy_gen : moy gen indicative
moy_ue_<ue_id>, ..., les moyennes d'UE
moy_res_<modimpl_id>_<ue_id>, ... les moyennes de ressources dans l'UE
moy_sae_<modimpl_id>_<ue_id>, ... les moyennes de SAE dans l'UE
On ajoute aussi des attributs:
- pour les lignes:
_css_row_class (inutilisé pour le monent)
_<column_id>_class classe css:
- la moyenne générale a la classe col_moy_gen
- les colonnes SAE ont la classe col_sae
- les colonnes Resources ont la classe col_res
- les colonnes d'UE ont la classe col_ue
- les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_<ue_id>
_<column_id>_order : clé de tri
"""
if convert_values:
fmt_note = scu.fmt_note
else:
fmt_note = lambda x: x
parcours = self.formsemestre.formation.get_parcours()
barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE
barre_valid_ue = parcours.NOTES_BARRE_VALID_UE
barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING
NO_NOTE = "-" # contenu des cellules sans notes
rows = []
# column_id : title
titles = {}
# les titres en footer: les mêmes, mais avec des bulles et liens:
titles_bot = {}
dict_nom_res = {} # cache uid : nomcomplet
def add_cell(
row: dict,
col_id: str,
title: str,
content: str,
classes: str = "",
idx: int = 100,
):
"Add a row to our table. classes is a list of css class names"
row[col_id] = content
if classes:
row[f"_{col_id}_class"] = classes + f" c{idx}"
if not col_id in titles:
titles[col_id] = title
titles[f"_{col_id}_col_order"] = idx
if classes:
titles[f"_{col_id}_class"] = classes
return idx + 1
etuds_inscriptions = self.formsemestre.etuds_inscriptions
ues = self.formsemestre.query_ues(with_sport=True) # avec bonus
ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT]
modimpl_ids = set() # modimpl effectivement présents dans la table
for etudid in etuds_inscriptions:
idx = 0 # index de la colonne
etud = Identite.query.get(etudid)
row = {"etudid": etudid}
# --- Codes (seront cachés, mais exportés en excel)
idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx)
idx = add_cell(
row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx
)
# --- Rang
idx = add_cell(
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
)
row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}"
# --- Identité étudiant
idx = add_cell(
row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx
)
idx = add_cell(
row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx
)
row["_nom_disp_order"] = etud.sort_key
idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx)
idx = add_cell(
row, "nom_short", "Nom", etud.nom_short, "identite_court", idx
)
row["_nom_short_order"] = etud.sort_key
row["_nom_short_target"] = url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
formsemestre_id=self.formsemestre.id,
etudid=etudid,
)
row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
row["_nom_disp_target"] = row["_nom_short_target"]
row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]
idx = 30 # début des colonnes de notes
# --- Moyenne générale
moy_gen = self.etud_moy_gen.get(etudid, False)
note_class = ""
if moy_gen is False:
moy_gen = NO_NOTE
elif isinstance(moy_gen, float) and moy_gen < barre_moy:
note_class = " moy_ue_warning" # en rouge
idx = add_cell(
row,
"moy_gen",
"Moy",
fmt_note(moy_gen),
"col_moy_gen" + note_class,
idx,
)
titles_bot["_moy_gen_target_attrs"] = (
'title="moyenne indicative"' if self.is_apc else ""
)
# --- Moyenne d'UE
nb_ues_validables, nb_ues_warning = 0, 0
for ue in ues_sans_bonus:
ue_status = self.get_etud_ue_status(etudid, ue.id)
if ue_status is not None:
col_id = f"moy_ue_{ue.id}"
val = ue_status["moy"]
note_class = ""
if isinstance(val, float):
if val < barre_moy:
note_class = " moy_inf"
elif val >= barre_valid_ue:
note_class = " moy_ue_valid"
nb_ues_validables += 1
if val < barre_warning_ue:
note_class = " moy_ue_warning" # notes très basses
nb_ues_warning += 1
idx = add_cell(
row,
col_id,
ue.acronyme,
fmt_note(val),
"col_ue" + note_class,
idx,
)
titles_bot[
f"_{col_id}_target_attrs"
] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """
if mode_jury:
# pas d'autre colonnes de résultats
continue
# Bonus (sport) dans cette UE ?
# Le bonus sport appliqué sur cette UE
if (self.bonus_ues is not None) and (ue.id in self.bonus_ues):
val = self.bonus_ues[ue.id][etud.id] or ""
val_fmt = val_fmt_html = fmt_note(val)
if val:
val_fmt_html = f'<span class="green-arrow-up"></span><span class="sp2l">{val_fmt}</span>'
idx = add_cell(
row,
f"bonus_ue_{ue.id}",
f"Bonus {ue.acronyme}",
val_fmt_html if allow_html else val_fmt,
"col_ue_bonus",
idx,
)
row[f"_bonus_ue_{ue.id}_xls"] = val_fmt
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE
idx_malus = idx # place pour colonne malus à gauche des modules
idx += 1
for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False):
if ue_status["is_capitalized"]:
val = "-c-"
else:
modimpl_results = self.modimpls_results.get(modimpl.id)
if modimpl_results: # pas bonus
if self.is_apc: # BUT
moys_vers_ue = modimpl_results.etuds_moy_module.get(
ue.id
)
val = (
moys_vers_ue.get(etudid, "?")
if moys_vers_ue is not None
else ""
)
else: # classique: Series indépendante de l'UE
val = modimpl_results.etuds_moy_module.get(
etudid, "?"
)
else:
val = ""
col_id = (
f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
)
val_fmt = val_fmt_html = fmt_note(val)
if convert_values and (
modimpl.module.module_type == scu.ModuleType.MALUS
):
val_fmt_html = (
(scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else ""
)
idx = add_cell(
row,
col_id,
modimpl.module.code,
val_fmt_html,
# class col_res mod_ue_123
f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}",
idx,
)
row[f"_{col_id}_xls"] = val_fmt
if modimpl.module.module_type == scu.ModuleType.MALUS:
titles[f"_{col_id}_col_order"] = idx_malus
titles_bot[f"_{col_id}_target"] = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
nom_resp = dict_nom_res.get(modimpl.responsable_id)
if nom_resp is None:
user = User.query.get(modimpl.responsable_id)
nom_resp = user.get_nomcomplet() if user else ""
dict_nom_res[modimpl.responsable_id] = nom_resp
titles_bot[
f"_{col_id}_target_attrs"
] = f""" title="{modimpl.module.titre} ({nom_resp})" """
modimpl_ids.add(modimpl.id)
nb_ues_etud_parcours = len(self.etud_ues_ids(etudid))
ue_valid_txt = (
ue_valid_txt_html
) = f"{nb_ues_validables}/{nb_ues_etud_parcours}"
if nb_ues_warning:
ue_valid_txt_html += " " + scu.EMO_WARNING
add_cell(
row,
"ues_validables",
"UEs",
ue_valid_txt_html,
"col_ue col_ues_validables",
29, # juste avant moy. gen.
)
row["_ues_validables_xls"] = ue_valid_txt
if nb_ues_warning:
row["_ues_validables_class"] += " moy_ue_warning"
elif nb_ues_validables < len(ues_sans_bonus):
row["_ues_validables_class"] += " moy_inf"
row["_ues_validables_order"] = nb_ues_validables # pour tri
if mode_jury and self.validations:
if self.is_apc:
# formations BUT: pas de code semestre, concatene ceux des UE
dec_ues = self.validations.decisions_jury_ues.get(etudid)
if dec_ues:
jury_code_sem = ",".join(
[dec_ues[ue_id].get("code", "") for ue_id in dec_ues]
)
else:
jury_code_sem = ""
else:
# formations classiqes: code semestre
dec_sem = self.validations.decisions_jury.get(etudid)
jury_code_sem = dec_sem["code"] if dec_sem else ""
idx = add_cell(
row,
"jury_code_sem",
"Jury",
jury_code_sem,
"jury_code_sem",
1000,
)
idx = add_cell(
row,
"jury_link",
"",
f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid
)
}">{"saisir" if not jury_code_sem else "modifier"} décision</a>""",
"col_jury_link",
idx,
)
rows.append(row)
self.recap_add_partitions(rows, titles)
self._recap_add_admissions(rows, titles)
# tri par rang croissant
rows.sort(key=lambda e: e["_rang_order"])
# INFOS POUR FOOTER
bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note)
if include_evaluations:
self._recap_add_evaluations(rows, titles, bottom_infos)
# Ajoute style "col_empty" aux colonnes de modules vides
for col_id in titles:
c_class = f"_{col_id}_class"
if "col_empty" in bottom_infos["moy"].get(c_class, ""):
for row in rows:
row[c_class] = row.get(c_class, "") + " col_empty"
titles[c_class] += " col_empty"
for row in bottom_infos.values():
row[c_class] = row.get(c_class, "") + " col_empty"
# --- TABLE FOOTER: ECTS, moyennes, min, max...
footer_rows = []
for (bottom_line, row) in bottom_infos.items():
# Cases vides à styler:
row["moy_gen"] = row.get("moy_gen", "")
row["_moy_gen_class"] = "col_moy_gen"
# titre de la ligne:
row["prenom"] = row["nom_short"] = (
row.get("_title", "") or bottom_line.capitalize()
)
row["_tr_class"] = bottom_line.lower() + (
(" " + row["_tr_class"]) if "_tr_class" in row else ""
)
footer_rows.append(row)
titles_bot.update(titles)
footer_rows.append(titles_bot)
column_ids = [title for title in titles if not title.startswith("_")]
column_ids.sort(
key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000)
)
return (rows, footer_rows, titles, column_ids)
def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
"""Les informations à mettre en bas de la table: min, max, moy, ECTS"""
row_min, row_max, row_moy, row_coef, row_ects, row_apo = (
{"_tr_class": "bottom_info", "_title": "Min."},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info"},
{"_tr_class": "bottom_info", "_title": "Code Apogée"},
)
# --- ECTS
for ue in ues:
colid = f"moy_ue_{ue.id}"
row_ects[colid] = ue.ects
row_ects[f"_{colid}_class"] = "col_ue"
# style cases vides pour borders verticales
row_coef[colid] = ""
row_coef[f"_{colid}_class"] = "col_ue"
# row_apo[colid] = ue.code_apogee or ""
row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT])
row_ects["_moy_gen_class"] = "col_moy_gen"
# --- MIN, MAX, MOY, APO
row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min())
row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max())
row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean())
for ue in ues:
colid = f"moy_ue_{ue.id}"
row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min())
row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max())
row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean())
row_min[f"_{colid}_class"] = "col_ue"
row_max[f"_{colid}_class"] = "col_ue"
row_moy[f"_{colid}_class"] = "col_ue"
row_apo[colid] = ue.code_apogee or ""
for modimpl in self.formsemestre.modimpls_sorted:
if modimpl.id in modimpl_ids:
colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}"
if self.is_apc:
coef = self.modimpl_coefs_df[modimpl.id][ue.id]
else:
coef = modimpl.module.coefficient or 0
row_coef[colid] = fmt_note(coef)
notes = self.modimpl_notes(modimpl.id, ue.id)
if np.isnan(notes).all():
# aucune note valide
row_min[colid] = np.nan
row_max[colid] = np.nan
moy = np.nan
else:
row_min[colid] = fmt_note(np.nanmin(notes))
row_max[colid] = fmt_note(np.nanmax(notes))
moy = np.nanmean(notes)
row_moy[colid] = fmt_note(moy)
if np.isnan(moy):
# aucune note dans ce module
row_moy[f"_{colid}_class"] = "col_empty"
row_apo[colid] = modimpl.module.code_apogee or ""
return { # { key : row } avec key = min, max, moy, coef
"min": row_min,
"max": row_max,
"moy": row_moy,
"coef": row_coef,
"ects": row_ects,
"apo": row_apo,
}
def _recap_etud_groups_infos(
self, etudid: int, row: dict, titles: dict
): # XXX non utilisé
"""Table recap: ajoute à row les colonnes sur les groupes pour cet etud"""
# dec = self.get_etud_decision_sem(etudid)
# if dec:
# codes_nb[dec["code"]] += 1
row_class = ""
etud_etat = self.get_etud_etat(etudid)
if etud_etat == DEM:
gr_name = "Dém."
row_class = "dem"
elif etud_etat == DEF:
gr_name = "Déf."
row_class = "def"
else:
# XXX probablement à revoir pour utiliser données cachées,
# via get_etud_groups_in_partition ou autre
group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id)
gr_name = group["group_name"] or ""
row["group"] = gr_name
row["_group_class"] = "group"
if row_class:
row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class])
titles["group"] = "Gr"
def _recap_add_admissions(self, rows: list[dict], titles: dict):
"""Ajoute les colonnes "admission"
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "admission"
"""
fields = {
"bac": "Bac",
"specialite": "Spécialité",
"type_admission": "Type Adm.",
"classement": "Rg. Adm.",
}
first = True
for i, cid in enumerate(fields):
titles[f"_{cid}_col_order"] = 10000 + i # tout à droite
if first:
titles[f"_{cid}_class"] = "admission admission_first"
first = False
else:
titles[f"_{cid}_class"] = "admission"
titles.update(fields)
for row in rows:
etud = Identite.query.get(row["etudid"])
admission = etud.admission.first()
first = True
for cid in fields:
row[cid] = getattr(admission, cid) or ""
if first:
row[f"_{cid}_class"] = "admission admission_first"
first = False
else:
row[f"_{cid}_class"] = "admission"
def recap_add_partitions(self, rows: list[dict], titles: dict, col_idx: int = None):
"""Ajoute les colonnes indiquant les groupes
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "partition"
"""
partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
self.formsemestre.id
)
first_partition = True
col_order = 10 if col_idx is None else col_idx
for partition in partitions:
cid = f"part_{partition['partition_id']}"
rg_cid = cid + "_rg" # rang dans la partition
titles[cid] = partition["partition_name"]
if first_partition:
klass = "partition"
else:
klass = "partition partition_aux"
titles[f"_{cid}_class"] = klass
titles[f"_{cid}_col_order"] = col_order
titles[f"_{rg_cid}_col_order"] = col_order + 1
col_order += 2
if partition["bul_show_rank"]:
titles[rg_cid] = f"Rg {partition['partition_name']}"
titles[f"_{rg_cid}_class"] = "partition_rangs"
partition_etud_groups = partitions_etud_groups[partition["partition_id"]]
for row in rows:
group = None # group (dict) de l'étudiant dans cette partition
# dans NotesTableCompat, à revoir
etud_etat = self.get_etud_etat(row["etudid"])
if etud_etat == "D":
gr_name = "Dém."
row["_tr_class"] = "dem"
elif etud_etat == DEF:
gr_name = "Déf."
row["_tr_class"] = "def"
else:
group = partition_etud_groups.get(row["etudid"])
gr_name = group["group_name"] if group else ""
if gr_name:
row[cid] = gr_name
row[f"_{cid}_class"] = klass
# Rangs dans groupe
if (
partition["bul_show_rank"]
and (group is not None)
and (group["id"] in self.moy_gen_rangs_by_group)
):
rang = self.moy_gen_rangs_by_group[group["id"]][0]
row[rg_cid] = rang.get(row["etudid"], "")
first_partition = False
def _recap_add_evaluations(
self, rows: list[dict], titles: dict, bottom_infos: dict
):
"""Ajoute les colonnes avec les notes aux évaluations
rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "evaluation"
"""
# nouvelle ligne pour description évaluations:
bottom_infos["descr_evaluation"] = {
"_tr_class": "bottom_info",
"_title": "Description évaluation",
}
first_eval = True
index_col = 9000 # à droite
for modimpl in self.formsemestre.modimpls_sorted:
evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl)
eval_index = len(evals) - 1
inscrits = {i.etudid for i in modimpl.inscriptions}
first_eval_of_mod = True
for e in evals:
cid = f"eval_{e.id}"
titles[
cid
] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}'
klass = "evaluation"
if first_eval:
klass += " first"
elif first_eval_of_mod:
klass += " first_of_mod"
titles[f"_{cid}_class"] = klass
first_eval_of_mod = first_eval = False
titles[f"_{cid}_col_order"] = index_col
index_col += 1
eval_index -= 1
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(
e.evaluation_id
)
for row in rows:
etudid = row["etudid"]
if etudid in inscrits:
if etudid in notes_db:
val = notes_db[etudid]["value"]
else:
# Note manquante mais prise en compte immédiate: affiche ATT
val = scu.NOTES_ATTENTE
row[cid] = scu.fmt_note(val)
row[f"_{cid}_class"] = klass + {
"ABS": " abs",
"ATT": " att",
"EXC": " exc",
}.get(row[cid], "")
else:
row[cid] = "ni"
row[f"_{cid}_class"] = klass + " non_inscrit"
bottom_infos["coef"][cid] = e.coefficient
bottom_infos["min"][cid] = "0"
bottom_infos["max"][cid] = scu.fmt_note(e.note_max)
bottom_infos["descr_evaluation"][cid] = e.description or ""
bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
)

View File

@ -1,12 +1,13 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Classe résultats pour compatibilité avec le code ScoDoc 7
"""
from functools import cached_property
import pandas as pd
from flask import flash, g, Markup, url_for
@ -14,11 +15,8 @@ from app import log
from app.comp import moy_sem
from app.comp.aux_stats import StatsMoyenne
from app.comp.res_common import ResultatsSemestre
from app.comp import res_sem
from app.models import FormSemestre
from app.models import Identite
from app.models import ModuleImpl
from app.scodoc.sco_codes_parcours import UE_SPORT, DEF
from app.models import Identite, FormSemestre, ModuleImpl, ScolarAutorisationInscription
from app.scodoc.codes_cursus import UE_SPORT, DEF
from app.scodoc import sco_utils as scu
# Pour raccorder le code des anciens codes qui attendent une NoteTable
@ -26,7 +24,7 @@ class NotesTableCompat(ResultatsSemestre):
"""Implementation partielle de NotesTable
Les méthodes définies dans cette classe sont
pour conserver la compatibilité abvec les codes anciens et
pour conserver la compatibilité avec les codes anciens et
il n'est pas recommandé de les utiliser dans de nouveaux
développements (API malcommode et peu efficace).
"""
@ -53,7 +51,7 @@ class NotesTableCompat(ResultatsSemestre):
self.moy_gen_rangs_by_group = {} # { group_id : (Series, Series) }
self.ue_rangs_by_group = {} # { ue_id : {group_id : (Series, Series)}}
self.expr_diagnostics = ""
self.parcours = self.formsemestre.formation.get_parcours()
self.parcours = self.formsemestre.formation.get_cursus()
self._modimpls_dict_by_ue = {} # local cache
def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]:
@ -104,10 +102,9 @@ class NotesTableCompat(ResultatsSemestre):
"""Stats (moy/min/max) sur la moyenne générale"""
return StatsMoyenne(self.etud_moy_gen)
def get_ues_stat_dict(
self, filter_sport=False, check_apc_ects=True
) -> list[dict]: # was get_ues()
"""Liste des UEs, ordonnée par numero.
def get_ues_stat_dict(self, filter_sport=False, check_apc_ects=True) -> list[dict]:
"""Liste des UEs de toutes les UEs du semestre (tous parcours),
ordonnée par numero.
Si filter_sport, retire les UE de type SPORT.
Résultat: liste de dicts { champs UE U stats moyenne UE }
"""
@ -168,15 +165,24 @@ class NotesTableCompat(ResultatsSemestre):
moy_gen_rangs_by_group[group_id]
ue_rangs_by_group[group_id]
"""
mask_inscr = pd.Series(
[
self.formsemestre.etuds_inscriptions[etudid].etat == scu.INSCRIT
for etudid in self.etud_moy_gen.index
],
dtype=float,
index=self.etud_moy_gen.index,
)
etud_moy_gen_dem_zero = self.etud_moy_gen * mask_inscr
(
self.etud_moy_gen_ranks,
self.etud_moy_gen_ranks_int,
) = moy_sem.comp_ranks_series(self.etud_moy_gen)
) = moy_sem.comp_ranks_series(etud_moy_gen_dem_zero)
ues = self.formsemestre.query_ues()
for ue in ues:
moy_ue = self.etud_moy_ue[ue.id]
self.ue_rangs[ue.id] = (
moy_sem.comp_ranks_series(moy_ue)[0], # juste en chaine
moy_sem.comp_ranks_series(moy_ue * mask_inscr)[0], # juste en chaine
int(moy_ue.count()),
)
# .count() -> nb of non NaN values
@ -196,7 +202,7 @@ class NotesTableCompat(ResultatsSemestre):
)
# list() car pandas veut une sequence pour take()
# Rangs / moyenne générale:
group_moys_gen = self.etud_moy_gen[group_members]
group_moys_gen = etud_moy_gen_dem_zero[group_members]
self.moy_gen_rangs_by_group[group.id] = moy_sem.comp_ranks_series(
group_moys_gen
)
@ -205,7 +211,7 @@ class NotesTableCompat(ResultatsSemestre):
group_moys_ue = self.etud_moy_ue[ue.id][group_members]
self.ue_rangs_by_group.setdefault(ue.id, {})[
group.id
] = moy_sem.comp_ranks_series(group_moys_ue)
] = moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
def get_etud_rang(self, etudid: int) -> str:
"""Le rang (classement) de l'étudiant dans le semestre.
@ -272,10 +278,19 @@ class NotesTableCompat(ResultatsSemestre):
return True
def etud_has_decision(self, etudid):
"""True s'il y a une décision de jury pour cet étudiant"""
return self.get_etud_decision_ues(etudid) or self.get_etud_decision_sem(etudid)
"""True s'il y a une décision de jury pour cet étudiant émanant de ce formsemestre.
prend aussi en compte les autorisations de passage.
Sous-classée en BUT pour les RCUEs et années.
"""
return (
self.get_etud_decisions_ue(etudid)
or self.get_etud_decision_sem(etudid)
or ScolarAutorisationInscription.query.filter_by(
origin_formsemestre_id=self.formsemestre.id, etudid=etudid
).count()
)
def get_etud_decision_ues(self, etudid: int) -> dict:
def get_etud_decisions_ue(self, etudid: int) -> dict:
"""Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu.
Ne tient pas compte des UE capitalisées.
{ ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : "d/m/y", 'ects' : x }
@ -284,16 +299,16 @@ class NotesTableCompat(ResultatsSemestre):
if self.get_etud_etat(etudid) == DEF:
return {}
else:
validations = self.load_validations()
validations = self.get_formsemestre_validations()
return validations.decisions_jury_ues.get(etudid, None)
def get_etud_ects_valides(self, etudid: int, decisions_ues: dict = False) -> 0:
"""Le total des ECTS validés (et enregistrés) par l'étudiant dans ce semestre.
NB: avant jury, rien d'enregistré, donc zéro ECTS.
Optimisation: si decisions_ues est passé, l'utilise, sinon appelle get_etud_decision_ues()
Optimisation: si decisions_ues est passé, l'utilise, sinon appelle get_etud_decisions_ue()
"""
if decisions_ues is False:
decisions_ues = self.get_etud_decision_ues(etudid)
decisions_ues = self.get_etud_decisions_ue(etudid)
if not decisions_ues:
return 0.0
return sum([d.get("ects", 0.0) for d in decisions_ues.values()])
@ -311,7 +326,7 @@ class NotesTableCompat(ResultatsSemestre):
"compense_formsemestre_id": None,
}
else:
validations = self.load_validations()
validations = self.get_formsemestre_validations()
return validations.decisions_jury.get(etudid, None)
def get_etud_mat_moy(self, matiere_id: int, etudid: int) -> str:

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

@ -16,6 +16,7 @@ import flask_login
import app
from app.auth.models import User
import app.scodoc.sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
class ZUser(object):
@ -95,7 +96,7 @@ def permission_required(permission):
return decorator
def permission_required_compat_scodoc7(permission):
def permission_required_compat_scodoc7(permission): # XXX TODO A SUPPRIMER
"""Décorateur pour les fonctions utilisées comme API dans ScoDoc 7
Comme @permission_required mais autorise de passer directement
les informations d'auth en paramètres:
@ -117,6 +118,10 @@ def permission_required_compat_scodoc7(permission):
else:
abort(405) # method not allowed
if user_name and user_password:
# Ancienne API: va être supprimée courant mars 2023
current_app.logger.warning(
"using DEPRECATED ScoDoc7 authentication method !"
)
u = User.query.filter_by(user_name=user_name).first()
if u and u.check_password(user_password):
auth_ok = True
@ -180,19 +185,24 @@ def scodoc7func(func):
else:
arg_names = argspec.args
for arg_name in arg_names: # pour chaque arg de la fonction vue
if arg_name == "REQUEST": # ne devrait plus arriver !
# debug check, TODO remove after tests
raise ValueError("invalid REQUEST parameter !")
else:
# peut produire une KeyError s'il manque un argument attendu:
v = req_args[arg_name]
# try to convert all arguments to INTEGERS
# necessary for db ids and boolean values
try:
v = int(v)
except (ValueError, TypeError):
pass
pos_arg_values.append(v)
# peut produire une KeyError s'il manque un argument attendu:
v = req_args[arg_name]
# try to convert all arguments to INTEGERS
# necessary for db ids and boolean values
try:
v = int(v) if v else v
except (ValueError, TypeError) as exc:
if arg_name in {
"etudid",
"formation_id",
"formsemestre_id",
"module_id",
"moduleimpl_id",
"partition_id",
"ue_id",
}:
raise ScoValueError("page introuvable (id invalide)") from exc
pos_arg_values.append(v)
# current_app.logger.info("pos_arg_values=%s" % pos_arg_values)
# current_app.logger.info("req_args=%s" % req_args)
# Add keyword arguments

View File

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -11,6 +11,8 @@ from flask import current_app, g
from flask_mail import Message
from app import mail
from app.models.departements import Departement
from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_preferences
@ -56,6 +58,7 @@ def send_message(msg: Message):
In mail debug mode, addresses are discarded and all mails are sent to the
specified debugging address.
"""
email_test_mode_address = False
if hasattr(g, "scodoc_dept"):
# on est dans un département, on peut accéder aux préférences
email_test_mode_address = sco_preferences.get_preference(
@ -81,6 +84,35 @@ Adresses d'origine:
+ msg.body
)
current_app.logger.info(
f"""email sent to{' (mode test)' if email_test_mode_address else ''}: {msg.recipients}
from sender {msg.sender}
"""
)
Thread(
target=send_async_email, args=(current_app._get_current_object(), msg)
).start()
def get_from_addr(dept_acronym: str = None):
"""L'adresse "from" à utiliser pour envoyer un mail
Si le departement est spécifié, ou si l'attribut `g.scodoc_dept`existe,
prend le `email_from_addr` des préférences de ce département si ce champ est non vide.
Sinon, utilise le paramètre global `email_from_addr`.
Sinon, la variable de config `SCODOC_MAIL_FROM`.
"""
dept_acronym = dept_acronym or getattr(g, "scodoc_dept", None)
if dept_acronym:
dept = Departement.query.filter_by(acronym=dept_acronym).first()
if dept:
from_addr = (
sco_preferences.get_preference("email_from_addr", dept_id=dept.id) or ""
).strip()
if from_addr:
return from_addr
return (
ScoDocSiteConfig.get("email_from_addr")
or current_app.config["SCODOC_MAIL_FROM"]
or "none"
)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -216,7 +216,7 @@ def send_email_notifications_entreprise(subject: str, entreprise: Entreprise):
txt = "\n".join(txt)
email.send_email(
subject,
sco_preferences.get_preference("email_from_addr"),
email.get_from_addr(),
[EntreprisePreferences.get_email_notifications],
txt,
)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -89,7 +89,7 @@ def index():
visible=True, association=True, siret_provisoire=True
)
return render_template(
"entreprises/entreprises.html",
"entreprises/entreprises.j2",
title="Entreprises",
entreprises=entreprises,
logs=logs,
@ -109,7 +109,7 @@ def logs():
EntrepriseHistorique.date.desc()
).paginate(page=page, per_page=20)
return render_template(
"entreprises/logs.html",
"entreprises/logs.j2",
title="Logs",
logs=logs,
)
@ -134,7 +134,7 @@ def correspondants():
.all()
)
return render_template(
"entreprises/correspondants.html",
"entreprises/correspondants.j2",
title="Correspondants",
correspondants=correspondants,
logs=logs,
@ -149,7 +149,7 @@ def validation():
"""
entreprises = Entreprise.query.filter_by(visible=False).all()
return render_template(
"entreprises/entreprises_validation.html",
"entreprises/entreprises_validation.j2",
title="Validation entreprises",
entreprises=entreprises,
)
@ -167,7 +167,7 @@ def fiche_entreprise_validation(entreprise_id):
description=f"fiche entreprise (validation) {entreprise_id} inconnue"
)
return render_template(
"entreprises/fiche_entreprise_validation.html",
"entreprises/fiche_entreprise_validation.j2",
title="Validation fiche entreprise",
entreprise=entreprise,
)
@ -205,7 +205,7 @@ def validate_entreprise(entreprise_id):
flash("L'entreprise a été validé et ajouté à la liste.")
return redirect(url_for("entreprises.validation"))
return render_template(
"entreprises/form_validate_confirmation.html",
"entreprises/form_validate_confirmation.j2",
title="Validation entreprise",
form=form,
)
@ -242,7 +242,7 @@ def delete_validation_entreprise(entreprise_id):
flash("L'entreprise a été supprimé de la liste des entreprise à valider.")
return redirect(url_for("entreprises.validation"))
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression entreprise",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -282,7 +282,7 @@ def offres_recues():
files.append(file)
offres_recues_with_files.append([envoi_offre, offre, files, correspondant])
return render_template(
"entreprises/offres_recues.html",
"entreprises/offres_recues.j2",
title="Offres reçues",
offres_recues=offres_recues_with_files,
)
@ -321,7 +321,7 @@ def preferences():
form.mail_entreprise.data = EntreprisePreferences.get_email_notifications()
form.check_siret.data = int(EntreprisePreferences.get_check_siret())
return render_template(
"entreprises/preferences.html",
"entreprises/preferences.j2",
title="Préférences",
form=form,
)
@ -357,7 +357,7 @@ def add_entreprise():
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
"entreprises/form_ajout_entreprise.html",
"entreprises/form_ajout_entreprise.j2",
title="Ajout entreprise avec correspondant",
form=form,
)
@ -408,7 +408,7 @@ def add_entreprise():
flash("L'entreprise a été ajouté à la liste pour la validation.")
return redirect(url_for("entreprises.index"))
return render_template(
"entreprises/form_ajout_entreprise.html",
"entreprises/form_ajout_entreprise.j2",
title="Ajout entreprise avec correspondant",
form=form,
)
@ -446,7 +446,7 @@ def fiche_entreprise(entreprise_id):
.all()
)
return render_template(
"entreprises/fiche_entreprise.html",
"entreprises/fiche_entreprise.j2",
title="Fiche entreprise",
entreprise=entreprise,
offres=offres_with_files,
@ -472,7 +472,7 @@ def logs_entreprise(entreprise_id):
.paginate(page=page, per_page=20)
)
return render_template(
"entreprises/logs_entreprise.html",
"entreprises/logs_entreprise.j2",
title="Logs",
logs=logs,
entreprise=entreprise,
@ -490,7 +490,7 @@ def offres_expirees(entreprise_id):
).first_or_404(description=f"fiche entreprise {entreprise_id} inconnue")
offres_with_files = are.get_offres_expirees_with_files(entreprise.offres)
return render_template(
"entreprises/offres_expirees.html",
"entreprises/offres_expirees.j2",
title="Offres expirées",
entreprise=entreprise,
offres_expirees=offres_with_files,
@ -574,7 +574,7 @@ def edit_entreprise(entreprise_id):
form.pays.data = entreprise.pays
form.association.data = entreprise.association
return render_template(
"entreprises/form_modification_entreprise.html",
"entreprises/form_modification_entreprise.j2",
title="Modification entreprise",
form=form,
)
@ -610,7 +610,7 @@ def fiche_entreprise_desactiver(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Désactiver entreprise",
form=form,
info_message="Cliquez sur le bouton Modifier pour confirmer la désactivation",
@ -646,7 +646,7 @@ def fiche_entreprise_activer(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Activer entreprise",
form=form,
info_message="Cliquez sur le bouton Modifier pour confirmer l'activaction",
@ -692,7 +692,7 @@ def add_taxe_apprentissage(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout taxe apprentissage",
form=form,
)
@ -735,7 +735,7 @@ def edit_taxe_apprentissage(entreprise_id, taxe_id):
form.montant.data = taxe.montant
form.notes.data = taxe.notes
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification taxe apprentissage",
form=form,
)
@ -775,7 +775,7 @@ def delete_taxe_apprentissage(entreprise_id, taxe_id):
url_for("entreprises.fiche_entreprise", entreprise_id=taxe.entreprise_id)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supprimer taxe apprentissage",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -845,7 +845,7 @@ def add_offre(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout offre",
form=form,
)
@ -921,7 +921,7 @@ def edit_offre(entreprise_id, offre_id):
form.expiration_date.data = offre.expiration_date
form.depts.data = offre_depts_list
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification offre",
form=form,
)
@ -971,7 +971,7 @@ def delete_offre(entreprise_id, offre_id):
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression offre",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1047,7 +1047,7 @@ def add_site(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout site",
form=form,
)
@ -1098,7 +1098,7 @@ def edit_site(entreprise_id, site_id):
form.ville.data = site.ville
form.pays.data = site.pays
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification site",
form=form,
)
@ -1154,7 +1154,7 @@ def add_correspondant(entreprise_id, site_id):
url_for("entreprises.fiche_entreprise", entreprise_id=site.entreprise_id)
)
return render_template(
"entreprises/form_ajout_correspondants.html",
"entreprises/form_ajout_correspondants.j2",
title="Ajout correspondant",
form=form,
)
@ -1234,7 +1234,7 @@ def edit_correspondant(entreprise_id, site_id, correspondant_id):
form.origine.data = correspondant.origine
form.notes.data = correspondant.notes
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification correspondant",
form=form,
)
@ -1290,7 +1290,7 @@ def delete_correspondant(entreprise_id, site_id, correspondant_id):
)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression correspondant",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1308,7 +1308,7 @@ def contacts(entreprise_id):
).first_or_404(description=f"entreprise {entreprise_id} inconnue")
contacts = EntrepriseContact.query.filter_by(entreprise=entreprise.id).all()
return render_template(
"entreprises/contacts.html",
"entreprises/contacts.j2",
title="Liste des contacts",
contacts=contacts,
entreprise=entreprise,
@ -1365,7 +1365,7 @@ def add_contact(entreprise_id):
db.session.commit()
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise.id))
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout contact",
form=form,
)
@ -1421,7 +1421,7 @@ def edit_contact(entreprise_id, contact_id):
)
form.notes.data = contact.notes
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Modification contact",
form=form,
)
@ -1459,7 +1459,7 @@ def delete_contact(entreprise_id, contact_id):
url_for("entreprises.contacts", entreprise_id=contact.entreprise)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression contact",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1525,7 +1525,7 @@ def add_stage_apprentissage(entreprise_id):
url_for("entreprises.fiche_entreprise", entreprise_id=entreprise.id)
)
return render_template(
"entreprises/form_ajout_stage_apprentissage.html",
"entreprises/form_ajout_stage_apprentissage.j2",
title="Ajout stage / apprentissage",
form=form,
)
@ -1599,7 +1599,7 @@ def edit_stage_apprentissage(entreprise_id, stage_apprentissage_id):
form.date_fin.data = stage_apprentissage.date_fin
form.notes.data = stage_apprentissage.notes
return render_template(
"entreprises/form_ajout_stage_apprentissage.html",
"entreprises/form_ajout_stage_apprentissage.j2",
title="Modification stage / apprentissage",
form=form,
)
@ -1640,7 +1640,7 @@ def delete_stage_apprentissage(entreprise_id, stage_apprentissage_id):
)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Supression stage/apprentissage",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1690,7 +1690,7 @@ def envoyer_offre(entreprise_id, offre_id):
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
)
return render_template(
"entreprises/form_envoi_offre.html",
"entreprises/form_envoi_offre.j2",
title="Envoyer une offre",
form=form,
)
@ -1816,7 +1816,7 @@ def import_donnees():
db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.")
return render_template(
"entreprises/import_donnees.html",
"entreprises/import_donnees.j2",
title="Importation données",
form=form,
)
@ -1845,7 +1845,7 @@ def import_donnees():
db.session.commit()
flash(f"Importation réussie")
return render_template(
"entreprises/import_donnees.html",
"entreprises/import_donnees.j2",
title="Importation données",
form=form,
entreprises_import=entreprises_import,
@ -1853,7 +1853,7 @@ def import_donnees():
correspondants_import=correspondants,
)
return render_template(
"entreprises/import_donnees.html", title="Importation données", form=form
"entreprises/import_donnees.j2", title="Importation données", form=form
)
@ -1927,7 +1927,7 @@ def add_offre_file(entreprise_id, offre_id):
url_for("entreprises.fiche_entreprise", entreprise_id=offre.entreprise_id)
)
return render_template(
"entreprises/form.html",
"entreprises/form.j2",
title="Ajout fichier à une offre",
form=form,
)
@ -1969,7 +1969,7 @@ def delete_offre_file(entreprise_id, offre_id, filedir):
)
)
return render_template(
"entreprises/form_confirmation.html",
"entreprises/form_confirmation.j2",
title="Suppression fichier d'une offre",
form=form,
info_message="Cliquez sur le bouton Supprimer pour confirmer votre supression",
@ -1981,4 +1981,4 @@ def not_found_error_handler(e):
"""
Renvoie une page d'erreur pour l'erreur 404
"""
return render_template("entreprises/error.html", title="Erreur", e=e)
return render_template("entreprises/error.j2", title="Erreur", e=e)

View File

@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire changement formation
"""
from flask_wtf import FlaskForm
from wtforms import RadioField, SubmitField, validators
from app.models import Formation
class FormSemestreChangeFormationForm(FlaskForm):
"Formulaire changement formation d'un formsemestre"
# consrtuit dynamiquement ci-dessous
def gen_formsemestre_change_formation_form(
formations: list[Formation],
) -> FormSemestreChangeFormationForm:
"Create our dynamical form"
# see https://wtforms.readthedocs.io/en/2.3.x/specific_problems/#dynamic-form-composition
class F(FormSemestreChangeFormationForm):
pass
setattr(
F,
"radio_but",
RadioField(
"Label",
choices=[
(formation.id, formation.get_titre_version())
for formation in formations
],
),
)
setattr(F, "submit", SubmitField("Changer la formation"))
setattr(F, "cancel", SubmitField("Annuler"))
return F()

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -35,14 +35,14 @@ from wtforms.fields.simple import StringField
from app.models import SHORT_STR_LEN
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
def _build_code_field(code):
return StringField(
label=code,
default=code,
description=sco_codes_parcours.CODES_EXPL[code],
description=codes_cursus.CODES_EXPL[code],
validators=[
validators.regexp(
r"^[A-Z0-9_]*$",
@ -63,6 +63,7 @@ class CodesDecisionsForm(FlaskForm):
ABL = _build_code_field("ABL")
ADC = _build_code_field("ADC")
ADJ = _build_code_field("ADJ")
ADJR = _build_code_field("ADJR")
ADM = _build_code_field("ADM")
AJ = _build_code_field("AJ")
ATB = _build_code_field("ATB")

View File

@ -0,0 +1,78 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire configuration CAS
"""
from flask_wtf import FlaskForm
from wtforms import BooleanField, SubmitField
from wtforms.fields.simple import FileField, StringField
class ConfigCASForm(FlaskForm):
"Formulaire paramétrage CAS"
cas_enable = BooleanField("Activer le CAS")
cas_force = BooleanField(
"Forcer l'utilisation de CAS (tous les utilisateurs seront redirigés vers le CAS)"
)
cas_server = StringField(
label="URL du serveur CAS",
description="""url complète. Commence en général par <tt>https://</tt>.""",
)
cas_login_route = StringField(
label="Route du login CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas</tt> (si commence par <tt>/</tt>, part de la racine)""",
default="/cas",
)
cas_logout_route = StringField(
label="Route du logout CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas/logout</tt>""",
default="/cas/logout",
)
cas_validate_route = StringField(
label="Route de validation CAS",
description="""ajouté à l'URL du serveur: exemple <tt>/cas/serviceValidate</tt>""",
default="/cas/serviceValidate",
)
cas_attribute_id = StringField(
label="Attribut CAS utilisé comme id (laissez vide pour prendre l'id par défaut)",
description="""Le champs CAS qui sera considéré comme l'id unique des
comptes utilisateurs.""",
)
cas_ssl_verify = BooleanField("Vérification du certificat SSL")
cas_ssl_certificate_file = FileField(
label="Certificat (PEM)",
description="""Le contenu du certificat PEM
(commence typiquement par <tt>-----BEGIN CERTIFICATE-----</tt>)""",
)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -148,6 +148,9 @@ class AddLogoForm(FlaskForm):
kwargs["meta"] = {"csrf": False}
super().__init__(*args, **kwargs)
def id(self):
return f"id=add_{self.dept_key.data}"
def validate_name(self, name):
dept_id = dept_key_to_id(self.dept_key.data)
if dept_id == GLOBAL:
@ -171,7 +174,7 @@ class AddLogoForm(FlaskForm):
class LogoForm(FlaskForm):
"""Embed both presentation of a logo (cf. template file configuration.html)
"""Embed both presentation of a logo (cf. template file configuration.j2)
and all its data and UI action (change, delete)"""
dept_key = HiddenField()
@ -227,6 +230,10 @@ class LogoForm(FlaskForm):
self.description = "Se substitue au footer défini au niveau global"
self.titre = "Logo pied de page"
def id(self):
idstring = f"{self.dept_key.data}_{self.logo_id.data}"
return f"id={idstring}"
def select_action(self):
from app.scodoc.sco_config_actions import LogoRename
from app.scodoc.sco_config_actions import LogoUpdate
@ -258,6 +265,9 @@ class DeptForm(FlaskForm):
kwargs["meta"] = {"csrf": False}
super().__init__(*args, **kwargs)
def id(self):
return f"id=DEPT_{self.dept_key.data}"
def is_local(self):
if self.dept_key.data == GLOBAL:
return None
@ -434,7 +444,7 @@ def config_logos():
scu.flash_errors(form)
return render_template(
"config_logos.html",
"config_logos.j2",
scodoc_dept=None,
title="Configuration ScoDoc",
form=form,

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -31,8 +31,8 @@ Formulaires configuration Exports Apogée (codes)
from flask import flash, url_for, redirect, request, render_template
from flask_wtf import FlaskForm
from wtforms import BooleanField, SelectField, SubmitField
from wtforms import BooleanField, SelectField, StringField, SubmitField
from wtforms.validators import Email, Optional
import app
from app.models import ScoDocSiteConfig
import app.scodoc.sco_utils as scu
@ -54,6 +54,28 @@ class BonusConfigurationForm(FlaskForm):
class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée"
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
month_debut_annee_scolaire = SelectField(
label="Mois de début des années scolaires",
description="""Date pivot. En France métropolitaine, août.
S'applique à tous les départements.""",
choices=[
(i, name.capitalize()) for (i, name) in enumerate(scu.MONTH_NAMES, start=1)
],
)
month_debut_periode2 = SelectField(
label="Mois de début deuxième période de l'année",
description="""Date pivot. En France métropolitaine, décembre.
S'applique à tous les départements.""",
choices=[
(i, name.capitalize()) for (i, name) in enumerate(scu.MONTH_NAMES, start=1)
],
)
email_from_addr = StringField(
label="Adresse source des mails",
description="""adresse email source (from) des mails émis par ScoDoc.
Attention: si ce champ peut aussi être défini dans chaque département.""",
validators=[Optional(), Email()],
)
submit_scodoc = SubmitField("Valider")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -67,7 +89,12 @@ def configuration():
}
)
form_scodoc = ScoDocConfigurationForm(
data={"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled()}
data={
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
}
)
if request.method == "POST" and (
form_bonus.cancel_bonus.data or form_scodoc.cancel_scodoc.data
@ -94,10 +121,28 @@ def configuration():
"Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
)
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
int(form_scodoc.data["month_debut_annee_scolaire"])
):
flash(
f"""Début des années scolaires fixé au mois de {
scu.MONTH_NAMES[ScoDocSiteConfig.get_month_debut_annee_scolaire()-1]
}"""
)
if ScoDocSiteConfig.set_month_debut_periode2(
int(form_scodoc.data["month_debut_periode2"])
):
flash(
f"""Début des années scolaires fixé au mois de {
scu.MONTH_NAMES[ScoDocSiteConfig.get_month_debut_periode2()-1]
}"""
)
if ScoDocSiteConfig.set("email_from_addr", form_scodoc.data["email_from_addr"]):
flash("Adresse email origine enregistrée")
return redirect(url_for("scodoc.index"))
return render_template(
"configuration.html",
"configuration.j2",
form_bonus=form_bonus,
form_scodoc=form_scodoc,
scu=scu,

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -9,6 +9,7 @@ CODE_STR_LEN = 16 # chaine pour les codes
SHORT_STR_LEN = 32 # courtes chaine, eg acronymes
APO_CODE_STR_LEN = 512 # nb de car max d'un code Apogée (il peut y en avoir plusieurs)
GROUPNAME_STR_LEN = 64
USERNAME_STR_LEN = 64
convention = {
"ix": "ix_%(column_0_label)s",
@ -36,7 +37,7 @@ from app.models.etudiants import (
from app.models.events import Scolog, ScolarNews
from app.models.formations import Formation, Matiere
from app.models.modules import Module, ModuleUECoef, NotesTag, notes_modules_tags
from app.models.ues import UniteEns
from app.models.ues import DispenseUE, UniteEns
from app.models.formsemestre import (
FormSemestre,
FormSemestreEtape,
@ -72,12 +73,15 @@ from app.models.validations import (
from app.models.preferences import ScoPreference
from app.models.but_refcomp import (
ApcReferentielCompetences,
ApcCompetence,
ApcSituationPro,
ApcAppCritique,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcReferentielCompetences,
ApcSituationPro,
)
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.config import ScoDocSiteConfig
from app.models.assiduites import Assiduite, Justificatif

View File

@ -15,8 +15,10 @@ class Absence(db.Model):
db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True
)
jour = db.Column(db.Date)
# absent / justifié / absent+ justifié
estabs = db.Column(db.Boolean())
estjust = db.Column(db.Boolean())
matin = db.Column(db.Boolean())
# motif de l'absence:
description = db.Column(db.Text())
@ -24,10 +26,8 @@ class Absence(db.Model):
# moduleimpid concerne (optionnel):
moduleimpl_id = db.Column(
db.Integer,
db.ForeignKey("notes_moduleimpl.id"),
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
)
# XXX TODO: contrainte ajoutée: vérifier suppression du module
# (mettre à NULL sans supprimer)
def to_dict(self):
data = {

336
app/models/assiduites.py Normal file
View File

@ -0,0 +1,336 @@
# -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs)
"""
from datetime import datetime
from app import db
from app.models import ModuleImpl
from app.models.etudiants import Identite
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
localize_datetime,
)
class Assiduite(db.Model):
"""
Représente une assiduité:
- une plage horaire lié à un état et un étudiant
- un module si spécifiée
- une description si spécifiée
"""
__tablename__ = "assiduites"
id = db.Column(db.Integer, primary_key=True, nullable=False)
assiduite_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
moduleimpl_id = db.Column(
db.Integer,
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(db.Integer, nullable=False)
desc = db.Column(db.Text)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
)
est_just = db.Column(db.Boolean, server_default="false", nullable=False)
def to_dict(self, format_api=True) -> dict:
"""Retourne la représentation json de l'assiduité"""
etat = self.etat
if format_api:
etat = EtatAssiduite.inverse().get(self.etat).name
data = {
"assiduite_id": self.id,
"etudid": self.etudid,
"moduleimpl_id": self.moduleimpl_id,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"desc": self.desc,
"entry_date": self.entry_date,
"user_id": self.user_id,
"est_just": self.est_just,
}
return data
@classmethod
def create_assiduite(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl: ModuleImpl = None,
description: str = None,
entry_date: datetime = None,
user_id: int = None,
est_just: bool = False,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
assiduites: list[Assiduite] = etud.assiduites
if is_period_conflicting(date_debut, date_fin, assiduites, Assiduite):
raise ScoValueError(
"Duplication des assiduités (la période rentrée rentre en conflit avec une assiduité enregistrée)"
)
if moduleimpl is not None:
# Vérification de l'existence du module pour l'étudiant
if moduleimpl.est_inscrit(etud):
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
moduleimpl_id=moduleimpl.id,
desc=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
)
else:
raise ScoValueError("L'étudiant n'est pas inscrit au moduleimpl")
else:
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
desc=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
)
return nouv_assiduite
@classmethod
def fast_create_assiduite(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl_id: int = None,
description: str = None,
entry_date: datetime = None,
est_just: bool = False,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
nouv_assiduite = Assiduite(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
moduleimpl_id=moduleimpl_id,
desc=description,
entry_date=entry_date,
est_just=est_just,
)
return nouv_assiduite
class Justificatif(db.Model):
"""
Représente un justificatif:
- une plage horaire lié à un état et un étudiant
- une raison si spécifiée
- un fichier si spécifié
"""
__tablename__ = "justificatifs"
id = db.Column(db.Integer, primary_key=True)
justif_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(
db.Integer,
nullable=False,
)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
raison = db.Column(db.Text())
# Archive_id -> sco_archives_justificatifs.py
fichier = db.Column(db.Text())
def to_dict(self, format_api: bool = False) -> dict:
"""transformation de l'objet en dictionnaire sérialisable"""
etat = self.etat
if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name
data = {
"justif_id": self.justif_id,
"etudid": self.etudid,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"raison": self.raison,
"fichier": self.fichier,
"entry_date": self.entry_date,
"user_id": self.user_id,
}
return data
@classmethod
def create_justificatif(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
user_id: int = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
raison=raison,
entry_date=entry_date,
user_id=user_id,
)
return nouv_justificatif
@classmethod
def fast_create_justificatif(
cls,
etudid: int,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudid=etudid,
raison=raison,
entry_date=entry_date,
)
return nouv_justificatif
def is_period_conflicting(
date_debut: datetime,
date_fin: datetime,
collection: list[Assiduite or Justificatif],
collection_cls: Assiduite or Justificatif,
) -> bool:
"""
Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes
"""
date_debut = localize_datetime(date_debut)
date_fin = localize_datetime(date_fin)
if (
collection.filter_by(date_debut=date_debut, date_fin=date_fin).first()
is not None
):
return True
count: int = collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
).count()
return count > 0
def compute_assiduites_justified(
justificatifs: Justificatif = Justificatif, reset: bool = False
) -> list[int]:
"""Calcule et modifie les champs "est_just" de chaque assiduité lié à l'étud
retourne la liste des assiduite_id justifiées
Si reset alors : met à false toutes les assiduités non justifiées par les justificatifs donnés
"""
list_assiduites_id: set[int] = set()
for justi in justificatifs:
assiduites: Assiduite = (
Assiduite.query.join(Justificatif, Assiduite.etudid == Justificatif.etudid)
.filter(Assiduite.etat != EtatAssiduite.PRESENT)
.filter(
Assiduite.date_debut <= justi.date_fin,
Assiduite.date_fin >= justi.date_debut,
)
)
for assi in assiduites:
assi.est_just = True
list_assiduites_id.add(assi.id)
db.session.add(assi)
if reset:
un_justified: Assiduite = (
Assiduite.query.filter(Assiduite.id.not_in(list_assiduites_id))
.join(Justificatif, Assiduite.etudid == Justificatif.etudid)
.filter(Assiduite.etat != EtatAssiduite.PRESENT)
)
for assi in un_justified:
assi.est_just = False
db.session.add(assi)
db.session.commit()
return

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
@ -14,7 +14,7 @@ import sqlalchemy
from app import db
from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
@ -53,14 +53,18 @@ class XMLModel:
class ApcReferentielCompetences(db.Model, XMLModel):
"Référentiel de compétence d'une spécialité"
id = db.Column(db.Integer, primary_key=True)
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
annexe = db.Column(db.Text())
specialite = db.Column(db.Text())
specialite_long = db.Column(db.Text())
type_titre = db.Column(db.Text())
type_structure = db.Column(db.Text())
dept_id = db.Column(
db.Integer, db.ForeignKey("departement.id", ondelete="CASCADE"), index=True
)
annexe = db.Column(db.Text()) # '1', '22', ...
specialite = db.Column(db.Text()) # 'CJ', 'RT', 'INFO', ...
specialite_long = db.Column(
db.Text()
) # 'Carrière Juridique', 'Réseaux et télécommunications', ...
type_titre = db.Column(db.Text()) # 'B.U.T.'
type_structure = db.Column(db.Text()) # 'type1', 'type2', ...
type_departement = db.Column(db.Text()) # "secondaire", "tertiaire"
version_orebut = db.Column(db.Text())
version_orebut = db.Column(db.Text()) # '2021-12-11 00:00:00'
_xml_attribs = { # Orébut xml attrib : attribute
"type": "type_titre",
"version": "version_orebut",
@ -86,9 +90,16 @@ class ApcReferentielCompetences(db.Model, XMLModel):
def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
def to_dict(self):
def get_version(self) -> str:
"La version, normalement sous forme de date iso yyy-mm-dd"
if not self.version_orebut:
return ""
return self.version_orebut.split()[0]
def to_dict(self, parcours: list["ApcParcours"] = None, with_app_critiques=True):
"""Représentation complète du ref. de comp.
comme un dict.
Si parcours est une liste de parcours, restreint l'export aux parcours listés.
"""
return {
"dept_id": self.dept_id,
@ -103,29 +114,45 @@ class ApcReferentielCompetences(db.Model, XMLModel):
if self.scodoc_date_loaded
else "",
"scodoc_orig_filename": self.scodoc_orig_filename,
"competences": {x.titre: x.to_dict() for x in self.competences},
"parcours": {x.code: x.to_dict() for x in self.parcours},
"competences": {
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
for x in self.competences
},
"parcours": {
x.code: x.to_dict()
for x in (self.parcours if parcours is None else parcours)
},
}
def get_niveaux_by_parcours(self, annee) -> dict:
def get_niveaux_by_parcours(
self, annee: int, parcour: "ApcParcours" = None
) -> tuple[list["ApcParcours"], dict]:
"""
Construit la liste des niveaux de compétences pour chaque parcours
de ce référentiel.
de ce référentiel, ou seulement pour le parcours donné.
Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun.
Le tronc commun n'est pas identifié comme tel dans les référentiels Orébut:
on cherche les niveaux qui sont présents dans tous les parcours et les range sous
la clé "TC" (toujours présente mais éventuellement liste vide si pas de tronc commun).
résultat:
{
"TC" : [ ApcNiveau ],
parcour.id : [ ApcNiveau ]
}
Résultat: couple
( [ ApcParcours ],
{
"TC" : [ ApcNiveau ],
parcour.id : [ ApcNiveau ]
}
)
"""
parcours = self.parcours.order_by(ApcParcours.numero).all()
parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
if parcour is None:
parcours = parcours_ref
else:
parcours = [parcour]
niveaux_by_parcours = {
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
for parcour in parcours
for parcour in parcours_ref
}
# Cherche tronc commun
if niveaux_by_parcours:
@ -154,14 +181,37 @@ class ApcReferentielCompetences(db.Model, XMLModel):
niveau for niveau in niveaux_parcours_1 if niveau.id in niveaux_ids_tc
]
niveaux_by_parcours_no_tc["TC"] = niveaux_tc
return niveaux_by_parcours_no_tc
return parcours, niveaux_by_parcours_no_tc
def get_competences_tronc_commun(self) -> list["ApcCompetence"]:
"""Liste des compétences communes à tous les parcours du référentiel."""
parcours = self.parcours.all()
if not parcours:
return []
ids = set.intersection(
*[
{competence.id for competence in parcour.query_competences()}
for parcour in parcours
]
)
return sorted(
[
competence
for competence in parcours[0].query_competences()
if competence.id in ids
],
key=lambda c: c.numero or 0,
)
class ApcCompetence(db.Model, XMLModel):
"Compétence"
id = db.Column(db.Integer, primary_key=True)
referentiel_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
db.Integer,
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
nullable=False,
)
# les compétences dans Orébut sont identifiées par leur id unique
# (mais id_orebut n'est pas unique car le même ref. pourra être chargé dans plusieurs depts)
@ -197,7 +247,7 @@ class ApcCompetence(db.Model, XMLModel):
def __repr__(self):
return f"<ApcCompetence {self.id} {self.titre!r}>"
def to_dict(self):
def to_dict(self, with_app_critiques=True):
"repr dict recursive sur situations, composantes, niveaux"
return {
"id_orebut": self.id_orebut,
@ -209,7 +259,10 @@ class ApcCompetence(db.Model, XMLModel):
"composantes_essentielles": [
x.to_dict() for x in self.composantes_essentielles
],
"niveaux": {x.annee: x.to_dict() for x in self.niveaux},
"niveaux": {
x.annee: x.to_dict(with_app_critiques=with_app_critiques)
for x in self.niveaux
},
}
def to_dict_bul(self) -> dict:
@ -227,7 +280,9 @@ class ApcSituationPro(db.Model, XMLModel):
"Situation professionnelle"
id = db.Column(db.Integer, primary_key=True)
competence_id = db.Column(
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
db.Integer,
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
nullable=False,
)
libelle = db.Column(db.Text(), nullable=False)
# aucun attribut (le text devient le libellé)
@ -239,7 +294,9 @@ class ApcComposanteEssentielle(db.Model, XMLModel):
"Composante essentielle"
id = db.Column(db.Integer, primary_key=True)
competence_id = db.Column(
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
db.Integer,
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
nullable=False,
)
libelle = db.Column(db.Text(), nullable=False)
@ -257,7 +314,9 @@ class ApcNiveau(db.Model, XMLModel):
id = db.Column(db.Integer, primary_key=True)
competence_id = db.Column(
db.Integer, db.ForeignKey("apc_competence.id"), nullable=False
db.Integer,
db.ForeignKey("apc_competence.id", ondelete="CASCADE"),
nullable=False,
)
libelle = db.Column(db.Text(), nullable=False)
annee = db.Column(db.Text(), nullable=False) # "BUT1", "BUT2", "BUT3"
@ -275,13 +334,15 @@ class ApcNiveau(db.Model, XMLModel):
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
self.annee!r} {self.competence!r}>"""
def to_dict(self):
"as a dict, recursif sur les AC"
def to_dict(self, with_app_critiques=True):
"as a dict, recursif (ou non) sur les AC"
return {
"libelle": self.libelle,
"annee": self.annee,
"ordre": self.ordre,
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques},
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {},
}
def to_dict_bul(self):
@ -306,9 +367,8 @@ class ApcNiveau(db.Model, XMLModel):
if annee not in {1, 2, 3}:
raise ValueError("annee invalide pour un parcours BUT")
if referentiel_competence is None:
raise ScoValueError(
"Pas de référentiel de compétences associé à la formation !"
)
raise ScoNoReferentielCompetences()
annee_formation = f"BUT{annee}"
if parcour is None:
return ApcNiveau.query.filter(
@ -337,7 +397,7 @@ app_critiques_modules = db.Table(
),
db.Column(
"app_crit_id",
db.ForeignKey("apc_app_critique.id"),
db.ForeignKey("apc_app_critique.id", ondelete="CASCADE"),
primary_key=True,
),
)
@ -346,7 +406,9 @@ app_critiques_modules = db.Table(
class ApcAppCritique(db.Model, XMLModel):
"Apprentissage Critique BUT"
id = db.Column(db.Integer, primary_key=True)
niveau_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"), nullable=False)
niveau_id = db.Column(
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="CASCADE"), nullable=False
)
code = db.Column(db.Text(), nullable=False, index=True)
libelle = db.Column(db.Text())
@ -376,7 +438,9 @@ class ApcAppCritique(db.Model, XMLModel):
query = query.filter(ApcNiveau.competence == competence)
return query
def to_dict(self) -> dict:
def to_dict(self, with_code=False) -> dict:
if with_code:
return {"code": self.code, "libelle": self.libelle}
return {"libelle": self.libelle}
def get_label(self) -> str:
@ -393,7 +457,10 @@ class ApcAppCritique(db.Model, XMLModel):
parcours_modules = db.Table(
"parcours_modules",
db.Column(
"parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
"parcours_id",
db.Integer,
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
primary_key=True,
),
db.Column(
"module_id",
@ -407,7 +474,10 @@ parcours_modules = db.Table(
parcours_formsemestre = db.Table(
"parcours_formsemestre",
db.Column(
"parcours_id", db.Integer, db.ForeignKey("apc_parcours.id"), primary_key=True
"parcours_id",
db.Integer,
db.ForeignKey("apc_parcours.id", ondelete="CASCADE"),
primary_key=True,
),
db.Column(
"formsemestre_id",
@ -420,9 +490,12 @@ parcours_formsemestre = db.Table(
class ApcParcours(db.Model, XMLModel):
"Un parcours BUT"
id = db.Column(db.Integer, primary_key=True)
referentiel_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False
db.Integer,
db.ForeignKey("apc_referentiel_competences.id", ondelete="CASCADE"),
nullable=False,
)
numero = db.Column(db.Integer) # ordre de présentation
code = db.Column(db.Text(), nullable=False)
@ -433,6 +506,7 @@ class ApcParcours(db.Model, XMLModel):
lazy="dynamic",
cascade="all, delete-orphan",
)
ues = db.relationship("UniteEns", back_populates="parcour")
def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>"
@ -450,11 +524,19 @@ class ApcParcours(db.Model, XMLModel):
d["annees"] = {x.ordre: x.to_dict() for x in self.annees}
return d
def query_competences(self) -> flask_sqlalchemy.BaseQuery:
"Les compétences associées à ce parcours"
return (
ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
.filter_by(parcours_id=self.id)
.order_by(ApcCompetence.numero)
)
class ApcAnneeParcours(db.Model, XMLModel):
id = db.Column(db.Integer, primary_key=True)
parcours_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id"), nullable=False
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="CASCADE"), nullable=False
)
ordre = db.Column(db.Integer)
"numéro de l'année: 1, 2, 3"

View File

@ -2,20 +2,18 @@
"""Décisions de jury (validations) des RCUE et années du BUT
"""
import flask_sqlalchemy
from sqlalchemy.sql import text
from typing import Union
from app import db
import flask_sqlalchemy
from app import db
from app.models import CODE_STR_LEN
from app.models.but_refcomp import ApcNiveau
from app.models.etudiants import Identite
from app.models.ues import UniteEns
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_codes_parcours as sco_codes
from app.models.ues import UniteEns
from app.scodoc import codes_cursus as sco_codes
from app.scodoc import sco_utils as scu
@ -24,7 +22,7 @@ class ApcValidationRCUE(db.Model):
aka "regroupements cohérents d'UE" dans le jargon BUT.
le formsemestre est celui du semestre PAIR du niveau de compétence
Le formsemestre est celui du semestre PAIR du niveau de compétence
"""
__tablename__ = "apc_validation_rcue"
@ -48,7 +46,9 @@ class ApcValidationRCUE(db.Model):
ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False)
# optionnel, le parcours dans lequel se trouve la compétence:
parcours_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), nullable=True)
parcours_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="set null"), nullable=True
)
date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True)
@ -59,13 +59,36 @@ class ApcValidationRCUE(db.Model):
parcour = db.relationship("ApcParcours")
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.etud} {self.ue1}/{self.ue2}:{self.code!r}>"
return f"""<{self.__class__.__name__} {self.id} {self.etud} {
self.ue1}/{self.ue2}:{self.code!r}>"""
def __str__(self):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
def to_html(self) -> str:
"description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b>
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}</em>"""
def annee(self) -> str:
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
niveau = self.niveau()
return niveau.annee if niveau else None
def niveau(self) -> ApcNiveau:
"""Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE
return self.ue2.niveau_competence
def to_dict(self):
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
return d
def to_dict_bul(self) -> dict:
"Export dict pour bulletins: le code et le niveau de compétence"
niveau = self.niveau()
@ -74,34 +97,41 @@ class ApcValidationRCUE(db.Model):
"niveau": None if niveau is None else niveau.to_dict_bul(),
}
def to_dict_codes(self) -> dict:
"Dict avec seulement les ids et la date - pour cache table jury"
return {
"id": self.id,
"code": self.code,
"date": self.date,
"etudid": self.etudid,
"niveau_id": self.niveau().id,
"formsemestre_id": self.formsemestre_id,
}
# Attention: ce n'est pas un modèle mais une classe ordinaire:
class RegroupementCoherentUE:
"""Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs
de la même année (BUT1,2,3) liées au *même niveau de compétence*.
La moyenne (10/20) au RCU déclenche la compensation des UE.
La moyenne (10/20) au RCUE déclenche la compensation des UE.
"""
def __init__(
self,
etud: Identite,
formsemestre_1: FormSemestre,
ue_1: UniteEns,
dec_ue_1: "DecisionsProposeesUE",
formsemestre_2: FormSemestre,
ue_2: UniteEns,
dec_ue_2: "DecisionsProposeesUE",
inscription_etat: str,
):
from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
ue_1 = dec_ue_1.ue
ue_2 = dec_ue_2.ue
# Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
(ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
(
ue_2,
formsemestre_2,
),
(ue_2, formsemestre_2),
(ue_1, formsemestre_1),
)
assert formsemestre_1.semestre_id % 2 == 1
@ -121,21 +151,12 @@ class RegroupementCoherentUE:
self.moy_ue_1 = self.moy_ue_2 = "-"
self.moy_ue_1_val = self.moy_ue_2_val = 0.0
return
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1)
if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]:
self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id]
self.moy_ue_1_val = self.moy_ue_1 # toujours float, peut être NaN
else:
self.moy_ue_1 = None
self.moy_ue_1_val = 0.0
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_2)
if ue_2.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_2.id]:
self.moy_ue_2 = res.etud_moy_ue[ue_2.id][etud.id]
self.moy_ue_2_val = self.moy_ue_2
else:
self.moy_ue_2 = None
self.moy_ue_2_val = 0.0
# Calcul de la moyenne au RCUE
self.moy_ue_1 = dec_ue_1.moy_ue_with_cap
self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
self.moy_ue_2 = dec_ue_2.moy_ue_with_cap
self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
# Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées)
if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None):
# Moyenne RCUE (les pondérations par défaut sont 1.)
self.moy_rcue = (
@ -145,7 +166,14 @@ class RegroupementCoherentUE:
self.moy_rcue = None
def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.ue_1.acronyme}({self.moy_ue_1}) {self.ue_2.acronyme}({self.moy_ue_2})>"
return f"""<{self.__class__.__name__} {
self.ue_1.acronyme}({self.moy_ue_1}) {
self.ue_2.acronyme}({self.moy_ue_2})>"""
def __str__(self) -> str:
return f"""RCUE {
self.ue_1.acronyme}({self.moy_ue_1}) + {
self.ue_2.acronyme}({self.moy_ue_2})"""
def query_validations(
self,
@ -177,8 +205,9 @@ class RegroupementCoherentUE:
return self.query_validations().count() > 0
def est_compensable(self):
"""Vrai si ce RCUE est validable par compensation
c'est à dire que sa moyenne est > 10 avec une UE < 10
"""Vrai si ce RCUE est validable (uniquement) par compensation
c'est à dire que sa moyenne est > 10 avec une UE < 10.
Note: si ADM, est_compensable est faux.
"""
return (
(self.moy_rcue is not None)
@ -214,62 +243,62 @@ class RegroupementCoherentUE:
# unused
def find_rcues(
formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
) -> list[RegroupementCoherentUE]:
"""Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
ce semestre pour cette UE.
# def find_rcues(
# formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
# ) -> list[RegroupementCoherentUE]:
# """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
# ce semestre pour cette UE.
Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
# Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit.
# En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
Résultat: la liste peut être vide.
"""
if (ue.niveau_competence is None) or (ue.semestre_idx is None):
return []
# Résultat: la liste peut être vide.
# """
# if (ue.niveau_competence is None) or (ue.semestre_idx is None):
# return []
if ue.semestre_idx % 2: # S1, S3, S5
other_semestre_idx = ue.semestre_idx + 1
else:
other_semestre_idx = ue.semestre_idx - 1
# if ue.semestre_idx % 2: # S1, S3, S5
# other_semestre_idx = ue.semestre_idx + 1
# else:
# other_semestre_idx = ue.semestre_idx - 1
cursor = db.session.execute(
text(
"""SELECT
ue.id, formsemestre.id
FROM
notes_ue ue,
notes_formsemestre_inscription inscr,
notes_formsemestre formsemestre
# cursor = db.session.execute(
# text(
# """SELECT
# ue.id, formsemestre.id
# FROM
# notes_ue ue,
# notes_formsemestre_inscription inscr,
# notes_formsemestre formsemestre
WHERE
inscr.etudid = :etudid
AND inscr.formsemestre_id = formsemestre.id
AND formsemestre.semestre_id = :other_semestre_idx
AND ue.formation_id = formsemestre.formation_id
AND ue.niveau_competence_id = :ue_niveau_competence_id
AND ue.semestre_idx = :other_semestre_idx
"""
),
{
"etudid": etud.id,
"other_semestre_idx": other_semestre_idx,
"ue_niveau_competence_id": ue.niveau_competence_id,
},
)
rcues = []
for ue_id, formsemestre_id in cursor:
other_ue = UniteEns.query.get(ue_id)
other_formsemestre = FormSemestre.query.get(formsemestre_id)
rcues.append(
RegroupementCoherentUE(
etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat
)
)
# safety check: 1 seul niveau de comp. concerné:
assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
return rcues
# WHERE
# inscr.etudid = :etudid
# AND inscr.formsemestre_id = formsemestre.id
# AND formsemestre.semestre_id = :other_semestre_idx
# AND ue.formation_id = formsemestre.formation_id
# AND ue.niveau_competence_id = :ue_niveau_competence_id
# AND ue.semestre_idx = :other_semestre_idx
# """
# ),
# {
# "etudid": etud.id,
# "other_semestre_idx": other_semestre_idx,
# "ue_niveau_competence_id": ue.niveau_competence_id,
# },
# )
# rcues = []
# for ue_id, formsemestre_id in cursor:
# other_ue = UniteEns.query.get(ue_id)
# other_formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# rcues.append(
# RegroupementCoherentUE(
# etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat
# )
# )
# # safety check: 1 seul niveau de comp. concerné:
# assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
# return rcues
class ApcValidationAnnee(db.Model):
@ -277,7 +306,7 @@ class ApcValidationAnnee(db.Model):
__tablename__ = "apc_validation_annee"
# Assure unicité de la décision:
__table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire"),)
__table_args__ = (db.UniqueConstraint("etudid", "annee_scolaire", "ordre"),)
id = db.Column(db.Integer, primary_key=True)
etudid = db.Column(
db.Integer,
@ -299,7 +328,11 @@ class ApcValidationAnnee(db.Model):
formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees")
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"
return f"""<{self.__class__.__name__} {self.id} {self.etud
} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>"""
def __str__(self):
return f"""décision sur année BUT{self.ordre} {self.annee_scolaire} : {self.code}"""
def to_dict_bul(self) -> dict:
"dict pour bulletins"
@ -333,7 +366,8 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
titres_rcues.append(f"""pas de compétence: code {dec_rcue["code"]}""")
else:
titres_rcues.append(
f"""{niveau["competence"]["titre"]}&nbsp;{niveau["ordre"]}:&nbsp;{dec_rcue["code"]}"""
f"""{niveau["competence"]["titre"]}&nbsp;{niveau["ordre"]}:&nbsp;{
dec_rcue["code"]}"""
)
decisions["descr_decisions_rcue"] = ", ".join(titres_rcues)
decisions["descr_decisions_niveaux"] = (

View File

@ -4,15 +4,16 @@
"""
from flask import flash
from app import db, log
from app import current_app, db, log
from app.comp import bonus_spo
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import (
from app.scodoc.codes_cursus import (
ABAN,
ABL,
ADC,
ADJ,
ADJR,
ADM,
AJ,
ATB,
@ -34,6 +35,7 @@ CODES_SCODOC_TO_APO = {
ABL: "ABL",
ADC: "ADMC",
ADJ: "ADM",
ADJR: "ADM",
ADM: "ADM",
AJ: "AJ",
ATB: "AJAC",
@ -83,6 +85,15 @@ class ScoDocSiteConfig(db.Model):
"INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
"enable_entreprises": bool,
"month_debut_annee_scolaire": int,
"month_debut_periode2": int,
# CAS
"cas_enable": bool,
"cas_server": str,
"cas_login_route": str,
"cas_logout_route": str,
"cas_validate_route": str,
"cas_attribute_id": str,
}
def __init__(self, name, value):
@ -166,7 +177,7 @@ class ScoDocSiteConfig(db.Model):
(starting with empty string to represent "no bonus function").
"""
d = bonus_spo.get_bonus_class_dict()
class_list = [(name, d[name].displayed_name) for name in d.keys()]
class_list = [(name, d[name].displayed_name) for name in d]
class_list.sort(key=lambda x: x[1].replace(" du ", " de "))
return [("", "")] + class_list
@ -200,13 +211,17 @@ class ScoDocSiteConfig(db.Model):
db.session.add(cfg)
db.session.commit()
@classmethod
def is_cas_enabled(cls) -> bool:
"""True si on utilise le CAS"""
cfg = ScoDocSiteConfig.query.filter_by(name="cas_enable").first()
return cfg is not None and cfg.value
@classmethod
def is_entreprises_enabled(cls) -> bool:
"""True si on doit activer le module entreprise"""
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
if (cfg is None) or not cfg.value:
return False
return True
return cfg is not None and cfg.value
@classmethod
def enable_entreprises(cls, enabled=True) -> bool:
@ -223,3 +238,99 @@ class ScoDocSiteConfig(db.Model):
db.session.commit()
return True
return False
@classmethod
def get(cls, name: str, default: str = "") -> str:
"Get configuration param; empty string or specified default if unset"
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None:
return default
return cfg.value or ""
@classmethod
def set(cls, name: str, value: str) -> bool:
"Set parameter, returns True if change. Commit session."
value_str = str(value or "")
if (cls.get(name) or "") != value_str:
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=value_str)
else:
cfg.value = str(value or "")
current_app.logger.info(
f"""ScoDocSiteConfig: recording {cfg.name}='{cfg.value[:32]}...'"""
)
db.session.add(cfg)
db.session.commit()
return True
return False
@classmethod
def _get_int_field(cls, name: str, default=None) -> int:
"""Valeur d'un champs integer"""
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if (cfg is None) or cfg.value is None:
return default
return int(cfg.value)
@classmethod
def _set_int_field(
cls,
name: str,
value: int,
default=None,
range_values: tuple = (),
) -> bool:
"""Set champs integer. True si changement."""
if value != cls._get_int_field(name, default=default):
if not isinstance(value, int) or (
range_values and (value < range_values[0]) or (value > range_values[1])
):
raise ValueError("invalid value")
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=str(value))
else:
cfg.value = str(value)
db.session.add(cfg)
db.session.commit()
return True
return False
@classmethod
def get_month_debut_annee_scolaire(cls) -> int:
"""Mois de début de l'année scolaire."""
return cls._get_int_field(
"month_debut_annee_scolaire", scu.MONTH_DEBUT_ANNEE_SCOLAIRE
)
@classmethod
def get_month_debut_periode2(cls) -> int:
"""Mois de début de l'année scolaire."""
return cls._get_int_field("month_debut_periode2", scu.MONTH_DEBUT_PERIODE2)
@classmethod
def set_month_debut_annee_scolaire(
cls, month: int = scu.MONTH_DEBUT_ANNEE_SCOLAIRE
) -> bool:
"""Fixe le mois de début des années scolaires.
True si changement.
"""
if cls._set_int_field(
"month_debut_annee_scolaire", month, scu.MONTH_DEBUT_ANNEE_SCOLAIRE, (1, 12)
):
log(f"set_month_debut_annee_scolaire({month})")
return True
return False
@classmethod
def set_month_debut_periode2(cls, month: int = scu.MONTH_DEBUT_PERIODE2) -> bool:
"""Fixe le mois de début des années scolaires.
True si changement.
"""
if cls._set_int_field(
"month_debut_periode2", month, scu.MONTH_DEBUT_PERIODE2, (1, 12)
):
log(f"set_month_debut_periode2({month})")
return True
return False

View File

@ -2,12 +2,15 @@
"""ScoDoc models : departements
"""
from typing import Any
import re
from app import db
from app.models import SHORT_STR_LEN
from app.models.preferences import ScoPreference
from app.scodoc.sco_exceptions import ScoValueError
VALID_DEPT_EXP = re.compile(r"^[\w@\\\-\.]+$")
class Departement(db.Model):
"""Un département ScoDoc"""
@ -39,7 +42,7 @@ class Departement(db.Model):
def __repr__(self):
return f"<{self.__class__.__name__}(id={self.id}, acronym='{self.acronym}')>"
def to_dict(self):
def to_dict(self, with_dept_name=True, with_dept_preferences=False):
data = {
"id": self.id,
"acronym": self.acronym,
@ -47,8 +50,28 @@ class Departement(db.Model):
"visible": self.visible,
"date_creation": self.date_creation,
}
if with_dept_name:
pref = ScoPreference.query.filter_by(
dept_id=self.id, name="DeptName"
).first()
data["dept_name"] = pref.value if pref else None
# Ceci n'est pas encore utilisé, mais pourrait être publié
# par l'API après nettoyage des préférences.
if with_dept_preferences:
data["preferences"] = {
p.name: p.value for p in ScoPreference.query.filter_by(dept_id=self.id)
}
return data
@classmethod
def invalid_dept_acronym(cls, dept_acronym: str) -> bool:
"Check that dept_acronym is invalid"
return (
not dept_acronym
or (len(dept_acronym) >= SHORT_STR_LEN)
or not VALID_DEPT_EXP.match(dept_acronym)
)
@classmethod
def from_acronym(cls, acronym):
dept = cls.query.filter_by(acronym=acronym).first_or_404()
@ -59,6 +82,8 @@ def create_dept(acronym: str, visible=True) -> Departement:
"Create new departement"
from app.models import ScoPreference
if Departement.invalid_dept_acronym(acronym):
raise ScoValueError("acronyme departement invalide")
existing = Departement.query.filter_by(acronym=acronym).count()
if existing:
raise ScoValueError(f"acronyme {acronym} déjà existant")

View File

@ -6,6 +6,8 @@
import datetime
from functools import cached_property
from operator import attrgetter
from flask import abort, has_request_context, url_for
from flask import g, request
import sqlalchemy
@ -27,6 +29,7 @@ class Identite(db.Model):
__table_args__ = (
db.UniqueConstraint("dept_id", "code_nip"),
db.UniqueConstraint("dept_id", "code_ine"),
db.CheckConstraint("civilite IN ('M', 'F', 'X')"),
)
id = db.Column(db.Integer, primary_key=True)
@ -36,10 +39,8 @@ class Identite(db.Model):
nom = db.Column(db.Text())
prenom = db.Column(db.Text())
nom_usuel = db.Column(db.Text())
# optionnel (si present, affiché à la place du nom)
"optionnel (si present, affiché à la place du nom)"
civilite = db.Column(db.String(1), nullable=False)
__table_args__ = (db.CheckConstraint("civilite IN ('M', 'F', 'X')"),)
date_naissance = db.Column(db.Date)
lieu_naissance = db.Column(db.Text())
dept_naissance = db.Column(db.Text())
@ -58,6 +59,16 @@ class Identite(db.Model):
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
#
admission = db.relationship("Admission", backref="identite", lazy="dynamic")
dispense_ues = db.relationship(
"DispenseUE",
back_populates="etud",
cascade="all, delete",
passive_deletes=True,
)
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship("Assiduite", backref="etudiant", lazy="dynamic")
justificatifs = db.relationship("Justificatif", backref="etudiant", lazy="dynamic")
def __repr__(self):
return (
@ -65,13 +76,30 @@ class Identite(db.Model):
)
@classmethod
def from_request(cls, etudid=None, code_nip=None):
def from_request(cls, etudid=None, code_nip=None) -> "Identite":
"""Étudiant à partir de l'etudid ou du code_nip, soit
passés en argument soit retrouvés directement dans la requête web.
Erreur 404 si inexistant.
"""
args = make_etud_args(etudid=etudid, code_nip=code_nip)
return Identite.query.filter_by(**args).first_or_404()
return cls.query.filter_by(**args).first_or_404()
@classmethod
def get_etud(cls, etudid: int) -> "Identite":
"""Etudiant ou 404, cherche uniquement dans le département courant"""
if g.scodoc_dept:
return cls.query.filter_by(
id=etudid, dept_id=g.scodoc_dept_id
).first_or_404()
return cls.query.filter_by(id=etudid).first_or_404()
@classmethod
def create_etud(cls, **args):
"Crée un étudiant, avec admission et adresse vides."
etud: Identite = cls(**args)
etud.adresses.append(Adresse())
etud.admission.append(Admission())
return etud
@property
def civilite_str(self):
@ -142,9 +170,19 @@ class Identite(db.Model):
)
def get_first_email(self, field="email") -> str:
"Le mail associé à la première adrese de l'étudiant, ou None"
"Le mail associé à la première adresse de l'étudiant, ou None"
return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
def get_formsemestres(self) -> list:
"""Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
triée par date_debut
"""
return sorted(
[ins.formsemestre for ins in self.formsemestre_inscriptions],
key=attrgetter("date_debut"),
reverse=True,
)
def to_dict_short(self) -> dict:
"""Les champs essentiels"""
return {
@ -169,6 +207,10 @@ class Identite(db.Model):
e["etudid"] = self.id
e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"])
e["ne"] = self.e
e["nomprenom"] = self.nomprenom
adresse = self.adresses.first()
if adresse:
e.update(adresse.to_dict())
return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty
def to_dict_bul(self, include_urls=True):
@ -278,11 +320,18 @@ class Identite(db.Model):
inscription_courante = self.inscription_courante()
if inscription_courante:
titre_sem = inscription_courante.formsemestre.titre_mois()
if inscription_courante.etat == scu.DEMISSION:
inscr_txt = "Démission de"
elif inscription_courante.etat == scu.DEF:
inscr_txt = "Défaillant dans"
else:
inscr_txt = "Inscrit en"
return {
"etat_in_cursem": inscription_courante.etat,
"inscription_courante": inscription_courante,
"inscription": titre_sem,
"inscription_str": "Inscrit en " + titre_sem,
"inscription_str": inscr_txt + " " + titre_sem,
"situation": self.descr_situation_etud(),
}
else:
@ -311,7 +360,7 @@ class Identite(db.Model):
"situation": situation,
}
def inscription_etat(self, formsemestre_id):
def inscription_etat(self, formsemestre_id: int) -> str:
"""État de l'inscription de cet étudiant au semestre:
False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF
"""
@ -394,14 +443,21 @@ class Identite(db.Model):
return situation
def etat_civil_pv(self, line_sep="\n") -> str:
def etat_civil_pv(self, with_paragraph=True, line_sep="\n") -> str:
"""Présentation, pour PV jury
M. Pierre Dupont
n° 12345678
(e) le 7/06/1974
à Paris
Si with_paragraph (défaut):
M. Pierre Dupont
n° 12345678
(e) le 7/06/1974
à Paris
Sinon:
M. Pierre Dupont
"""
return f"""{self.nomprenom}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{line_sep}à {self.lieu_naissance or ""}"""
if with_paragraph:
return f"""{self.nomprenom}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.nomprenom
def photo_html(self, title=None, size="small") -> str:
"""HTML img tag for the photo, either in small size (h90)

View File

@ -5,12 +5,16 @@
import datetime
from app import db
from app.models.etudiants import Identite
from app.models.moduleimpls import ModuleImpl
from app.models.notes import NotesNotes
from app.models.ues import UniteEns
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.notesdb as ndb
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
class Evaluation(db.Model):
"""Evaluation (contrôle, examen, ...)"""
@ -44,10 +48,12 @@ class Evaluation(db.Model):
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
def __repr__(self):
return f"""<Evaluation {self.id} {self.jour.isoformat() if self.jour else ''} "{self.description[:16] if self.description else ''}">"""
return f"""<Evaluation {self.id} {
self.jour.isoformat() if self.jour else ''} "{
self.description[:16] if self.description else ''}">"""
def to_dict(self) -> dict:
"Représentation dict, pour json"
"Représentation dict (riche, compat ScoDoc 7)"
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators
@ -67,6 +73,34 @@ class Evaluation(db.Model):
e["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
return evaluation_enrich_dict(e)
def to_dict_api(self) -> dict:
"Représentation dict pour API JSON"
if self.jour is None:
date_debut = None
date_fin = None
else:
date_debut = datetime.datetime.combine(
self.jour, self.heure_debut or datetime.time(0, 0)
).isoformat()
date_fin = datetime.datetime.combine(
self.jour, self.heure_fin or datetime.time(0, 0)
).isoformat()
return {
"coefficient": self.coefficient,
"date_debut": date_debut,
"date_fin": date_fin,
"description": self.description,
"evaluation_type": self.evaluation_type,
"id": self.id,
"moduleimpl_id": self.moduleimpl_id,
"note_max": self.note_max,
"numero": self.numero,
"poids": self.get_ue_poids_dict(),
"publish_incomplete": self.publish_incomplete,
"visi_bulletin": self.visibulletin,
}
def from_dict(self, data):
"""Set evaluation attributes from given dict values."""
check_evaluation_args(data)
@ -74,6 +108,29 @@ class Evaluation(db.Model):
if k != "_sa_instance_state" and k != "id" and k in data:
setattr(self, k, data[k])
def descr_heure(self) -> str:
"Description de la plage horaire pour affichages"
if self.heure_debut and (
not self.heure_fin or self.heure_fin == self.heure_debut
):
return f"""à {self.heure_debut.strftime("%Hh%M")}"""
elif self.heure_debut and self.heure_fin:
return f"""de {self.heure_debut.strftime("%Hh%M")} à {self.heure_fin.strftime("%Hh%M")}"""
else:
return ""
def descr_duree(self) -> str:
"Description de la durée pour affichages"
if self.heure_debut is None and self.heure_fin is None:
return ""
debut = self.heure_debut or DEFAULT_EVALUATION_TIME
fin = self.heure_fin or DEFAULT_EVALUATION_TIME
d = (fin.hour * 60 + fin.minute) - (debut.hour * 60 + debut.minute)
duree = f"{d//60}h"
if d % 60:
duree += f"{d%60:02d}"
return duree
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain commit
@ -87,6 +144,29 @@ class Evaluation(db.Model):
db.session.add(copy)
return copy
def set_default_poids(self) -> bool:
"""Initialize les poids bvers les UE à leurs valeurs par défaut
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
Les poids existants ne sont pas modifiés.
Return True if (uncommited) modification, False otherwise.
"""
ue_coef_dict = self.moduleimpl.module.get_ue_coef_dict()
sem_ues = self.moduleimpl.formsemestre.query_ues(with_sport=False).all()
modified = False
for ue in sem_ues:
existing_poids = EvaluationUEPoids.query.filter_by(
ue=ue, evaluation=self
).first()
if existing_poids is None:
coef_ue = ue_coef_dict.get(ue.id, 0.0) or 0.0
if coef_ue > 0:
poids = 1.0 # par défaut au départ
else:
poids = 0.0
self.set_ue_poids(ue, poids)
modified = True
return modified
def set_ue_poids(self, ue, poids: float) -> None:
"""Set poids évaluation vers cette UE"""
self.update_ue_poids_dict({ue.id: poids})
@ -99,7 +179,7 @@ class Evaluation(db.Model):
for ue_id, poids in ue_poids_dict.items():
ue = UniteEns.query.get(ue_id)
L.append(EvaluationUEPoids(evaluation=self, ue=ue, poids=poids))
self.ue_poids = L
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
self.moduleimpl.invalidate_evaluations_poids() # inval cache
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None:
@ -108,8 +188,18 @@ class Evaluation(db.Model):
current.update(ue_poids_dict)
self.set_ue_poids_dict(current)
def get_ue_poids_dict(self) -> dict:
"""returns { ue_id : poids }"""
def get_ue_poids_dict(self, sort=False) -> dict:
"""returns { ue_id : poids }
Si sort, trie par UE
"""
if sort:
return {
p.ue.id: p.poids
for p in sorted(
self.ue_poids, key=lambda p: (p.ue.numero or 0, p.ue.acronyme)
)
}
return {p.ue.id: p.poids for p in self.ue_poids}
def get_ue_poids_str(self) -> str:
@ -130,6 +220,12 @@ class Evaluation(db.Model):
]
)
def get_etud_note(self, etud: Identite) -> NotesNotes:
"""La note de l'étudiant, ou None si pas noté.
(nb: pas de cache, lent, ne pas utiliser pour des calculs)
"""
return NotesNotes.query.filter_by(etudid=etud.id, evaluation_id=self.id).first()
class EvaluationUEPoids(db.Model):
"""Poids des évaluations (BUT)
@ -164,7 +260,7 @@ class EvaluationUEPoids(db.Model):
# Fonction héritée de ScoDoc7 à refactorer
def evaluation_enrich_dict(e):
def evaluation_enrich_dict(e: dict):
"""add or convert some fields in an evaluation dict"""
# For ScoDoc7 compat
heure_debut_dt = e["heure_debut"] or datetime.time(
@ -173,7 +269,7 @@ def evaluation_enrich_dict(e):
heure_fin_dt = e["heure_fin"] or datetime.time(8, 00)
e["heure_debut"] = ndb.TimefromISO8601(e["heure_debut"])
e["heure_fin"] = ndb.TimefromISO8601(e["heure_fin"])
e["jouriso"] = ndb.DateDMYtoISO(e["jour"])
e["jour_iso"] = ndb.DateDMYtoISO(e["jour"])
heure_debut, heure_fin = e["heure_debut"], e["heure_fin"]
d = ndb.TimeDuration(heure_debut, heure_fin)
if d is not None:

View File

@ -13,7 +13,6 @@ from app import email
from app import log
from app.auth.models import User
from app.models import SHORT_STR_LEN
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
import app.scodoc.sco_utils as scu
from app.scodoc import sco_preferences
@ -170,10 +169,12 @@ class ScolarNews(db.Model):
log(f"news: {news}")
news.notify_by_mail()
def get_news_formsemestre(self) -> FormSemestre:
def get_news_formsemestre(self) -> "FormSemestre":
"""formsemestre concerné par la nouvelle
None si inexistant
"""
from app.models.formsemestre import FormSemestre
formsemestre_id = None
if self.type == self.NEWS_INSCR:
formsemestre_id = self.object
@ -232,8 +233,7 @@ class ScolarNews(db.Model):
txt = re.sub(r'<a.*?href\s*=\s*"(.*?)".*?>(.*?)</a>', r"\2: \1", txt)
subject = "[ScoDoc] " + self.NEWS_MAP.get(self.type, "?")
sender = prefs["email_from_addr"]
sender = email.get_from_addr()
email.send_email(subject, sender, destinations, txt)
@classmethod

View File

@ -17,9 +17,9 @@ from app.models.modules import Module
from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns
from app.scodoc import sco_cache
from app.scodoc import sco_codes_parcours
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_STANDARD
from app.scodoc.codes_cursus import UE_STANDARD
class Formation(db.Model):
@ -36,6 +36,7 @@ class Formation(db.Model):
titre = db.Column(db.Text(), nullable=False)
titre_officiel = db.Column(db.Text(), nullable=False)
version = db.Column(db.Integer, default=1, server_default="1")
commentaire = db.Column(db.Text())
formation_code = db.Column(
db.String(SHORT_STR_LEN),
server_default=db.text("notes_newid_fcod()"),
@ -47,7 +48,7 @@ class Formation(db.Model):
# Optionnel, pour les formations type BUT
referentiel_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_referentiel_competences.id")
db.Integer, db.ForeignKey("apc_referentiel_competences.id", ondelete="SET NULL")
)
ues = db.relationship("UniteEns", backref="formation", lazy="dynamic")
formsemestres = db.relationship("FormSemestre", lazy="dynamic", backref="formation")
@ -55,26 +56,41 @@ class Formation(db.Model):
modules = db.relationship("Module", lazy="dynamic", backref="formation")
def __repr__(self):
return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme!r}')>"
return f"""<{self.__class__.__name__}(id={self.id}, dept_id={
self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>"""
def to_html(self) -> str:
"titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
def to_dict(self):
def to_dict(self, with_refcomp_attrs=False, with_departement=True):
"""As a dict.
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
e["departement"] = self.departement.to_dict()
# ScoDoc7 output_formators: (backward compat)
e["formation_id"] = self.id
if "referentiel_competence" in e:
e.pop("referentiel_competence")
e["code_specialite"] = e["code_specialite"] or ""
e["commentaire"] = e["commentaire"] or ""
if with_departement and self.departement:
e["departement"] = self.departement.to_dict()
else:
e.pop("departement", None)
e["formation_id"] = self.id # ScoDoc7 backward compat
if with_refcomp_attrs and self.referentiel_competence:
e["refcomp_version_orebut"] = self.referentiel_competence.version_orebut
e["refcomp_specialite"] = self.referentiel_competence.specialite
e["refcomp_type_titre"] = self.referentiel_competence.type_titre
return e
def get_parcours(self):
"""get l'instance de TypeParcours de cette formation
(le TypeParcours définit le genre de formation, à ne pas confondre
def get_cursus(self) -> codes_cursus.TypeCursus:
"""get l'instance de TypeCursus de cette formation
(le TypeCursus définit le genre de formation, à ne pas confondre
avec les parcours du BUT).
"""
return sco_codes_parcours.get_parcours_from_code(self.type_parcours)
return codes_cursus.get_cursus_from_code(self.type_parcours)
def get_titre_version(self) -> str:
"""Titre avec version"""
@ -82,7 +98,7 @@ class Formation(db.Model):
def is_apc(self):
"True si formation APC avec SAE (BUT)"
return self.get_parcours().APC_SAE
return self.get_cursus().APC_SAE
def get_module_coefs(self, semestre_idx: int = None):
"""Les coefs des modules vers les UE (accès via cache)"""
@ -101,9 +117,14 @@ class Formation(db.Model):
df_cache.ModuleCoefsCache.set(key, modules_coefficients)
return modules_coefficients
def has_locked_sems(self):
"True if there is a locked formsemestre in this formation"
return len(self.formsemestres.filter_by(etat=False).all()) > 0
def has_locked_sems(self, semestre_idx: int = None):
"""True if there is a locked formsemestre in this formation.
If semestre_idx is specified, check only this index.
"""
query = self.formsemestres.filter_by(etat=False)
if semestre_idx is not None:
query = query.filter_by(semestre_id=semestre_idx)
return len(query.all()) > 0
def invalidate_module_coefs(self, semestre_idx: int = None):
"""Invalide le cache des coefficients de modules.
@ -194,12 +215,17 @@ class Formation(db.Model):
def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery:
"""Les UEs d'un parcours de la formation.
Si parcour est None, les UE sans parcours.
Exemple: pour avoir les UE du semestre 3, faire
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
"""
return UniteEns.query.filter_by(formation=self).filter(
if parcour is None:
return UniteEns.query.filter_by(
formation=self, type=UE_STANDARD, parcour_id=None
)
return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter(
UniteEns.niveau_competence_id == ApcNiveau.id,
UniteEns.type == UE_STANDARD,
(UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcAnneeParcours.parcours_id == parcour.id,
@ -226,6 +252,21 @@ class Formation(db.Model):
.filter(ApcAnneeParcours.parcours_id == parcour.id)
)
def refcomp_desassoc(self):
"""Désassocie la formation de son ref. de compétence"""
self.referentiel_competence = None
db.session.add(self)
# Niveaux des UE
for ue in self.ues:
ue.niveau_competence = None
db.session.add(ue)
# Parcours et AC des modules
for mod in self.modules:
mod.parcours = []
mod.app_critiques = []
db.session.add(mod)
db.session.commit()
class Matiere(db.Model):
"""Matières: regroupe les modules d'une UE
@ -253,6 +294,6 @@ class Matiere(db.Model):
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators
e["ue_id"] = self.id
e["numero"] = e["numero"] if e["numero"] else 0
e["ue_id"] = self.id
return e

View File

@ -1,45 +1,49 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
# pylint génère trop de faux positifs avec les colonnes date:
# pylint: disable=no-member,not-an-iterable
"""ScoDoc models: formsemestre
"""
import datetime
from functools import cached_property
from flask import flash, g
from flask_login import current_user
import flask_sqlalchemy
from flask import flash, g
from sqlalchemy import and_, or_
from sqlalchemy.sql import text
from app import db
from app import log
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
import app.scodoc.sco_utils as scu
from app import db, log
from app.auth.models import User
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
parcours_formsemestre,
)
from app.models.groups import GroupDescr, Partition
import app.scodoc.sco_utils as scu
from app.models.but_refcomp import parcours_formsemestre
from app.models.config import ScoDocSiteConfig
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
from app.models.modules import Module
from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_preferences
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus, sco_preferences
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_vdi import ApoEtapeVDI
class FormSemestre(db.Model):
@ -54,51 +58,58 @@ class FormSemestre(db.Model):
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
titre = db.Column(db.Text())
date_debut = db.Column(db.Date())
date_fin = db.Column(db.Date())
etat = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true"
) # False si verrouillé
titre = db.Column(db.Text(), nullable=False)
date_debut = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False)
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
"False si verrouillé"
modalite = db.Column(
db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
) # "FI", "FAP", "FC", ...
# gestion compensation sem DUT:
)
"Modalité de formation: 'FI', 'FAP', 'FC', ..."
gestion_compensation = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# ne publie pas le bulletin XML ou JSON:
"gestion compensation sem DUT (inutilisé en APC)"
bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# Bloque le calcul des moyennes (générale et d'UE)
"ne publie pas le bulletin XML ou JSON"
block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# semestres decales (pour gestion jurys):
"Bloque le calcul des moyennes (générale et d'UE)"
block_moyenne_generale = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# couleur fond bulletins HTML:
"Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)"
bul_bgcolor = db.Column(
db.String(SHORT_STR_LEN), default="white", server_default="white"
db.String(SHORT_STR_LEN),
default="white",
server_default="white",
nullable=False,
)
# autorise resp. a modifier semestre:
"couleur fond bulletins HTML"
resp_can_edit = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# autorise resp. a modifier slt les enseignants:
"autorise resp. à modifier le formsemestre"
resp_can_change_ens = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true"
)
# autorise les ens a creer des evals:
"autorise resp. a modifier slt les enseignants"
ens_can_edit_eval = db.Column(
db.Boolean(), nullable=False, default=False, server_default="False"
)
# code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'
"autorise les enseignants à créer des évals dans leurs modimpls"
elt_sem_apo = db.Column(db.Text()) # peut être fort long !
# code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'
"code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
elt_annee_apo = db.Column(db.Text())
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
# Relations:
etapes = db.relationship(
@ -108,6 +119,7 @@ class FormSemestre(db.Model):
"ModuleImpl",
backref="formsemestre",
lazy="dynamic",
cascade="all, delete-orphan",
)
etuds = db.relationship(
"Identite",
@ -145,7 +157,21 @@ class FormSemestre(db.Model):
self.modalite = FormationModalite.DEFAULT_MODALITE
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>"
return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
@classmethod
def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre":
""" "FormSemestre ou 404, cherche uniquement dans le département courant"""
if g.scodoc_dept:
return cls.query.filter_by(
id=formsemestre_id, dept_id=g.scodoc_dept_id
).first_or_404()
return cls.query.filter_by(id=formsemestre_id).first_or_404()
def sort_key(self) -> tuple:
"""clé pour tris par ordre alphabétique
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
return (self.date_debut, self.semestre_id)
def to_dict(self, convert_objects=False) -> dict:
"""dict (compatible ScoDoc7).
@ -170,7 +196,7 @@ class FormSemestre(db.Model):
d["responsables"] = [u.id for u in self.responsables]
d["titre_formation"] = self.titre_formation()
if convert_objects:
d["parcours"] = [p.to_dict() for p in self.parcours]
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
d["departement"] = self.departement.to_dict()
d["formation"] = self.formation.to_dict()
d["etape_apo"] = self.etapes_apo_str()
@ -197,9 +223,10 @@ class FormSemestre(db.Model):
d["etape_apo"] = self.etapes_apo_str()
d["formsemestre_id"] = self.id
d["formation"] = self.formation.to_dict()
d["parcours"] = [p.to_dict() for p in self.parcours]
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
d["responsables"] = [u.id for u in self.responsables]
d["titre_court"] = self.formation.acronyme
d["titre_formation"] = self.titre_formation()
d["titre_num"] = self.titre_num()
d["session_id"] = self.session_id()
return d
@ -219,7 +246,8 @@ class FormSemestre(db.Model):
d["mois_debut_ord"] = self.date_debut.month
d["mois_fin_ord"] = self.date_fin.month
# La période: considère comme "S1" (ou S3) les débuts en aout-sept-octobre
# devrait sans doute pouvoir etre changé...
# devrait sans doute pouvoir etre changé... XXX PIVOT
d["periode"] = self.periode()
if self.date_debut.month >= 8 and self.date_debut.month <= 10:
d["periode"] = 1 # typiquement, début en septembre: S1, S3...
else:
@ -238,17 +266,41 @@ class FormSemestre(db.Model):
d["etapes_apo_str"] = self.etapes_apo_str()
return d
def flip_lock(self):
"""Flip etat (lock)"""
self.etat = not self.etat
db.session.add(self)
def get_parcours_apc(self) -> list[ApcParcours]:
"""Liste des parcours proposés par ce semestre.
Si aucun n'est coché et qu'il y a un référentiel, tous ceux du référentiel.
"""
r = self.parcours or (
self.formation.referentiel_competence
and self.formation.referentiel_competence.parcours
)
return r or []
def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery:
"""UE des modules de ce semestre, triées par numéro.
- Formations classiques: les UEs auxquelles appartiennent
les modules mis en place dans ce semestre.
- Formations APC / BUT: les UEs de la formation qui ont
le même numéro de semestre que ce formsemestre.
- Formations APC / BUT: les UEs de la formation qui
- ont le même numéro de semestre que ce formsemestre
- sont associées à l'un des parcours de ce formsemestre (ou à aucun)
"""
if self.formation.get_parcours().APC_SAE:
if self.formation.get_cursus().APC_SAE:
sem_ues = UniteEns.query.filter_by(
formation=self.formation, semestre_idx=self.semestre_id
)
if self.parcours:
# Prend toutes les UE de l'un des parcours du sem., ou déclarées sans parcours
sem_ues = sem_ues.filter(
(UniteEns.parcour == None)
| (UniteEns.parcour_id.in_([p.id for p in self.parcours]))
)
# si le sem. ne coche aucun parcours, prend toutes les UE
else:
sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id,
@ -256,12 +308,15 @@ class FormSemestre(db.Model):
UniteEns.id == Module.ue_id,
)
if not with_sport:
sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT)
sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
return sem_ues.order_by(UniteEns.numero)
def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery:
"""UE que suit l'étudiant dans ce semestre BUT
"""XXX inutilisé à part pour un test unitaire => supprimer ?
UEs que suit l'étudiant dans ce semestre BUT
en fonction du parcours dans lequel il est inscrit.
Si l'étudiant n'est inscrit à aucun parcours,
renvoie uniquement les UEs de tronc commun (sans parcours).
Si voulez les UE d'un parcours, il est plus efficace de passer par
`formation.query_ues_parcour(parcour)`.
@ -272,7 +327,13 @@ class FormSemestre(db.Model):
UniteEns.niveau_competence_id == ApcNiveau.id,
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
or_(
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
and_(
FormSemestreInscription.parcour_id.is_(None),
UniteEns.parcour_id.is_(None),
),
),
)
@cached_property
@ -285,7 +346,7 @@ class FormSemestre(db.Model):
if self.formation.is_apc():
modimpls.sort(
key=lambda m: (
m.module.module_type or 0,
m.module.module_type or 0, # ressources (2) avant SAEs (3)
m.module.numero or 0,
m.module.code or 0,
)
@ -324,7 +385,7 @@ class FormSemestre(db.Model):
return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor]
def can_be_edited_by(self, user):
"""Vrai si user peut modifier ce semestre"""
"""Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
if not user.has_permission(Permission.ScoImplement): # pas chef
if not self.resp_can_edit or user.id not in [
resp.id for resp in self.responsables
@ -338,7 +399,7 @@ class FormSemestre(db.Model):
(les dates de début et fin sont incluses)
"""
today = datetime.date.today()
return (self.date_debut <= today) and (today <= self.date_fin)
return self.date_debut <= today <= self.date_fin
def contient_periode(self, date_debut, date_fin) -> bool:
"""Vrai si l'intervalle [date_debut, date_fin] est
@ -351,29 +412,105 @@ class FormSemestre(db.Model):
"""Test si sem est entièrement sur la même année scolaire.
(ce n'est pas obligatoire mais si ce n'est pas le
cas les exports Apogée risquent de mal fonctionner)
Pivot au 1er août.
Pivot au 1er août par défaut.
"""
if self.date_debut > self.date_fin:
flash(f"Dates début/fin inversées pour le semestre {self.titre_annee()}")
log(f"Warning: semestre {self.id} begins after ending !")
annee_debut = self.date_debut.year
if self.date_debut.month < 8: # août
# considere que debut sur l'anne scolaire precedente
month_debut_annee = ScoDocSiteConfig.get_month_debut_annee_scolaire()
if self.date_debut.month < month_debut_annee:
# début sur l'année scolaire précédente (juillet inclus par défaut)
annee_debut -= 1
annee_fin = self.date_fin.year
if self.date_fin.month < 9:
# 9 (sept) pour autoriser un début en sept et une fin en aout
if self.date_fin.month < (month_debut_annee + 1):
# 9 (sept) pour autoriser un début en sept et une fin en août
annee_fin -= 1
return annee_debut == annee_fin
def est_decale(self):
"""Vrai si semestre "décalé"
c'est à dire semestres impairs commençant entre janvier et juin
et les pairs entre juillet et decembre
c'est à dire semestres impairs commençant (par défaut)
entre janvier et juin et les pairs entre juillet et décembre.
"""
if self.semestre_id <= 0:
return False # formations sans semestres
return (self.semestre_id % 2 and self.date_debut.month <= 6) or (
not self.semestre_id % 2 and self.date_debut.month > 6
return (
# impair
(
self.semestre_id % 2
and self.date_debut.month < scu.MONTH_DEBUT_ANNEE_SCOLAIRE
)
or
# pair
(
(not self.semestre_id % 2)
and self.date_debut.month >= scu.MONTH_DEBUT_ANNEE_SCOLAIRE
)
)
def est_terminal(self) -> bool:
"Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
return (self.semestre_id < 0) or (
self.semestre_id == self.formation.get_cursus().NB_SEM
)
@classmethod
def comp_periode(
cls,
date_debut: datetime,
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
jour_pivot_annee=1,
jour_pivot_periode=1,
):
"""Calcule la session associée à un formsemestre commençant en date_debut
sous la forme (année, période)
année: première année de l'année scolaire
période = 1 (première période de l'année scolaire, souvent automne)
ou 2 (deuxième période de l'année scolaire, souvent printemps)
Les quatre derniers paramètres forment les dates pivots pour l'année
(1er août par défaut) et pour la période (1er décembre par défaut).
Les calculs se font à partir de la date de début indiquée.
Exemples dans tests/unit/test_periode
Implémentation:
Cas à considérer pour le calcul de la période
pa < pp -----------------|-------------------|---------------->
(A-1, P:2) pa (A, P:1) pp (A, P:2)
pp < pa -----------------|-------------------|---------------->
(A-1, P:1) pp (A-1, P:2) pa (A, P:1)
"""
pivot_annee = 100 * mois_pivot_annee + jour_pivot_annee
pivot_periode = 100 * mois_pivot_periode + jour_pivot_periode
pivot_sem = 100 * date_debut.month + date_debut.day
if pivot_sem < pivot_annee:
annee = date_debut.year - 1
else:
annee = date_debut.year
if pivot_annee < pivot_periode:
if pivot_sem < pivot_annee or pivot_sem >= pivot_periode:
periode = 2
else:
periode = 1
else:
if pivot_sem < pivot_periode or pivot_sem >= pivot_annee:
periode = 1
else:
periode = 2
return annee, periode
def periode(self) -> int:
"""La période:
* 1 : première période: automne à Paris
* 2 : deuxième période, printemps à Paris
"""
return FormSemestre.comp_periode(
self.date_debut,
mois_pivot_annee=ScoDocSiteConfig.get_month_debut_annee_scolaire(),
mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(),
)
def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
@ -420,13 +557,35 @@ class FormSemestre(db.Model):
else:
return ", ".join([u.get_nomcomplet() for u in self.responsables])
def est_responsable(self, user):
def est_responsable(self, user: User):
"True si l'user est l'un des responsables du semestre"
return user.id in [u.id for u in self.responsables]
def est_chef_or_diretud(self, user: User = None):
"Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
user = user or current_user
return user.has_permission(Permission.ScoImplement) or self.est_responsable(
user
)
def can_edit_jury(self, user: User = None):
"""Vrai si utilisateur (par def. current) peut saisir decision de jury
dans ce semestre: vérifie permission et verrouillage.
"""
user = user or current_user
return self.etat and self.est_chef_or_diretud(user)
def can_edit_pv(self, user: User = None):
"Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre"
user = user or current_user
# Autorise les secrétariats, repérés via la permission ScoEtudChangeAdr
return self.est_chef_or_diretud(user) or user.has_permission(
Permission.ScoEtudChangeAdr
)
def annee_scolaire(self) -> int:
"""L'année de début de l'année scolaire.
Par exemple, 2022 si le semestre va de septebre 2022 à février 2023."""
Par exemple, 2022 si le semestre va de septembre 2022 à février 2023."""
return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
def annee_scolaire_str(self):
@ -459,7 +618,7 @@ class FormSemestre(db.Model):
if not imputation_dept:
imputation_dept = prefs["DeptName"]
imputation_dept = imputation_dept.upper()
parcours_name = self.formation.get_parcours().NAME
cursus_name = self.formation.get_cursus().NAME
modalite = self.modalite
# exception pour code Apprentissage:
modalite = (modalite or "").replace("FAP", "FA").replace("APP", "FA")
@ -472,11 +631,13 @@ class FormSemestre(db.Model):
scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
)
return scu.sanitize_string(
f"{imputation_dept}-{parcours_name}-{modalite}-{semestre_id}-{annee_sco}"
f"{imputation_dept}-{cursus_name}-{modalite}-{semestre_id}-{annee_sco}"
)
def titre_annee(self) -> str:
""" """
"""Le titre avec l'année
'DUT Réseaux et Télécommunications semestre 3 FAP 2020-2021'
"""
titre_annee = (
f"{self.titre_num()} {self.modalite or ''} {self.date_debut.year}"
)
@ -484,10 +645,12 @@ class FormSemestre(db.Model):
titre_annee += "-" + str(self.date_fin.year)
return titre_annee
def titre_formation(self):
def titre_formation(self, with_sem_idx=False):
"""Titre avec formation, court, pour passerelle: "BUT R&T"
(méthode de formsemestre car on pourrait ajouter le semestre, ou d'autres infos, à voir)
"""
if with_sem_idx and self.semestre_id > 0:
return f"{self.formation.acronyme} S{self.semestre_id}"
return self.formation.acronyme
def titre_mois(self) -> str:
@ -502,9 +665,9 @@ class FormSemestre(db.Model):
def titre_num(self) -> str:
"""Le titre et le semestre, ex ""DUT Informatique semestre 2"" """
if self.semestre_id == sco_codes_parcours.NO_SEMESTRE_ID:
if self.semestre_id == codes_cursus.NO_SEMESTRE_ID:
return self.titre
return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}"
return f"{self.titre} {self.formation.get_cursus().SESSION_NAME} {self.semestre_id}"
def sem_modalite(self) -> str:
"""Le semestre et la modalité, ex "S2 FI" ou "S3 APP" """
@ -582,14 +745,43 @@ class FormSemestre(db.Model):
db.session.add(partition)
db.session.flush() # pour avoir un id
flash("Partition Parcours créée.")
elif partition.groups_editable:
# Il ne faut jamais laisser éditer cette partition de parcours
partition.groups_editable = False
db.session.add(partition)
for parcour in self.parcours:
for parcour in self.get_parcours_apc():
if parcour.code:
group = GroupDescr.query.filter_by(
partition_id=partition.id, group_name=parcour.code
).first()
if not group:
partition.groups.append(GroupDescr(group_name=parcour.code))
db.session.flush()
# S'il reste des groupes de parcours qui ne sont plus dans le semestre
# - s'ils n'ont pas d'inscrits, supprime-les.
# - s'ils ont des inscrits: avertissement
for group in GroupDescr.query.filter_by(partition_id=partition.id):
if group.group_name not in (p.code for p in self.get_parcours_apc()):
if (
len(
[
inscr
for inscr in self.inscriptions
if (inscr.parcour is not None)
and inscr.parcour.code == group.group_name
]
)
== 0
):
flash(f"Suppression du groupe de parcours vide {group.group_name}")
db.session.delete(group)
else:
flash(
f"""Attention: groupe de parcours {group.group_name} non vide:
réaffectez ses étudiants dans des parcours du semestre"""
)
db.session.commit()
def update_inscriptions_parcours_from_groups(self) -> None:
@ -648,6 +840,71 @@ class FormSemestre(db.Model):
)
db.session.commit()
def etud_validations_description_html(self, etudid: int) -> str:
"""Description textuelle des validations de jury de cet étudiant dans ce semestre"""
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
vals_sem = ScolarFormSemestreValidation.query.filter_by(
etudid=etudid, formsemestre_id=self.id, ue_id=None
).all()
vals_ues = (
ScolarFormSemestreValidation.query.filter_by(
etudid=etudid, formsemestre_id=self.id
)
.join(UniteEns)
.order_by(UniteEns.numero)
.all()
)
# Validations BUT:
vals_rcues = (
ApcValidationRCUE.query.filter_by(etudid=etudid, formsemestre_id=self.id)
.join(UniteEns, ApcValidationRCUE.ue1)
.order_by(UniteEns.numero)
.all()
)
vals_annee = (
ApcValidationAnnee.query.filter_by(
etudid=etudid,
annee_scolaire=self.annee_scolaire(),
)
.join(ApcValidationAnnee.formsemestre)
.join(FormSemestre.formation)
.filter(Formation.formation_code == self.formation.formation_code)
.all()
)
H = []
for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):
if vals:
H.append(
f"""<ul><li>{"</li><li>".join(str(x) for x in vals)}</li></ul>"""
)
return "\n".join(H)
def etud_set_all_missing_notes(self, etud: Identite, value=None) -> int:
"""Met toutes les notes manquantes de cet étudiant dans ce semestre
(ie dans toutes les évaluations des modules auxquels il est inscrit et n'a pas de note)
à la valeur donnée par value, qui est en général "ATT", "ABS", "EXC".
"""
from app.scodoc import sco_saisie_notes
inscriptions = (
ModuleImplInscription.query.filter_by(etudid=etud.id)
.join(ModuleImpl)
.filter_by(formsemestre_id=self.id)
)
nb_recorded = 0
for inscription in inscriptions:
for evaluation in inscription.modimpl.evaluations:
if evaluation.get_etud_note(etud) is None:
if not sco_saisie_notes.do_evaluation_set_etud_note(
evaluation, etud, value
):
raise ScoValueError(
"erreur lors de l'enregistrement de la note"
)
nb_recorded += 1
return nb_recorded
# Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
notes_formsemestre_responsables = db.Table(
@ -826,7 +1083,9 @@ class FormSemestreInscription(db.Model):
# Etape Apogée d'inscription (ajout 2020)
etape = db.Column(db.String(APO_CODE_STR_LEN))
# Parcours (pour les BUT)
parcour_id = db.Column(db.Integer, db.ForeignKey("apc_parcours.id"), index=True)
parcour_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True
)
parcour = db.relationship(ApcParcours)
def __repr__(self):
@ -846,8 +1105,8 @@ class NotesSemSet(db.Model):
title = db.Column(db.Text)
annee_scolaire = db.Column(db.Integer, nullable=True, default=None)
# periode: 0 (année), 1 (Simpair), 2 (Spair)
sem_id = db.Column(db.Integer, nullable=True, default=None)
sem_id = db.Column(db.Integer, nullable=False, default=0)
"période: 0 (année), 1 (Simpair), 2 (Spair)"
# Association: many to many

View File

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -72,7 +72,7 @@ class Partition(db.Model):
"""
if not isinstance(partition_name, str):
return False
if not len(partition_name.strip()) > 0:
if not (0 < len(partition_name.strip()) < SHORT_STR_LEN):
return False
if (not existing) and (
partition_name in [p.partition_name for p in formsemestre.partitions]
@ -87,6 +87,7 @@ class Partition(db.Model):
def to_dict(self, with_groups=False) -> dict:
"""as a dict, with or without groups"""
d = dict(self.__dict__)
d["partition_id"] = self.id
d.pop("_sa_instance_state", None)
d.pop("formsemestre", None)
@ -146,7 +147,7 @@ class GroupDescr(db.Model):
"""
if not isinstance(group_name, str):
return False
if not default and not len(group_name.strip()) > 0:
if not default and not (0 < len(group_name.strip()) < GROUPNAME_STR_LEN):
return False
if (not existing) and (group_name in [g.group_name for g in partition.groups]):
return False

View File

@ -5,10 +5,12 @@ import pandas as pd
import flask_sqlalchemy
from app import db
from app.auth.models import User
from app.comp import df_cache
from app.models.etudiants import Identite
from app.models.modules import Module
from app.scodoc.sco_exceptions import AccessDenied, ScoLockedSemError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
@ -20,14 +22,12 @@ class ModuleImpl(db.Model):
id = db.Column(db.Integer, primary_key=True)
moduleimpl_id = db.synonym("id")
module_id = db.Column(
db.Integer,
db.ForeignKey("notes_modules.id"),
)
module_id = db.Column(db.Integer, db.ForeignKey("notes_modules.id"), nullable=False)
formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
index=True,
nullable=False,
)
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
# formule de calcul moyenne:
@ -62,11 +62,11 @@ class ModuleImpl(db.Model):
"""Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id)
def check_apc_conformity(self) -> bool:
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
"""true si les poids des évaluations du module permettent de satisfaire
les coefficients du PN.
"""
if not self.module.formation.get_parcours().APC_SAE or (
if not self.module.formation.get_cursus().APC_SAE or (
self.module.module_type != scu.ModuleType.RESSOURCE
and self.module.module_type != scu.ModuleType.SAE
):
@ -76,7 +76,7 @@ class ModuleImpl(db.Model):
return moy_mod.moduleimpl_is_conforme(
self,
self.get_evaluations_poids(),
self.module.formation.get_module_coefs(self.module.semestre_id),
res.modimpl_coefs_df,
)
def to_dict(self, convert_objects=False, with_module=True):
@ -101,6 +101,64 @@ class ModuleImpl(db.Model):
d.pop("module", None)
return d
def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
"""Check if user can modify module resp.
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
= Admin, et dir des etud. (si option l'y autorise)
"""
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
# -- check access
# admin ou resp. semestre avec flag resp_can_change_resp
if user.has_permission(Permission.ScoImplement):
return True
if (
user.id in [resp.id for resp in self.formsemestre.responsables]
) and self.formsemestre.resp_can_change_ens:
return True
if raise_exc:
raise AccessDenied(f"Modification impossible pour {user}")
return False
def est_inscrit(self, etud: Identite) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl
Retourne Vrai si c'est le cas, faux sinon
"""
is_module: int = (
ModuleImplInscription.query.filter_by(
etudid=etud.id, moduleimpl_id=self.id
).count()
> 0
)
return is_module
def can_change_ens_by(self, user: User, raise_exc=False) -> bool:
"""Check if user can modify module resp.
If raise_exc, raises exception (AccessDenied or ScoLockedSemError) if not.
= Admin, et dir des etud. (si option l'y autorise)
"""
if not self.formsemestre.etat:
if raise_exc:
raise ScoLockedSemError("Modification impossible: semestre verrouille")
return False
# -- check access
# admin ou resp. semestre avec flag resp_can_change_resp
if user.has_permission(Permission.ScoImplement):
return True
if (
user.id in [resp.id for resp in self.formsemestre.responsables]
) and self.formsemestre.resp_can_change_ens:
return True
if raise_exc:
raise AccessDenied(f"Modification impossible pour {user}")
return False
# Enseignants (chargés de TD ou TP) d'un moduleimpl
notes_modules_enseignants = db.Table(

View File

@ -1,11 +1,13 @@
"""ScoDoc 9 models : Modules
"""
from flask import current_app
from app import db
from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import app_critiques_modules, parcours_modules
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import ModuleType
@ -37,7 +39,9 @@ class Module(db.Model):
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)
module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0")
# Relations:
modimpls = db.relationship("ModuleImpl", backref="module", lazy="dynamic")
modimpls = db.relationship(
"ModuleImpl", backref="module", lazy="dynamic", cascade="all, delete-orphan"
)
ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True)
tags = db.relationship(
"NotesTag",
@ -66,7 +70,39 @@ class Module(db.Model):
super(Module, self).__init__(**kwargs)
def __repr__(self):
return f"<Module{ModuleType(self.module_type or ModuleType.STANDARD).name} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
def clone(self):
"""Create a new copy of this module."""
mod = Module(
titre=self.titre,
abbrev=self.abbrev,
code=self.code + "-copie",
heures_cours=self.heures_cours,
heures_td=self.heures_td,
heures_tp=self.heures_tp,
coefficient=self.coefficient,
ects=self.ects,
ue_id=self.ue_id,
matiere_id=self.matiere_id,
formation_id=self.formation_id,
semestre_id=self.semestre_id,
numero=self.numero, # il est conseillé de renuméroter
code_apogee="", # volontairement vide pour éviter les erreurs
module_type=self.module_type,
)
# Les tags:
for tag in self.tags:
mod.tags.append(tag)
# Les parcours
for parcour in self.parcours:
mod.parcours.append(parcour)
# Les AC
for app_critique in self.app_critiques:
mod.app_critiques.append(app_critique)
return mod
def to_dict(self, convert_objects=False, with_matiere=False, with_ue=False) -> dict:
"""If convert_objects, convert all attributes to native types
@ -141,6 +177,11 @@ class Module(db.Model):
ue_coef_dict = { ue_id : coef }
Les coefs nuls (zéro) ne sont pas stockés: la relation est supprimée.
"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logguer.info(
f"set_ue_coef_dict: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
changed = False
for ue_id, coef in ue_coef_dict.items():
# Existant ?
@ -167,6 +208,11 @@ class Module(db.Model):
def update_ue_coef_dict(self, ue_coef_dict: dict):
"""update coefs vers UE (ajoute aux existants)"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logguer.info(
f"update_ue_coef_dict: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
current = self.get_ue_coef_dict()
current.update(ue_coef_dict)
self.set_ue_coef_dict(current)
@ -175,8 +221,17 @@ class Module(db.Model):
"""returns { ue_id : coef }"""
return {p.ue.id: p.coef for p in self.ue_coefs}
def get_ue_coef_dict_acronyme(self):
"""returns { ue_acronyme : coef }"""
return {p.ue.acronyme: p.coef for p in self.ue_coefs}
def delete_ue_coef(self, ue):
"""delete coef"""
if self.formation.has_locked_sems(self.ue.semestre_idx):
current_app.logguer.info(
f"delete_ue_coef: locked formation, ignoring request"
)
raise ScoValueError("Formation verrouillée")
ue_coef = ModuleUECoef.query.get((self.id, ue.id))
if ue_coef:
db.session.delete(ue_coef)
@ -188,25 +243,31 @@ class Module(db.Model):
# à redéfinir les relationships...
return sorted(self.ue_coefs, key=lambda x: x.ue.numero)
def ue_coefs_list(self, include_zeros=True):
def ue_coefs_list(
self, include_zeros=True, ues: list["UniteEns"] = None
) -> list[tuple["UniteEns", float]]:
"""Liste des coefs vers les UE (pour les modules APC).
Si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre,
Si ues est spécifié, restreint aux UE indiquées.
Sinon si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre,
sauf UE bonus sport.
Result: List of tuples [ (ue, coef) ]
"""
if not self.is_apc():
return []
if include_zeros:
if include_zeros and ues is None:
# Toutes les UE du même semestre:
ues_semestre = (
ues = (
self.formation.ues.filter_by(semestre_idx=self.ue.semestre_idx)
.filter(UniteEns.type != UE_SPORT)
.order_by(UniteEns.numero)
.all()
)
if not ues:
return []
if ues:
coefs_dict = self.get_ue_coef_dict()
coefs_list = []
for ue in ues_semestre:
for ue in ues:
coefs_list.append((ue, coefs_dict.get(ue.id, 0.0)))
return coefs_list
# Liste seulement les coefs définis:
@ -218,6 +279,19 @@ class Module(db.Model):
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def get_parcours(self) -> list[ApcParcours]:
"""Les parcours utilisant ce module.
Si tous les parcours, liste vide (!).
"""
ref_comp = self.formation.referentiel_competence
if not ref_comp:
return []
tous_parcours_ids = {p.id for p in ref_comp.parcours}
parcours_ids = {p.id for p in self.parcours}
if tous_parcours_ids == parcours_ids:
return []
return self.parcours
class ModuleUECoef(db.Model):
"""Coefficients des modules vers les UE (APC, BUT)

View File

@ -4,8 +4,6 @@
"""
from app import db
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
@ -53,6 +51,13 @@ class NotesNotes(db.Model):
d.pop("_sa_instance_state", None)
return d
def __repr__(self):
"pour debug"
from app.models.evaluations import Evaluation
return f"""<{self.__class__.__name__} {self.id} v={self.value} {self.date.isoformat()
} {Evaluation.query.get(self.evaluation_id) if self.evaluation_id else "X" }>"""
class NotesNotesLog(db.Model):
"""Historique des modifs sur notes (anciennes entrees de notes_notes)"""

View File

@ -1,9 +1,14 @@
"""ScoDoc 9 models : Unités d'Enseignement (UE)
"""
from app import db
import pandas as pd
from app import db, log
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models.but_refcomp import ApcNiveau, ApcParcours
from app.models.modules import Module
from app.scodoc.sco_exceptions import ScoFormationConflict
from app.scodoc import sco_utils as scu
@ -46,12 +51,26 @@ class UniteEns(db.Model):
color = db.Column(db.Text())
# BUT
niveau_competence_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"))
niveau_competence_id = db.Column(
db.Integer, db.ForeignKey("apc_niveau.id", ondelete="SET NULL")
)
niveau_competence = db.relationship("ApcNiveau", back_populates="ues")
# Une ue appartient soit à tous les parcours (tronc commun), soit à un seul:
parcour_id = db.Column(
db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True
)
parcour = db.relationship("ApcParcours", back_populates="ues")
# relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
modules = db.relationship("Module", lazy="dynamic", backref="ue")
dispense_ues = db.relationship(
"DispenseUE",
back_populates="ue",
cascade="all, delete",
passive_deletes=True,
)
def __repr__(self):
return f"""<{self.__class__.__name__}(id={self.id}, formation_id={
@ -59,6 +78,28 @@ class UniteEns(db.Model):
self.semestre_idx} {
'EXTERNE' if self.is_external else ''})>"""
def clone(self):
"""Create a new copy of this ue.
Ne copie pas le code, ni le code Apogée, ni les liens au réf. de comp.
(parcours et niveau).
"""
ue = UniteEns(
formation_id=self.formation_id,
acronyme=self.acronyme + "-copie",
numero=self.numero,
titre=self.titre,
semestre_idx=self.semestre_idx,
type=self.type,
ue_code="", # ne duplique pas le code
ects=self.ects,
is_external=self.is_external,
code_apogee="", # ne copie pas les codes Apo
coefficient=self.coefficient,
coef_rcue=self.coef_rcue,
color=self.color,
)
return ue
def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
"""as a dict, with the same conversions as in ScoDoc7
(except ECTS: keep None)
@ -74,6 +115,7 @@ class UniteEns(db.Model):
e["ects"] = e["ects"]
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
e["code_apogee"] = e["code_apogee"] or "" # pas de None
e["parcour"] = self.parcour.to_dict() if self.parcour else None
if with_module_ue_coefs:
if convert_objects:
e["module_ue_coefs"] = [
@ -83,6 +125,12 @@ class UniteEns(db.Model):
e.pop("module_ue_coefs", None)
return e
def annee(self) -> int:
"""L'année dans la formation (commence à 1).
En APC seulement, en classic renvoie toujours 1.
"""
return 1 if self.semestre_idx is None else (self.semestre_idx - 1) // 2 + 1
def is_locked(self):
"""True if UE should not be modified
(contains modules used in a locked formsemestre)
@ -135,3 +183,137 @@ class UniteEns(db.Model):
if self.code_apogee:
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def _check_apc_conflict(self, new_niveau_id: int, new_parcour_id: int):
"raises ScoFormationConflict si (niveau, parcours) pas unique dans ce semestre"
# Les UE du même semestre que nous:
ues_sem = self.formation.ues.filter_by(semestre_idx=self.semestre_idx)
if (new_niveau_id, new_parcour_id) in (
(oue.niveau_competence_id, oue.parcour_id)
for oue in ues_sem
if oue.id != self.id
):
log(
f"set_ue_niveau_competence: {self}: ({new_niveau_id}, {new_parcour_id}) déjà associé"
)
raise ScoFormationConflict()
def set_niveau_competence(self, niveau: ApcNiveau):
"""Associe cette UE au niveau de compétence indiqué.
Le niveau doit être dans le parcours de l'UE, s'il y en a un.
Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
"""
if niveau is not None:
self._check_apc_conflict(niveau.id, self.parcour_id)
# Le niveau est-il dans le parcours ? Sinon, erreur
if self.parcour and niveau.id not in (
n.id
for n in niveau.niveaux_annee_de_parcours(
self.parcour, self.annee(), self.formation.referentiel_competence
)
):
log(
f"set_niveau_competence: niveau {niveau} hors parcours {self.parcour}"
)
return
self.niveau_competence = niveau
db.session.add(self)
db.session.commit()
# Invalidation du cache
self.formation.invalidate_cached_sems()
log(f"ue.set_niveau_competence( {self}, {niveau} )")
def set_parcour(self, parcour: ApcParcours):
"""Associe cette UE au parcours indiqué.
Assure que ce soit la seule dans son parcours.
Sinon, raises ScoFormationConflict.
Si niveau est None, désassocie.
"""
if (parcour is not None) and self.niveau_competence is not None:
self._check_apc_conflict(self.niveau_competence.id, parcour.id)
self.parcour = parcour
# Le niveau est-il dans ce parcours ? Sinon, l'enlève
if (
parcour
and self.niveau_competence
and self.niveau_competence.id
not in (
n.id
for n in self.niveau_competence.niveaux_annee_de_parcours(
parcour, self.annee(), self.formation.referentiel_competence
)
)
):
self.niveau_competence = None
db.session.add(self)
db.session.commit()
# Invalidation du cache
self.formation.invalidate_cached_sems()
log(f"ue.set_parcour( {self}, {parcour} )")
class DispenseUE(db.Model):
"""Dispense d'UE
Utilisé en APC (BUT) pour indiquer les étudiants redoublants avec une UE capitalisée
qu'ils ne refont pas.
La dispense d'UE n'est PAS une validation:
- elle n'est pas affectée par les décisions de jury (pas effacée)
- elle est associée à un formsemestre
- elle ne permet pas la délivrance d'ECTS ou du diplôme.
On utilise cette dispense et non une "inscription" par souci d'efficacité:
en général, la grande majorité des étudiants suivront toutes les UEs de leur parcours,
la dispense étant une exception.
"""
__table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id", "etudid"),)
id = db.Column(db.Integer, primary_key=True)
formsemestre_id = formsemestre_id = db.Column(
db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True
)
ue_id = db.Column(
db.Integer,
db.ForeignKey(UniteEns.id, ondelete="CASCADE"),
index=True,
nullable=False,
)
ue = db.relationship("UniteEns", back_populates="dispense_ues")
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etud = db.relationship("Identite", back_populates="dispense_ues")
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} {self.id} etud={
repr(self.etud)} ue={repr(self.ue)}>"""
@classmethod
def load_formsemestre_dispense_ues_set(
cls, formsemestre: "FormSemestre", etudids: pd.Index, ues: list[UniteEns]
) -> set[tuple[int, int]]:
"""Construit l'ensemble des
etudids = modimpl_inscr_df.index, # les etudids
ue_ids : modimpl_coefs_df.index, # les UE du formsemestre sans les UE bonus sport
Résultat: set de (etudid, ue_id).
"""
# Prend toutes les dispenses obtenues par des étudiants de ce formsemestre,
# puis filtre sur inscrits et ues
ue_ids = {ue.id for ue in ues}
dispense_ues = {
(dispense_ue.etudid, dispense_ue.ue_id)
for dispense_ue in DispenseUE.query.filter_by(
formsemestre_id=formsemestre.id
)
if dispense_ue.etudid in etudids and dispense_ue.ue_id in ue_ids
}
return dispense_ues

View File

@ -4,6 +4,7 @@
"""
from app import db
from app import log
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
from app.models.events import Scolog
@ -53,11 +54,21 @@ class ScolarFormSemestreValidation(db.Model):
)
ue = db.relationship("UniteEns", lazy="select", uselist=False)
formsemestre = db.relationship(
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
)
def __repr__(self):
return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
return f"{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
def __str__(self):
if self.ue_id:
# Note: si l'objet vient d'être créé, ue_id peut exister mais pas ue !
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id}: {self.code}"""
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {self.event_date.strftime("%d/%m/%Y")}"""
def to_dict(self) -> dict:
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
return d
@ -83,7 +94,12 @@ class ScolarAutorisationInscription(db.Model):
db.ForeignKey("notes_formsemestre.id"),
)
def __repr__(self) -> str:
return f"""{self.__class__.__name__}(id={self.id}, etudid={
self.etudid}, semestre_id={self.semestre_id})"""
def to_dict(self) -> dict:
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
return d
@ -96,8 +112,7 @@ class ScolarAutorisationInscription(db.Model):
origin_formsemestre_id: int,
semestre_id: int,
):
"""Enregistre une autorisation, remplace celle émanant du même semestre si elle existe."""
cls.delete_autorisation_etud(etudid, origin_formsemestre_id)
"""Ajoute une autorisation"""
autorisation = cls(
etudid=etudid,
formation_code=formation_code,
@ -105,7 +120,10 @@ class ScolarAutorisationInscription(db.Model):
semestre_id=semestre_id,
)
db.session.add(autorisation)
Scolog.logdb("autorise_etud", etudid=etudid, msg=f"passage vers S{semestre_id}")
Scolog.logdb(
"autorise_etud", etudid=etudid, msg=f"Passage vers S{semestre_id}: autorisé"
)
log(f"ScolarAutorisationInscription: recording {autorisation}")
@classmethod
def delete_autorisation_etud(
@ -113,16 +131,17 @@ class ScolarAutorisationInscription(db.Model):
etudid: int,
origin_formsemestre_id: int,
):
"""Efface les autorisations de cette étudiant venant du sem. origine"""
"""Efface les autorisations de cet étudiant venant du sem. origine"""
autorisations = cls.query.filter_by(
etudid=etudid, origin_formsemestre_id=origin_formsemestre_id
)
for autorisation in autorisations:
db.session.delete(autorisation)
log(f"ScolarAutorisationInscription: deleting {autorisation}")
Scolog.logdb(
"autorise_etud",
etudid=etudid,
msg=f"annule passage vers S{autorisation.semestre_id}",
msg=f"Passage vers S{autorisation.semestre_id}: effacé",
)
db.session.flush()
@ -140,11 +159,11 @@ class ScolarEvent(db.Model):
event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
formsemestre_id = db.Column(
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
db.ForeignKey("notes_formsemestre.id", ondelete="SET NULL"),
)
ue_id = db.Column(
db.Integer,
db.ForeignKey("notes_ue.id"),
db.ForeignKey("notes_ue.id", ondelete="SET NULL"),
)
# 'CREATION', 'INSCRIPTION', 'DEMISSION',
# 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM'
@ -156,8 +175,16 @@ class ScolarEvent(db.Model):
db.Integer,
db.ForeignKey("notes_formsemestre.id"),
)
etud = db.relationship("Identite", lazy="select", backref="events", uselist=False)
formsemestre = db.relationship(
"FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id]
)
def to_dict(self) -> dict:
"as a dict"
d = dict(self.__dict__)
d.pop("_sa_instance_state", None)
return d
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.event_type}, {self.event_date.isoformat()}, {self.formsemestre})"

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -253,7 +253,7 @@ def get_annotation_PE(etudid, tag_annotation_pe):
) # Suppression du tag d'annotation PE
annotationPE = annotationPE.replace("\r", "") # Suppression des \r
annotationPE = annotationPE.replace(
"<br/>", "\n\n"
"<br>", "\n\n"
) # Interprète les retours chariots html
return annotationPE
return "" # pas d'annotations

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -48,11 +48,11 @@ from zipfile import ZipFile
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
from app.models import Formation, FormSemestre
from app.scodoc.gen_tables import GenTable, SeqGenTable
import app.scodoc.sco_utils as scu
from app.scodoc import sco_codes_parcours # sco_codes_parcours.NEXT -> sem suivant
from app.scodoc import codes_cursus # codes_cursus.NEXT -> sem suivant
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre
from app.pe import pe_tagtable
@ -65,10 +65,8 @@ def comp_nom_semestre_dans_parcours(sem):
"""Le nom a afficher pour titrer un semestre
par exemple: "semestre 2 FI 2015"
"""
from app.scodoc import sco_formations
F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0]
parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"])
formation: Formation = Formation.query.get_or_404(sem["formation_id"])
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
return "%s %s %s %s" % (
parcours.SESSION_NAME, # eg "semestre"
sem["semestre_id"], # eg 2
@ -457,10 +455,9 @@ class JuryPE(object):
reponse = False
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
(_, parcours) = sco_report.get_codeparcoursetud(etud)
(_, parcours) = sco_report.get_code_cursus_etud(etud)
if (
len(set(sco_codes_parcours.CODES_SEM_REO.keys()) & set(parcours.values()))
> 0
len(codes_cursus.CODES_SEM_REO & set(parcours.values())) > 0
): # Eliminé car NAR apparait dans le parcours
reponse = True
if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2:
@ -529,14 +526,14 @@ class JuryPE(object):
from app.scodoc import sco_report
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
(code, parcours) = sco_report.get_codeparcoursetud(
(code, parcours) = sco_report.get_code_cursus_etud(
etud
) # description = '1234:A', parcours = {1:ADM, 2:NAR, ...}
sonDernierSemestreValide = max(
[
int(cle)
for (cle, code) in parcours.items()
if code in sco_codes_parcours.CODES_SEM_VALIDES
if code in codes_cursus.CODES_SEM_VALIDES
]
+ [0]
) # n° du dernier semestre valide, 0 sinon
@ -563,9 +560,8 @@ class JuryPE(object):
dec = nt.get_etud_decision_sem(
etudid
) # quelle est la décision du jury ?
if dec and dec["code"] in list(
sco_codes_parcours.CODES_SEM_VALIDES.keys()
): # isinstance( sesMoyennes[i+1], float) and
if dec and (dec["code"] in codes_cursus.CODES_SEM_VALIDES):
# isinstance( sesMoyennes[i+1], float) and
# mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide"
leFid = sem["formsemestre_id"]
else:
@ -1139,7 +1135,7 @@ class JuryPE(object):
# ------------------------------------------------------------------------------------------------------------------
def get_cache_notes_d_un_semestre(self, formsemestre_id: int) -> NotesTableCompat:
"""Charge la table des notes d'un formsemestre"""
formsemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
return res_sem.load_formsemestre_results(formsemestre)
# ------------------------------------------------------------------------------------------------------------------

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -41,11 +41,12 @@ from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
from app.models.moduleimpls import ModuleImpl
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_tag_module
from app.pe import pe_tagtable
from app.scodoc import codes_cursus
from app.scodoc import sco_tag_module
from app.scodoc import sco_utils as scu
class SemestreTag(pe_tagtable.TableTag):
"""Un SemestreTag représente un tableau de notes (basé sur notesTable)
@ -103,7 +104,7 @@ class SemestreTag(pe_tagtable.TableTag):
self.inscrlist = [
etud
for etud in self.nt.inscrlist
if self.nt.get_etud_etat(etud["etudid"]) == "I"
if self.nt.get_etud_etat(etud["etudid"]) == scu.INSCRIT
]
self.identdict = {
etudid: ident
@ -115,7 +116,7 @@ class SemestreTag(pe_tagtable.TableTag):
self.modimpls = [
modimpl
for modimpl in self.nt.formsemestre.modimpls_sorted
if modimpl.module.ue.type == sco_codes_parcours.UE_STANDARD
if modimpl.module.ue.type == codes_cursus.UE_STANDARD
] # la liste des modules (objet modimpl)
self.somme_coeffs = sum(
[
@ -255,7 +256,7 @@ class SemestreTag(pe_tagtable.TableTag):
# Si le module ne fait pas partie des UE capitalisées
if modimpl.module.ue.id not in ue_capitalisees_id:
note = self.nt.get_etud_mod_moy(modimpl_id, etudid) # lecture de la note
coeff = modimpl.module.coefficient # le coeff
coeff = modimpl.module.coefficient or 0.0 # le coeff (! non compatible BUT)
coeff_norm = (
coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0
) # le coeff normalisé
@ -276,7 +277,7 @@ class SemestreTag(pe_tagtable.TableTag):
fid_prec = fids_prec[0]
# Lecture des notes de ce semestre
# le tableau de note du semestre considéré:
formsemestre_prec = FormSemestre.query.get_or_404(fid_prec)
formsemestre_prec = FormSemestre.get_formsemestre(fid_prec)
nt_prec: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre_prec
)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -40,7 +40,7 @@ Created on Thu Sep 8 09:36:33 2016
import datetime
import numpy as np
from app.scodoc import notes_table
from app.scodoc import sco_utils as scu
class TableTag(object):
@ -186,7 +186,7 @@ class TableTag(object):
if isinstance(col[0], float)
else 0, # remplace les None et autres chaines par des zéros
) # triées
self.rangs[tag] = notes_table.comp_ranks(lesMoyennesTriees) # les rangs
self.rangs[tag] = scu.comp_ranks(lesMoyennesTriees) # les rangs
# calcul des stats
self.comp_stats_d_un_tag(tag)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -55,7 +55,7 @@ def _pe_view_sem_recap_form(formsemestre_id):
<p class="help">
Cette fonction génère un ensemble de fichiers permettant d'éditer des avis de
poursuites d'études.
<br/>
<br>
De nombreux aspects sont paramétrables:
<a href="https://scodoc.org/AvisPoursuiteEtudes" target="_blank" rel="noopener">
voir la documentation</a>.
@ -65,7 +65,7 @@ def _pe_view_sem_recap_form(formsemestre_id):
<div class="pe_template_up">
Les templates sont généralement installés sur le serveur ou dans le
paramétrage de ScoDoc.
<br/>
<br>
Au besoin, vous pouvez spécifier ici votre propre fichier de template
(<tt>un_avis.tex</tt>):
<div class="pe_template_upb">Template:

43
app/profiler.py Normal file
View File

@ -0,0 +1,43 @@
from time import time
from datetime import datetime
class Profiler:
OUTPUT: str = "/tmp/scodoc.profiler.csv"
def __init__(self, tag: str) -> None:
self.tag: str = tag
self.start_time: time = None
self.stop_time: time = None
def start(self):
self.start_time = time()
return self
def stop(self):
self.stop_time = time()
return self
def elapsed(self) -> float:
return self.stop_time - self.start_time
def dates(self) -> tuple[datetime, datetime]:
return datetime.fromtimestamp(self.start_time), datetime.fromtimestamp(
self.stop_time
)
def write(self):
with open(Profiler.OUTPUT, "a") as file:
dates: tuple = self.dates()
date_str = (dates[0].isoformat(), dates[1].isoformat())
file.write(f"\n{self.tag},{self.elapsed() : .2}")
@classmethod
def write_in(cls, msg: str):
with open(cls.OUTPUT, "a") as file:
file.write(f"\n# {msg}")
@classmethod
def clear(cls):
with open(cls.OUTPUT, "w") as file:
file.write("")

View File

@ -10,6 +10,11 @@
"""
import html
import re
import flask_wtf
import wtforms
from app import log
from app.scodoc.sco_exceptions import ScoInvalidCSRF
import app.scodoc.sco_utils as scu
# re validant dd/mm/yyyy
@ -22,7 +27,7 @@ def TrivialFormulator(
form_url,
values,
formdescription=(),
initvalues={},
initvalues=None,
method="post",
enctype=None,
submitlabel="OK",
@ -32,12 +37,15 @@ def TrivialFormulator(
cssclass="",
cancelbutton=None,
submitbutton=True,
submitbuttonattributes=[],
submitbuttonattributes=None,
top_buttons=False, # place buttons at top of form
bottom_buttons=True, # buttons after form
html_foot_markup="",
readonly=False,
is_submitted=False,
title="",
after_table="",
before_table="{title}",
):
"""
form_url : URL for this form
@ -74,7 +82,8 @@ def TrivialFormulator(
HTML elements:
input_type : 'text', 'textarea', 'password',
'radio', 'menu', 'checkbox',
'hidden', 'separator', 'file', 'date', 'datedmy' (avec validation),
'hidden', 'separator', 'table_separator',
'file', 'date', 'datedmy' (avec validation),
'boolcheckbox', 'text_suggest',
'color'
(default text)
@ -95,7 +104,7 @@ def TrivialFormulator(
form_url,
values,
formdescription,
initvalues,
initvalues or {},
method,
enctype,
submitlabel,
@ -105,12 +114,15 @@ def TrivialFormulator(
cssclass=cssclass,
cancelbutton=cancelbutton,
submitbutton=submitbutton,
submitbuttonattributes=submitbuttonattributes,
submitbuttonattributes=submitbuttonattributes or [],
top_buttons=top_buttons,
bottom_buttons=bottom_buttons,
html_foot_markup=html_foot_markup,
readonly=readonly,
is_submitted=is_submitted,
title=title,
after_table=after_table,
before_table=before_table,
)
form = t.getform()
if t.canceled():
@ -127,8 +139,8 @@ class TF(object):
self,
form_url,
values,
formdescription=[],
initvalues={},
formdescription=None,
initvalues=None,
method="POST",
enctype=None,
submitlabel="OK",
@ -138,17 +150,20 @@ class TF(object):
cssclass="",
cancelbutton=None,
submitbutton=True,
submitbuttonattributes=[],
submitbuttonattributes=None,
top_buttons=False, # place buttons at top of form
bottom_buttons=True, # buttons after form
html_foot_markup="", # html snippet put at the end, just after the table
readonly=False,
is_submitted=False,
title="",
after_table="",
before_table="{title}",
):
self.form_url = form_url
self.values = values.copy()
self.formdescription = list(formdescription)
self.initvalues = initvalues
self.formdescription = list(formdescription or [])
self.initvalues = initvalues or {}
self.method = method
self.enctype = enctype
self.submitlabel = submitlabel
@ -161,10 +176,13 @@ class TF(object):
self.cssclass = cssclass
self.cancelbutton = cancelbutton
self.submitbutton = submitbutton
self.submitbuttonattributes = submitbuttonattributes
self.submitbuttonattributes = submitbuttonattributes or []
self.top_buttons = top_buttons
self.bottom_buttons = bottom_buttons
self.html_foot_markup = html_foot_markup
self.title = title
self.after_table = after_table
self.before_table = before_table
self.readonly = readonly
self.result = None
self.is_submitted = is_submitted
@ -176,11 +194,26 @@ class TF(object):
"true if form has been submitted"
if self.is_submitted:
return True
return self.values.get("%s_submitted" % self.formid, False)
form_submitted = self.values.get(f"{self.formid}_submitted", False)
if form_submitted:
self.check_csrf()
return form_submitted
def check_csrf(self):
"""check token for POST forms.
Raises ScoInvalidCSRF on failure.
"""
if self.method == "post":
token = self.values.get("csrf_token")
try:
flask_wtf.csrf.validate_csrf(token)
except wtforms.validators.ValidationError as exc:
log(f"Form.check_csrf: invalid CSRF token\n{exc.args}")
raise ScoInvalidCSRF() from exc
def canceled(self):
"true if form has been canceled"
return self.values.get("%s_cancel" % self.formid, False)
return self.values.get(f"{self.formid}_cancel", False)
def getform(self):
"return HTML form"
@ -357,12 +390,23 @@ class TF(object):
self.values[field] = True
else:
self.values[field] = False
# open('/tmp/toto','a').write('checkvalues: val=%s (%s) values[%s] = %s\n' % (val, type(val), field, self.values[field]))
if descr.get("convert_numbers", False):
if typ[:3] == "int":
self.values[field] = int(self.values[field])
try:
self.values[field] = int(self.values[field])
except ValueError:
msg.append(
f"valeur invalide ({self.values[field]}) pour le champs {field}"
)
ok = False
elif typ == "float" or typ == "real":
self.values[field] = float(self.values[field].replace(",", "."))
try:
self.values[field] = float(self.values[field].replace(",", "."))
except ValueError:
msg.append(
f"valeur invalide ({self.values[field]}) pour le champs {field}"
)
ok = False
if ok:
self.result = self.values
else:
@ -423,9 +467,16 @@ class TF(object):
self.form_attrs,
)
)
R.append('<input type="hidden" name="%s_submitted" value="1">' % self.formid)
if self.method == "post":
R.append(
f"""<input type="hidden" name="csrf_token" value="{
flask_wtf.csrf.generate_csrf()
}">"""
)
R.append(f"""<input type="hidden" name="{self.formid}_submitted" value="1">""")
if self.top_buttons:
R.append(buttons_markup + "<p></p>")
R.append(self.before_table.format(title=self.title))
R.append('<table class="tf">')
for field, descr in self.formdescription:
if descr.get("readonly", False):
@ -453,6 +504,16 @@ class TF(object):
etempl = separatortemplate
R.append(etempl % {"label": title, "item_dom_attr": item_dom_attr})
continue
elif input_type == "table_separator":
etempl = ""
# Table ouverte ?
if len([p for p in R if "<table" in p]) > len(
[p for p in R if "</table" in p]
):
R.append(f"""</table>{self.after_table}""")
R.append(
f"""{self.before_table.format(title=descr.get("title", ""))}<table class="tf">"""
)
else:
etempl = itemtemplate
lab = []
@ -543,11 +604,8 @@ class TF(object):
disabled_items = descr.get("disabled_items", {})
if vertical:
lem.append("<table>")
for i in range(len(labels)):
for i in range(len(labels)): # pylint: disable=consider-using-enumerate
if input_type == "checkbox":
# from app.scodoc.sco_utils import log # debug only
# log('checkbox: values[%s] = "%s"' % (field,repr(values[field]) ))
# log("descr['allowed_values'][%s] = '%s'" % (i, repr(descr['allowed_values'][i])))
if (
values[field]
and descr["allowed_values"][i] in values[field]
@ -563,7 +621,7 @@ class TF(object):
else:
try:
v = int(values[field])
except:
except (ValueError, KeyError):
v = False
if v:
checked = 'checked="checked"'
@ -613,7 +671,7 @@ class TF(object):
'<input type="hidden" name="%s" id="%s" value="%s" %s >'
% (field, wid, values[field], attribs)
)
elif input_type == "separator":
elif (input_type == "separator") or (input_type == "table_separator"):
pass
elif input_type == "file":
lem.append(
@ -644,13 +702,15 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
)
lem.append(('value="%(' + field + ')s" >') % values)
else:
raise ValueError("unkown input_type for form (%s)!" % input_type)
raise ValueError(f"unkown input_type for form ({input_type})!")
explanation = descr.get("explanation", "")
if explanation:
lem.append('<span class="tf-explanation">%s</span>' % explanation)
lem.append(f"""<span class="tf-explanation">{explanation}</span>""")
comment = descr.get("comment", "")
if comment:
lem.append('<br/><span class="tf-comment">%s</span>' % comment)
if (input_type != "checkbox") and (input_type != "boolcheckbox"):
lem.append("<br>")
lem.append(f"""<span class="tf-comment">{comment}</span>""")
R.append(
etempl
% {
@ -660,11 +720,11 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
}
)
R.append("</table>")
R.append(self.after_table)
R.append(self.html_foot_markup)
if self.bottom_buttons:
R.append("<br/>" + buttons_markup)
R.append("<br>" + buttons_markup)
if add_no_enter_js:
R.append(
@ -756,7 +816,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
if input_type == "separator": # separator
R.append('<td colspan="2">%s' % title)
else:
elif input_type != "table_separator":
R.append('<td class="tf-ro-fieldlabel%s">' % klass)
R.append("%s</td>" % title)
R.append('<td class="tf-ro-field%s">' % klass)
@ -766,7 +826,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
elif input_type in ("radio", "menu", "checkbox", "boolcheckbox"):
if input_type == "boolcheckbox":
labels = descr.get(
"labels", descr.get("allowed_values", ["oui", "non"])
"labels", descr.get("allowed_values", ["non", "oui"])
)
_val = self.values[field]
if isinstance(_val, bool):
@ -789,7 +849,11 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
R.append(
'<div class="tf-ro-textarea">%s</div>' % html.escape(self.values[field])
)
elif input_type == "separator" or input_type == "hidden":
elif (
input_type == "separator"
or input_type == "hidden"
or input_type == "table_separator"
):
pass
elif input_type == "file":
R.append("'%s'" % self.values[field])

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -25,17 +25,20 @@
#
##############################################################################
"""Semestres: Codes gestion parcours (constantes)
"""Semestres: Codes gestion cursus (constantes)
Attention: ne pas confondre avec les "parcours" du BUT.
Renommage des anciens "parcours" -> "cursus" effectué en 9.4.41
"""
import collections
import enum
import numpy as np
from app import log
@enum.unique
class CodesParcours(enum.IntEnum):
"""Codes numériques des parcours, enregistrés en base
class CodesCursus(enum.IntEnum):
"""Codes numériques des cursus (ex parcours), enregistrés en base
dans notes_formations.type_parcours
Ne pas modifier.
"""
@ -77,7 +80,7 @@ UE_STANDARD = 0 # UE "fondamentale"
UE_SPORT = 1 # bonus "sport"
UE_STAGE_LP = 2 # ue "projet tuteuré et stage" dans les Lic. Pro.
UE_STAGE_10 = 3 # ue "stage" avec moyenne requise > 10
UE_ELECTIVE = 4 # UE "élective" dans certains parcours (UCAC?, ISCID)
UE_ELECTIVE = 4 # UE "élective" dans certains cursus (UCAC?, ISCID)
UE_PROFESSIONNELLE = 5 # UE "professionnelle" (ISCID, ...)
UE_OPTIONNELLE = 6 # UE non fondamentales (ILEPS, ...)
@ -120,6 +123,7 @@ ABL = "ABL"
ADM = "ADM" # moyenne gen., barres UE, assiduité: sem. validé
ADC = "ADC" # admis par compensation (eg moy(S1, S2) > 10)
ADJ = "ADJ" # admis par le jury
ADJR = "ADJR" # UE admise car son RCUE est ADJ
ATT = "ATT" #
ATJ = "ATJ" # pb assiduité: décision repoussée au semestre suivant
ATB = "ATB"
@ -156,6 +160,7 @@ CODES_EXPL = {
ABL: "Année blanche",
ADC: "Validé par compensation",
ADJ: "Validé par le Jury",
ADJR: "UE validée car son RCUE est validé ADJ par le jury",
ADM: "Validé",
AJ: "Ajourné (ou UE/BC de BUT en attente pour problème de moyenne)",
ATB: "Décision en attente d'un autre semestre (au moins une UE sous la barre)",
@ -183,16 +188,25 @@ CODES_EXPL = {
# Les codes de semestres:
CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT}
CODES_SEM_VALIDES = {ADM: True, ADC: True, ADJ: True} # semestre validé
CODES_SEM_ATTENTES = {ATT: True, ATB: True, ATJ: True} # semestre en attente
CODES_SEM_VALIDES_DE_DROIT = {ADM, ADC}
CODES_SEM_VALIDES = CODES_SEM_VALIDES_DE_DROIT | {ADJ} # semestre validé
CODES_SEM_ATTENTES = {ATT, ATB, ATJ} # semestre en attente
CODES_SEM_REO = {NAR: 1} # reorientation
CODES_SEM_REO = {NAR} # reorientation
CODES_UE_VALIDES_DE_DROIT = {ADM, CMP} # validation "de droit"
CODES_UE_VALIDES = CODES_UE_VALIDES_DE_DROIT | {ADJ, ADJR}
"UE validée"
CODES_UE_CAPITALISANTS = {ADM}
"UE capitalisée"
CODES_RCUE_VALIDES_DE_DROIT = {ADM, CMP}
CODES_RCUE_VALIDES = CODES_RCUE_VALIDES_DE_DROIT | {ADJ}
"Niveau RCUE validé"
CODES_UE_VALIDES = {ADM: True, CMP: True, ADJ: True} # UE validée
CODES_RCUE_VALIDES = CODES_UE_VALIDES # Niveau RCUE validé
# Pour le BUT:
CODES_ANNEE_BUT_VALIDES_DE_DROIT = {ADM, PASD}
CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL}
CODES_RCUE = {ADM, AJ, CMP}
BUT_BARRE_UE8 = 8.0 - NOTES_TOLERANCE
BUT_BARRE_UE = BUT_BARRE_RCUE = 10.0 - NOTES_TOLERANCE
BUT_RCUE_SUFFISANT = 8.0 - NOTES_TOLERANCE
@ -203,21 +217,36 @@ BUT_CODES_PASSAGE = {
PAS1NCI,
ATJ,
}
# les codes, du plus "défavorable" à l'étudiant au plus favorable:
# (valeur par défaut 0)
BUT_CODES_ORDERED = {
NAR: 0,
DEF: 0,
AJ: 10,
ATJ: 20,
CMP: 50,
ADC: 50,
PASD: 50,
PAS1NCI: 60,
ADJR: 90,
ADJ: 100,
ADM: 100,
}
def code_semestre_validant(code: str) -> bool:
"Vrai si ce CODE entraine la validation du semestre"
return CODES_SEM_VALIDES.get(code, False)
return code in CODES_SEM_VALIDES
def code_semestre_attente(code: str) -> bool:
"Vrai si ce CODE est un code d'attente (semestre validable plus tard par jury ou compensation)"
return CODES_SEM_ATTENTES.get(code, False)
return code in CODES_SEM_ATTENTES
def code_ue_validant(code: str) -> bool:
"Vrai si ce code d'UE est validant (ie attribue les ECTS)"
return CODES_UE_VALIDES.get(code, False)
return code in CODES_UE_VALIDES
DEVENIR_EXPL = {
@ -246,7 +275,7 @@ DEVENIRS_NEXT2 = {NEXT_OR_NEXT2: 1, NEXT2: 1}
NO_SEMESTRE_ID = -1 # code semestre si pas de semestres
# Règles gestion parcours
# Règles gestion cursus
class DUTRule(object):
def __init__(self, rule_id, premise, conclusion):
self.rule_id = rule_id
@ -268,12 +297,12 @@ class DUTRule(object):
return True
# Types de parcours
DEFAULT_TYPE_PARCOURS = 100 # pour le menu de creation nouvelle formation
# Types de cursus
DEFAULT_TYPE_CURSUS = 100 # pour le menu de creation nouvelle formation
class TypeParcours(object):
TYPE_PARCOURS = None # id, utilisé par notes_formation.type_parcours
class TypeCursus:
TYPE_CURSUS = None # id, utilisé par notes_formation.type_parcours
NAME = None # required
NB_SEM = 1 # Nombre de semestres
COMPENSATION_UE = True # inutilisé
@ -287,9 +316,9 @@ class TypeParcours(object):
SESSION_NAME = "semestre"
SESSION_NAME_A = "du "
SESSION_ABBRV = "S" # S1, S2, ...
UNUSED_CODES = set() # Ensemble des codes jury non autorisés dans ce parcours
UNUSED_CODES = set() # Ensemble des codes jury non autorisés dans ce cursus
UE_IS_MODULE = False # 1 seul module par UE (si plusieurs modules, etudiants censéments inscrits à un seul d'entre eux)
ECTS_ONLY = False # Parcours avec progression basée uniquement sur les ECTS
ECTS_ONLY = False # Cursus avec progression basée uniquement sur les ECTS
ALLOWED_UE_TYPES = list(
UE_TYPE_NAME.keys()
) # par defaut, autorise tous les types d'UE
@ -335,18 +364,18 @@ class TypeParcours(object):
return False, """<b>%d UE sous la barre</b>""" % n
# Parcours définis (instances de sous-classes de TypeParcours):
TYPES_PARCOURS = collections.OrderedDict() # type : Parcours
# Cursus définis (instances de sous-classes de TypeCursus):
SCO_CURSUS: dict[int, TypeCursus] = {} # type : Cursus
def register_parcours(Parcours):
TYPES_PARCOURS[int(Parcours.TYPE_PARCOURS)] = Parcours
def register_cursus(cursus: TypeCursus):
SCO_CURSUS[int(cursus.TYPE_CURSUS)] = cursus
class ParcoursBUT(TypeParcours):
class CursusBUT(TypeCursus):
"""BUT Bachelor Universitaire de Technologie"""
TYPE_PARCOURS = 700
TYPE_CURSUS = 700
NAME = "BUT"
NB_SEM = 6
COMPENSATION_UE = False
@ -355,63 +384,63 @@ class ParcoursBUT(TypeParcours):
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
register_parcours(ParcoursBUT())
register_cursus(CursusBUT())
class ParcoursDUT(TypeParcours):
class CursusDUT(TypeCursus):
"""DUT selon l'arrêté d'août 2005"""
TYPE_PARCOURS = 100
TYPE_CURSUS = 100
NAME = "DUT"
NB_SEM = 4
COMPENSATION_UE = True
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
register_parcours(ParcoursDUT())
register_cursus(CursusDUT())
class ParcoursDUT4(ParcoursDUT):
class CursusDUT4(CursusDUT):
"""DUT (en 4 semestres sans compensations)"""
TYPE_PARCOURS = 110
TYPE_CURSUS = 110
NAME = "DUT4"
COMPENSATION_UE = False
register_parcours(ParcoursDUT4())
register_cursus(CursusDUT4())
class ParcoursDUTMono(TypeParcours):
class CursusDUTMono(TypeCursus):
"""DUT en un an (FC, Années spéciales)"""
TYPE_PARCOURS = 120
TYPE_CURSUS = 120
NAME = "DUT"
NB_SEM = 1
COMPENSATION_UE = False
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursDUTMono())
register_cursus(CursusDUTMono())
class ParcoursDUT2(ParcoursDUT):
class CursusDUT2(CursusDUT):
"""DUT en deux semestres (par ex.: années spéciales semestrialisées)"""
TYPE_PARCOURS = CodesParcours.DUT2
TYPE_CURSUS = CodesCursus.DUT2
NAME = "DUT2"
NB_SEM = 2
register_parcours(ParcoursDUT2())
register_cursus(CursusDUT2())
class ParcoursLP(TypeParcours):
class CursusLP(TypeCursus):
"""Licence Pro (en un "semestre")
(pour anciennes LP. Après 2014, préférer ParcoursLP2014)
(pour anciennes LP. Après 2014, préférer CursusLP2014)
"""
TYPE_PARCOURS = CodesParcours.LP
TYPE_CURSUS = CodesCursus.LP
NAME = "LP"
NB_SEM = 1
COMPENSATION_UE = False
@ -422,35 +451,35 @@ class ParcoursLP(TypeParcours):
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursLP())
register_cursus(CursusLP())
class ParcoursLP2sem(ParcoursLP):
class CursusLP2sem(CursusLP):
"""Licence Pro (en deux "semestres")"""
TYPE_PARCOURS = CodesParcours.LP2sem
TYPE_CURSUS = CodesCursus.LP2sem
NAME = "LP2sem"
NB_SEM = 2
COMPENSATION_UE = True
UNUSED_CODES = set((ADC,)) # autorise les codes ATT et ATB, mais pas ADC.
register_parcours(ParcoursLP2sem())
register_cursus(CursusLP2sem())
class ParcoursLP2semEvry(ParcoursLP):
class CursusLP2semEvry(CursusLP):
"""Licence Pro (en deux "semestres", U. Evry)"""
TYPE_PARCOURS = CodesParcours.LP2semEvry
TYPE_CURSUS = CodesCursus.LP2semEvry
NAME = "LP2semEvry"
NB_SEM = 2
COMPENSATION_UE = True
register_parcours(ParcoursLP2semEvry())
register_cursus(CursusLP2semEvry())
class ParcoursLP2014(TypeParcours):
class CursusLP2014(TypeCursus):
"""Licence Pro (en un "semestre"), selon arrêté du 22/01/2014"""
# Note: texte de référence
@ -467,7 +496,7 @@ class ParcoursLP2014(TypeParcours):
# l'établissement d'un coefficient qui peut varier dans un rapport de 1 à 3. ", etc ne sont _pas_
# vérifiés par ScoDoc)
TYPE_PARCOURS = CodesParcours.LP2014
TYPE_CURSUS = CodesCursus.LP2014
NAME = "LP2014"
NB_SEM = 1
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT, UE_STAGE_LP]
@ -487,7 +516,7 @@ class ParcoursLP2014(TypeParcours):
(ue_status["moy"], ue_status["coef_ue"])
for ue_status in ues_status
if ue_status["ue"]["type"] == UE_STAGE_LP
and type(ue_status["moy"]) == float
and np.issubdtype(type(ue_status["moy"]), np.floating)
]
# Moyenne des moyennes:
sum_coef = sum(x[1] for x in mc_stages_proj)
@ -505,74 +534,74 @@ class ParcoursLP2014(TypeParcours):
return True, "" # pas de coef, condition ok
register_parcours(ParcoursLP2014())
register_cursus(CursusLP2014())
class ParcoursLP2sem2014(ParcoursLP):
class CursusLP2sem2014(CursusLP):
"""Licence Pro (en deux "semestres", selon arrêté du 22/01/2014)"""
TYPE_PARCOURS = CodesParcours.LP2sem2014
TYPE_CURSUS = CodesCursus.LP2sem2014
NAME = "LP2014_2sem"
NB_SEM = 2
register_parcours(ParcoursLP2sem2014())
register_cursus(CursusLP2sem2014())
# Masters: M2 en deux semestres
class ParcoursM2(TypeParcours):
class CursusM2(TypeCursus):
"""Master 2 (en deux "semestres")"""
TYPE_PARCOURS = CodesParcours.M2
TYPE_CURSUS = CodesCursus.M2
NAME = "M2sem"
NB_SEM = 2
COMPENSATION_UE = True
UNUSED_CODES = set((ATT, ATB))
register_parcours(ParcoursM2())
register_cursus(CursusM2())
class ParcoursM2noncomp(ParcoursM2):
class CursusM2noncomp(CursusM2):
"""Master 2 (en deux "semestres") sans compensation"""
TYPE_PARCOURS = CodesParcours.M2noncomp
TYPE_CURSUS = CodesCursus.M2noncomp
NAME = "M2noncomp"
COMPENSATION_UE = False
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursM2noncomp())
register_cursus(CursusM2noncomp())
class ParcoursMono(TypeParcours):
class CursusMono(TypeCursus):
"""Formation générique en une session"""
TYPE_PARCOURS = CodesParcours.Mono
TYPE_CURSUS = CodesCursus.Mono
NAME = "Mono"
NB_SEM = 1
COMPENSATION_UE = False
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursMono())
register_cursus(CursusMono())
class ParcoursLegacy(TypeParcours):
class CursusLegacy(TypeCursus):
"""DUT (ancien ScoDoc, ne plus utiliser)"""
TYPE_PARCOURS = CodesParcours.Legacy
TYPE_CURSUS = CodesCursus.Legacy
NAME = "DUT"
NB_SEM = 4
COMPENSATION_UE = None # backward compat: defini dans formsemestre
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
register_parcours(ParcoursLegacy())
register_cursus(CursusLegacy())
class ParcoursISCID(TypeParcours):
"""Superclasse pour les parcours de l'ISCID"""
class CursusISCID(TypeCursus):
"""Superclasse pour les cursus de l'ISCID"""
# SESSION_NAME = "année"
# SESSION_NAME_A = "de l'"
@ -591,32 +620,32 @@ class ParcoursISCID(TypeParcours):
ECTS_PROF_DIPL = 0 # crédits professionnels requis pour obtenir le diplôme
class ParcoursBachelorISCID6(ParcoursISCID):
class CursusBachelorISCID6(CursusISCID):
"""ISCID: Bachelor en 3 ans (6 sem.)"""
NAME = "ParcoursBachelorISCID6"
TYPE_PARCOURS = CodesParcours.ISCID6
NAME = "CursusBachelorISCID6"
TYPE_CURSUS = CodesCursus.ISCID6
NAME = ""
NB_SEM = 6
ECTS_PROF_DIPL = 8 # crédits professionnels requis pour obtenir le diplôme
register_parcours(ParcoursBachelorISCID6())
register_cursus(CursusBachelorISCID6())
class ParcoursMasterISCID4(ParcoursISCID):
class CursusMasterISCID4(CursusISCID):
"ISCID: Master en 2 ans (4 sem.)"
TYPE_PARCOURS = CodesParcours.ISCID4
NAME = "ParcoursMasterISCID4"
TYPE_CURSUS = CodesCursus.ISCID4
NAME = "CursusMasterISCID4"
NB_SEM = 4
ECTS_PROF_DIPL = 15 # crédits professionnels requis pour obtenir le diplôme
register_parcours(ParcoursMasterISCID4())
register_cursus(CursusMasterISCID4())
class ParcoursILEPS(TypeParcours):
"""Superclasse pour les parcours de l'ILEPS"""
class CursusILEPS(TypeCursus):
"""Superclasse pour les cursus de l'ILEPS"""
# SESSION_NAME = "année"
# SESSION_NAME_A = "de l'"
@ -632,18 +661,18 @@ class ParcoursILEPS(TypeParcours):
BARRE_UE_DEFAULT = 0.0 # pas de barre sur les autres UE
class ParcoursLicenceILEPS6(ParcoursILEPS):
class CursusLicenceILEPS6(CursusILEPS):
"""ILEPS: Licence 6 semestres"""
TYPE_PARCOURS = 1010
TYPE_CURSUS = 1010
NAME = "LicenceILEPS6"
NB_SEM = 6
register_parcours(ParcoursLicenceILEPS6())
register_cursus(CursusLicenceILEPS6())
class ParcoursUCAC(TypeParcours):
class CursusUCAC(TypeCursus):
"""Règles de validation UCAC"""
SESSION_NAME = "année"
@ -657,79 +686,79 @@ class ParcoursUCAC(TypeParcours):
)
class ParcoursLicenceUCAC3(ParcoursUCAC):
class CursusLicenceUCAC3(CursusUCAC):
"""UCAC: Licence en 3 sessions d'un an"""
TYPE_PARCOURS = CodesParcours.LicenceUCAC3
TYPE_CURSUS = CodesCursus.LicenceUCAC3
NAME = "Licence UCAC en 3 sessions d'un an"
NB_SEM = 3
register_parcours(ParcoursLicenceUCAC3())
register_cursus(CursusLicenceUCAC3())
class ParcoursMasterUCAC2(ParcoursUCAC):
class CursusMasterUCAC2(CursusUCAC):
"""UCAC: Master en 2 sessions d'un an"""
TYPE_PARCOURS = CodesParcours.MasterUCAC2
TYPE_CURSUS = CodesCursus.MasterUCAC2
NAME = "Master UCAC en 2 sessions d'un an"
NB_SEM = 2
register_parcours(ParcoursMasterUCAC2())
register_cursus(CursusMasterUCAC2())
class ParcoursMonoUCAC(ParcoursUCAC):
class CursusMonoUCAC(CursusUCAC):
"""UCAC: Formation en 1 session de durée variable"""
TYPE_PARCOURS = CodesParcours.MonoUCAC
TYPE_CURSUS = CodesCursus.MonoUCAC
NAME = "Formation UCAC en 1 session de durée variable"
NB_SEM = 1
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursMonoUCAC())
register_cursus(CursusMonoUCAC())
class Parcours6Sem(TypeParcours):
"""Parcours générique en 6 semestres"""
class Cursus6Sem(TypeCursus):
"""Cursus générique en 6 semestres"""
TYPE_PARCOURS = CodesParcours.GEN_6_SEM
TYPE_CURSUS = CodesCursus.GEN_6_SEM
NAME = "Formation en 6 semestres"
NB_SEM = 6
COMPENSATION_UE = True
register_parcours(Parcours6Sem())
register_cursus(Cursus6Sem())
# # En cours d'implémentation:
# class ParcoursLicenceLMD(TypeParcours):
# class CursusLicenceLMD(TypeCursus):
# """Licence standard en 6 semestres dans le LMD"""
# TYPE_PARCOURS = 401
# TYPE_CURSUS = 401
# NAME = "Licence LMD"
# NB_SEM = 6
# COMPENSATION_UE = True
# register_parcours(ParcoursLicenceLMD())
# register_cursus(CursusLicenceLMD())
class ParcoursMasterLMD(TypeParcours):
class CursusMasterLMD(TypeCursus):
"""Master générique en 4 semestres dans le LMD"""
TYPE_PARCOURS = CodesParcours.MasterLMD
TYPE_CURSUS = CodesCursus.MasterLMD
NAME = "Master LMD"
NB_SEM = 4
COMPENSATION_UE = True # variabale inutilisée
UNUSED_CODES = set((ADC, ATT, ATB))
register_parcours(ParcoursMasterLMD())
register_cursus(CursusMasterLMD())
class ParcoursMasterIG(ParcoursMasterLMD):
class CursusMasterIG(CursusMasterLMD):
"""Master de l'Institut Galilée (U. Paris 13) en 4 semestres (LMD)"""
TYPE_PARCOURS = CodesParcours.MasterIG
TYPE_CURSUS = CodesCursus.MasterIG
NAME = "Master IG P13"
BARRE_MOY = 10.0
NOTES_BARRE_VALID_UE_TH = 10.0 # seuil pour valider UE
@ -739,7 +768,7 @@ class ParcoursMasterIG(ParcoursMasterLMD):
BARRE_MOY_UE_STAGE = 10.0
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT, UE_STAGE_10]
def check_barre_ues(self, ues_status): # inspire de la fonction de ParcoursLP2014
def check_barre_ues(self, ues_status): # inspire de la fonction de CursusLP2014
"""True si la ou les conditions sur les UE sont valides
moyenne d'UE > 7, ou > 10 si UE de stage
"""
@ -778,10 +807,10 @@ class ParcoursMasterIG(ParcoursMasterLMD):
return True, "" # pas de coef, condition ok
register_parcours(ParcoursMasterIG())
register_cursus(CursusMasterIG())
# Ajouter ici vos parcours, le TYPE_PARCOURS devant être unique au monde
# Ajouter ici vos cursus, le TYPE_CURSUS devant être unique au monde
# (avisez sur la liste de diffusion)
@ -789,16 +818,17 @@ register_parcours(ParcoursMasterIG())
# -------------------------
_tp = list(TYPES_PARCOURS.items())
_tp = list(SCO_CURSUS.items())
_tp.sort(key=lambda x: x[1].__doc__) # sort by intitulé
FORMATION_PARCOURS_DESCRS = [p[1].__doc__ for p in _tp] # intitulés (eg pour menu)
FORMATION_PARCOURS_TYPES = [p[0] for p in _tp] # codes numeriques (TYPE_PARCOURS)
FORMATION_CURSUS_DESCRS = [p[1].__doc__ for p in _tp] # intitulés (eg pour menu)
FORMATION_CURSUS_TYPES = [p[0] for p in _tp] # codes numeriques (TYPE_CURSUS)
def get_parcours_from_code(code_parcours):
parcours = TYPES_PARCOURS.get(code_parcours)
if parcours is None:
log(f"Warning: invalid code_parcours: {code_parcours}")
def get_cursus_from_code(code_cursus: int) -> TypeCursus:
"renvoie le cursus de code indiqué"
cursus = SCO_CURSUS.get(code_cursus)
if cursus is None:
log(f"Warning: invalid code_cursus: {code_cursus}")
# default to legacy
parcours = TYPES_PARCOURS.get(0)
return parcours
cursus = SCO_CURSUS.get(0)
return cursus

View File

@ -4,7 +4,7 @@
#
# Command: ./csv2rules.py misc/parcoursDUT.csv
#
from app.scodoc.sco_codes_parcours import (
from app.scodoc.codes_cursus import (
DUTRule,
ADC,
ADJ,

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -251,7 +251,7 @@ def sco_header(
#gtrcontent {{
margin-left: {params["margin_left"]};
height: 100%%;
margin-bottom: 10px;
margin-bottom: 16px;
}}
</style>
"""
@ -274,21 +274,11 @@ def sco_header(
H.append("""<div id="gtrcontent">""")
# En attendant le replacement complet de cette fonction,
# inclusion ici des messages flask
H.append(render_template("flashed_messages.html"))
H.append(render_template("flashed_messages.j2"))
#
# Barre menu semestre:
H.append(formsemestre_page_title(formsemestre_id))
# Avertissement si mot de passe à changer
if user_check:
if current_user.passwd_temp:
H.append(
f"""<div class="passwd_warn">
Attention !<br/>
Vous avez reçu un mot de passe temporaire.<br/>
Vous devez le changer: <a href="{scu.UsersURL}/form_change_password?user_name={current_user.user_name}">cliquez ici</a>
</div>"""
)
#
if head_message:
H.append('<div class="head_message">' + html.escape(head_message) + "</div>")

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -48,26 +48,26 @@ def sidebar_common():
url_for("users.user_info_page",
scodoc_dept=g.scodoc_dept, user_name=current_user.user_name)
}">{current_user.user_name}</a>
<br/><a id="deconnectlink" href="{url_for("auth.logout")}">déconnexion</a>
<br><a id="deconnectlink" href="{url_for("auth.logout")}">déconnexion</a>
</div>
{sidebar_dept()}
<h2 class="insidebar">Scolarité</h2>
<a href="{scu.ScoURL()}" class="sidebar">Semestres</a> <br/>
<a href="{scu.NotesURL()}" class="sidebar">Programmes</a> <br/>
<a href="{scu.AbsencesURL()}" class="sidebar">Absences</a> <br/>
<a href="{scu.ScoURL()}" class="sidebar">Semestres</a> <br>
<a href="{scu.NotesURL()}" class="sidebar">Programmes</a> <br>
<a href="{scu.AbsencesURL()}" class="sidebar">Absences</a> <br>
"""
]
if current_user.has_permission(
Permission.ScoUsersAdmin
) or current_user.has_permission(Permission.ScoUsersView):
H.append(
f"""<a href="{scu.UsersURL()}" class="sidebar">Utilisateurs</a> <br/>"""
f"""<a href="{scu.UsersURL()}" class="sidebar">Utilisateurs</a> <br>"""
)
if current_user.has_permission(Permission.ScoChangePreferences):
H.append(
f"""<a href="{url_for("scolar.edit_preferences", scodoc_dept=g.scodoc_dept)}"
class="sidebar">Paramétrage</a> <br/>"""
class="sidebar">Paramétrage</a> <br>"""
)
return "".join(H)
@ -84,7 +84,7 @@ def sidebar(etudid: int = None):
H = [
f"""<div class="sidebar">
{ sidebar_common() }
<div class="box-chercheetud">Chercher étudiant:<br/>
<div class="box-chercheetud">Chercher étudiant:<br>
<form method="get" id="form-chercheetud"
action="{url_for('scolar.search_etud_in_dept', scodoc_dept=g.scodoc_dept) }">
<div><input type="text" size="12" class="in-expnom" name="expnom" spellcheck="false"></input></div>
@ -101,7 +101,6 @@ def sidebar(etudid: int = None):
etudid = request.form.get("etudid", None)
if etudid is not None:
etudi = int(etudid)
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
params.update(etud)
params["fiche_url"] = url_for(
@ -121,7 +120,7 @@ def sidebar(etudid: int = None):
nbabsnj = nbabs - nbabsjust
H.append(
f"""<span title="absences du { cur_sem["date_debut"] } au { cur_sem["date_fin"] }">(1/2 j.)
<br/>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
<br>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
)
H.append("<ul>")
if current_user.has_permission(Permission.ScoAbsChange):
@ -150,7 +149,7 @@ def sidebar(etudid: int = None):
# Logo
H.append(
f"""<div class="logo-insidebar">
<div class="sidebar-bottom"><a href="{ url_for( 'scodoc.about', scodoc_dept=g.scodoc_dept ) }" class="sidebar">À propos</a><br/>
<div class="sidebar-bottom"><a href="{ url_for( 'scodoc.about', scodoc_dept=g.scodoc_dept ) }" class="sidebar">À propos</a><br>
<a href="{ scu.SCO_USER_MANUAL }" target="_blank" class="sidebar">Aide</a>
</div></div>
<div class="logo-logo">
@ -167,6 +166,6 @@ def sidebar(etudid: int = None):
def sidebar_dept():
"""Partie supérieure de la marge de gauche"""
return render_template(
"sidebar_dept.html",
"sidebar_dept.j2",
prefs=sco_preferences.SemPreferences(),
)

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -83,7 +83,7 @@ def histogram_notes(notes):
return "\n".join(D)
def make_menu(title, items, css_class="", alone=False):
def make_menu(title, items, css_class="", alone=False) -> str:
"""HTML snippet to render a simple drop down menu.
items is a list of dicts:
{ 'title' :

File diff suppressed because it is too large Load Diff

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