1
0
forked from ScoDoc/ScoDoc

Compare commits

...

274 Commits

Author SHA1 Message Date
8ebe3fa6f3 Merge branch 'modif' of https://scodoc.org/git/iziram/ScoDoc into iziram-rev 2023-02-11 03:40:17 +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
5a58346282 Merge branch 'modif' of https://scodoc.org/git/iziram/ScoDoc into iziram-rev 2023-02-04 00:07:36 +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
2fc978e515 Merge branch 'modif' of https://scodoc.org/git/iziram/ScoDoc into iziram-rev 2023-02-03 15:45:24 +01:00
iziram
4d72fec42d correction etat branche + tests unitaire + tests api 2023-02-03 14:51:05 +01:00
a63e14ce06 Améliore messages d'erreur si maquette Apo mal encodée 2023-02-03 11:41:09 +01:00
ba909d72f0 Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2023-02-03 11:40:52 +01:00
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
0f8998c891 Fix: suppression semestre avec dispense UE 2023-02-03 08:42:25 +01:00
be367de2a1 Modif message tableau bord sem. 2023-02-03 08:42:25 +01:00
e81ad610b6 typo 2023-02-03 08:42:25 +01:00
0f45101000 get_formsemestre => ScoValueError 2023-02-03 08:42:25 +01:00
1224b46846 Améliore messages d'erreur si maquette Apo mal encodée 2023-02-03 08:42:25 +01:00
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
338c24a9c1 Archiver: dept variable 2023-02-02 07:09:45 -03:00
43849007fb Merge branch 'modif' of https://scodoc.org/git/iziram/ScoDoc into iziram-rev 2023-02-02 06:42:57 -03: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
86f5751e79 version 2023-02-01 14:56:16 +01:00
b160f64e4f 9.4.35 2023-02-01 14:56:16 +01:00
ee2ac9d986 Test API: fix (login utilisateur unique) 2023-02-01 14:56:16 +01:00
fc78484186 Fix test API formation et ajout d'un test (test_formation_export_with_ids) 2023-02-01 14:56:16 +01:00
21b5474a6f Fix test unit: test_formations 2023-02-01 14:56:15 +01:00
3c1acc9c00 Améliore messages d'erreur si maquette Apo mal encodée 2023-02-01 14:56:15 +01:00
2548a97515 Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2023-02-01 14:56:00 +01:00
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
3998b5a366 Table recap: efface données client cachées si erreur. 2023-01-31 15:25:55 +01:00
728010bf69 Templates Jinja2: extension .j2 au lieu de .html 2023-01-31 15:25:55 +01:00
e9f23d8b3e Suppression de toutes les décisions de jury d'un semestre 2023-01-31 15:25:55 +01:00
f6d442beb4 Bonus IUT Valencienne 2023-01-31 15:25:55 +01:00
b19c94a1f4 Ajout champ commentaire dans les formations (=> migration) 2023-01-31 15:25:55 +01:00
9fb70aef5d Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2023-01-31 15:25:55 +01:00
c8c05ecd77 BUT: dispenses d'UE capitalisées. Voir #537. 2023-01-31 15:25:55 +01:00
efa8f617bb Merge branch 'modif' of https://scodoc.org/git/iziram/ScoDoc into iziram-rev 2023-01-31 07:19:21 -03:00
iziram
02ec55ca18 - Modifi comportement Batch
- Ajout description assiduite
2023-01-30 15:12:31 +01:00
b30d5eb996 Bonus Besançon - Vesoul 2023-01-30 13:33:08 +01:00
e33bc1e303 Modernise code (evals) 2023-01-30 13:33:08 +01:00
d2362c1080 Fix: saisie auto sur étud. redoublant puis défaillant 2023-01-30 13:33:08 +01:00
6e4bf424e5 Form. passage: # 566 2023-01-30 13:33:08 +01:00
2170b06e33 Améliore qq explications 2023-01-30 13:33:08 +01:00
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
9880645c01 Jurys BUT: modif. autorisations passage. Cosmetic css. 2023-01-30 13:33:08 +01:00
4d0ea06559 Améliore import/export formations APC. 2023-01-30 13:33:08 +01:00
f46e3f6db5 typo 2023-01-30 13:33:08 +01:00
4d66fb13ee Fix: tri noms étudiants accentués sur form saisie notes 2023-01-30 13:33:08 +01:00
138f9597f5 Fix: avis poursuites études 2023-01-30 13:33:08 +01:00
91e8c9185b Fix #578 API : Gestion semestre verrouillé. + tests unitaires API OK. 2023-01-30 13:33:08 +01:00
f3b2c6d4fe Fix #564 Passage de semestre: inscrire aux groupes de parcours si ils existent 2023-01-30 13:33:07 +01:00
7277c9f999 critical errors handler (xp) 2023-01-30 13:33:07 +01:00
9a19919bae Fix #570 liens PREV/NEXT si un seul étudiant dans le semestre 2023-01-30 13:33:07 +01:00
d97c0c08aa Lien navigation sur jury BUT sem. impair 2023-01-30 13:33:07 +01:00
325978a175 Fix #571 cursus BUT avec validation antérieure 2023-01-30 13:33:07 +01:00
135ca9fc1c Fix #576: invalidation cache lors des modifs assoc UE/refcomp 2023-01-30 13:33:07 +01:00
a4072efe4c Saisie automatique des décisions de jury BUT pour semestres pairs ou impairs. 2023-01-30 13:33:07 +01:00
4430eb9a61 Fix: missing import 2023-01-30 13:33:07 +01:00
073c3c7c44 Fix #573 (API set group) 2023-01-30 13:33:07 +01:00
75b87b24de Fix #569 front: ADJR 2023-01-30 13:33:07 +01:00
e0f6b022b1 Fix #568: affichage cursus 2023-01-30 13:33:07 +01:00
98c6761f6a Fix #572: Affichage date dans table Description du semestre 2023-01-30 13:33:07 +01:00
53514ef919 Envoi mail bulletins: bcc multiple addr. 2023-01-30 13:33:07 +01:00
294ce1d708 Fix: ordre col. tableau recap (groupe admission) 2023-01-30 13:33:07 +01:00
cf63e1c038 Fix: affichage cursus BUT si un sem n'a pas de ref. comp. 2023-01-30 13:33:07 +01:00
584a7af2a1 Jury et cursus BUT: ajout d'informations + modif fiche étudiant 2023-01-30 13:33:07 +01:00
635320fd62 Tests cas S1/S2/S1-red 2023-01-30 13:33:07 +01:00
6867974957 Form config APo: ADJR 2023-01-30 13:33:07 +01:00
6ad415dfca Amélioration tests. Cas geii84 OK 2023-01-30 13:33:07 +01:00
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
89948db135 Vérifie type id pour vues ScoDoc7 2023-01-30 13:33:07 +01:00
bdf90dfd69 WIP: Affichage validation cursus BUT sur page etudiant. 2023-01-30 13:33:07 +01:00
0b9c9be222 Fix: list_but_ue_inscriptions si aucun étudiant. 2023-01-30 13:33:07 +01:00
b5cf210112 Ajout ligne avec type sur table recap. Voir #561. Mais pas exporté en Excel. 2023-01-30 13:33:07 +01:00
6833a28274 Fix ordre colonnes res/sae dans tableau recap. 2023-01-30 13:33:07 +01:00
5753ac92f4 JS table recap: initialisation des labels des boutons 2023-01-30 13:33:07 +01:00
16cc35f63c Pas de warning si UE/module bonus non associé à un niveau 2023-01-30 13:33:07 +01:00
5e0922a4bf Fix #559 2023-01-30 13:33:07 +01:00
10148bc7c0 Affichage table recap BUT si pas de moyenne générale: pas de rangs 2023-01-30 13:33:07 +01:00
556d8e7cbf Implémente #557 2023-01-30 13:33:07 +01:00
c6b2af5635 moved code example 2023-01-30 13:33:06 +01:00
cc0c544519 Exemple utilisation sco_archive 2023-01-30 13:33:06 +01:00
71116e6b39 Dispenses d'UE BUT associées à un formsemestre 2023-01-30 13:33:06 +01:00
3121a6d54c BUT: dispenses d'UE / jury avec RCUE incomplet 2023-01-30 13:33:06 +01:00
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
5fc08b9716 form inscription/desinscription à toutes les UEs du BUT 2023-01-30 13:33:06 +01:00
e7559b7a78 Bulletins BUT json: ajout champs block_moyenne_generale et bgcolor 2023-01-30 13:33:06 +01:00
d8a98b6e5b msg erreur 2023-01-30 13:33:06 +01:00
1287aecc4b Fix: cascades sur modimpls 2023-01-30 13:33:06 +01:00
549323e781 Saisie notes : masquer DEM & ne pas copier coller 2023-01-30 13:33:06 +01:00
0ff5fa46d9 Améliore saisie 'automatique' des décisions BUT 2023-01-30 13:33:06 +01:00
8d124eca3e Modif log en mode TEST 2023-01-30 13:33:06 +01:00
6a7638d7ff Log enregistrement jurys BUT 2023-01-30 13:33:06 +01:00
452bbf2885 typo 2023-01-30 13:33:06 +01:00
4915852d66 Jury BUT: fix enregistrement décisions + message cohérence 2023-01-30 13:33:06 +01:00
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
b8b3fbb324 Fix: bulletin avec UE sans ECTS 2023-01-30 13:33:06 +01:00
dd93d952d7 Tests YAML jury BUT: amélioration code test + yaml GEII Lyon ok 2023-01-30 13:33:06 +01:00
00c09b1eb8 Fix: bulletin BUT json des démissionnaires. Closes #553 2023-01-30 13:33:06 +01:00
0e628273cf Contraint champs formsemestre non nulls 2023-01-30 13:33:06 +01:00
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
b4eab5fcbc Avertissements dates sur tableau bord semestre 2023-01-30 13:33:06 +01:00
c551634417 Merge -> 9.4.24 2023-01-30 13:33:06 +01:00
efe8673e8a Mise à jour de 'app/scodoc/sco_moduleimpl_status.py'
Correction https://scodoc.org/git/ScoDoc/ScoDoc/issues/550 :

Ajout du cas des évaluations "en attente" comportant des ATT.
2023-01-30 13:33:06 +01:00
f17b10da3b Filtres + affectation non affectés 2023-01-30 13:33:06 +01:00
cf18520e9c Jury BUT: amélioration gestion redoublants + #547 (WIP) 2023-01-30 13:33:06 +01:00
cda20c27b2 WIP: Test jury BUT: GEII Lyon 2023-01-30 13:33:06 +01:00
b7983a8d59 Nouvelle version editeur partitions 2023-01-30 13:33:05 +01:00
47b3eec14b formsemestre_status: warning si toutes evals visibles 2023-01-30 13:33:05 +01:00
9f6068caa2 Updater: DEBIAN_FRONTEND=noninteractive 2023-01-30 13:33:05 +01:00
04277d1f57 Fix: formsemestre_note_etuds_sans_notes 2023-01-30 13:33:05 +01:00
f9d15da553 Tests Yaml: saisie notes non numériques (EXC, ABS, ...) 2023-01-30 13:33:05 +01:00
a7126990f0 Fix typo 2023-01-30 13:33:05 +01:00
42d92cb998 Warning si poids non éditables 2023-01-30 13:33:05 +01:00
2d3d7d49fc Ajout commentaires 2023-01-30 13:33:05 +01:00
277e87add9 Fix erreur si changement jours travaillés 2023-01-30 13:33:05 +01:00
fffb07d612 Jury BUT: Messages d'erreur si pas de ref. comp. 2023-01-30 13:33:05 +01:00
afe9ae69a9 Change année copyright 2023-01-30 13:33:05 +01:00
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
7adc7d824b add some type annotations 2022-12-27 09:22:26 +01:00
cf900d2027 Fix: jury BUT si UE non associée à comp. 2022-12-27 09:22:26 +01:00
1fa8375b11 BUT: dispenses d'UE capitalisées. Voir #537. 2022-12-27 09:22:26 +01:00
bdefa111a7 Jury BUT: stats jury, #425 2022-12-26 07:58:09 +01:00
c5b2df379e Formulaire jury BUT: vérifie sortie sans enregistrement (JS) #425 2022-12-26 07:58:08 +01:00
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
18aed44644 Remplissage notes manquantes par groupes. closes #534 2022-12-26 07:56:11 +01:00
ec632dd43c Jury BUT: complète logs étudiants. + cosmetic 2022-12-26 07:56:11 +01:00
acc1ecf906 Tests YAML: permet d'indiquer la décision de jury sur les UEs 2022-12-26 07:56:11 +01:00
9566551e7e Améliore visu jury BUT. + minor code cleaning. 2022-12-26 07:56:11 +01:00
7e1b0177f0 WIP: jury BUT avec redoublements (à compléter). 2022-12-26 07:56:11 +01:00
8e6dc37a87 BUT: jury inter-année pour les redoublants 2022-12-26 07:56:11 +01:00
a4840f494b Fix: acces photo sans photos ni portail 2022-12-26 07:56:11 +01:00
a28f58a443 Test yaml GMP: ajoute S1 redoublé 2022-12-26 07:56:11 +01:00
2a41cf972c Test yaml GMP: inscrit à un parcours 2022-12-26 07:56:11 +01:00
e2ca9d417f Quelques commentaires rapides 2022-12-25 11:42:58 -03: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
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
3e2631b94d BUT: dispenses d'UE capitalisées. Voir #537. 2022-12-19 13:32:43 +01:00
9251810814 Tests unitaires yaml: check des RCUEs 2022-12-19 13:28:38 +01:00
4c83c69f7c API: rétabli formation.referentiel_competence_id. Tous tests OK. 2022-12-19 13:28:38 +01:00
b09dc63fe3 N'exporte pas le ref. comp. dans les formations 2022-12-19 13:28:38 +01:00
075d864de3 Fix: API formsemestre (parcours) 2022-12-19 13:28:38 +01:00
6440ca4a1f Fix API: formsemestres_courants 2022-12-19 13:28:38 +01:00
7d2d19f3a8 Tests unit BUT 2022-12-19 13:28:38 +01:00
882d131837 typo in preferences 2022-12-19 13:28:38 +01:00
3012fc465d Tests Yaml: vérification des résultats jury + fix explanation 2022-12-19 13:28:38 +01:00
7069fb6e31 Tests Yaml: vérification des résultats jury 2022-12-19 13:28:38 +01:00
be2d7926bf Tests: modif programme test GB 2022-12-19 13:28:38 +01:00
bc6d9d5442 Test unit. logo: désactive vérification contenu répertoire 2022-12-19 13:28:38 +01:00
a0a6dbea00 Pas d'UEs externes en BUT. Voir #542 2022-12-19 13:28:38 +01:00
872e741d9f Check APC conformity: cas UE de parcours 2022-12-19 13:28:38 +01:00
5258a570a6 Fix: affichage d'une UE capitalisée sans ECTS (None) 2022-12-19 13:28:38 +01:00
f0da8434a9 Groupes de parcours: API, avertissements. 2022-12-19 13:28:38 +01:00
e995228ca7 Ameliore gestion groupes de parcours 2022-12-19 13:28:38 +01:00
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
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
f1fd4d98d7 Tests unitaires yaml: reset sequences to get same ids 2022-12-19 13:28:38 +01:00
c6e35dd4cd Cosmetic: BUT SAE apres res. 2022-12-19 13:28:38 +01:00
cb8d313dc7 Cosmetic: BUT ue_table: cache UE rattachement pour res. et SAE 2022-12-19 13:28:38 +01:00
3e3b09134d Fix tableau bord module si aucune eval. 2022-12-19 13:28:38 +01:00
7b3c50620b Cosmetic: tableau bord module: normalise poids évaluations pour Hinton Map 2022-12-19 13:28:38 +01:00
63fb09348d Cosmetic: tableau bord module: code + présentation 2022-12-19 13:28:38 +01:00
7a9dc11af3 Améliore édition groupe: message si pas partition non éditable 2022-12-19 13:28:38 +01:00
d178c636bf Correctif relevé tri UEs capitalisées 2022-12-19 13:28:38 +01:00
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
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
0a1a847044 Modif handler ScoBugCatcher pour mode dev 2022-12-13 10:12:20 +01:00
f5442b924f Fix unit tests setup 2022-12-13 10:12:20 +01:00
bec4cd7978 BUT: corrige affichage coefs UE tableau sem., et niveaux sur fiche etud. + unit tests 2022-12-13 10:12:20 +01:00
f63fa43862 WIP: liste des UE d'un semestre avec parcours 2022-12-13 10:12:19 +01:00
ca20c303f0 BUT: tests unitaires yaml: associe modules/parcours + fix formation GB exemple 2022-12-13 10:12:19 +01:00
014886c288 BUT: tests unitaires yaml avec association UE/Competences 2022-12-13 10:12:19 +01:00
e2110f4abb BUT: corrige calcul inscriptions UE de parcours 2022-12-13 10:12:19 +01:00
ff12f4312e Jury BUT: affichage si UE non associées 2022-12-13 10:12:19 +01:00
930a96b984 WIP: Nouveaux tests unitaires pour les cursus BUT 2022-12-13 10:12:19 +01:00
60fa12df81 Corrige annulation dispense d'UE APC 2022-12-13 10:12:19 +01:00
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
688fc5401f Gestion du champ 'boursier' 2022-12-13 10:12:19 +01:00
c1cbd6bce0 Edition du champ 'boursier' 2022-12-13 10:12:19 +01:00
f2ffd69fe6 Automatise les tests unitaires de l'API 2022-12-13 10:12:19 +01:00
ba5b5cdb6f Fix regression in API/formsemestre_etudiants 2022-12-13 10:12:19 +01:00
51b0ca088c Fix unit tests 2022-12-13 10:12:19 +01:00
9c618692d1 Fix: API bul JSON classic cap (...) 2022-12-13 10:12:19 +01:00
a0c33b3c19 Enregistrement de l'étape lors de l'inscription au semestre 2022-12-13 10:12:19 +01:00
ef1b28fe27 Edition UEs: renumérote si besoin 2022-12-13 10:12:19 +01:00
f246d9e82c Fix: API bulletins JSON classic sans matières 2022-12-13 10:12:19 +01:00
cd36737460 API: ajout champ dept_name dans /departements et /departement 2022-12-13 10:12:19 +01:00
acb8e6aab2 Add col. version in refcomp table 2022-12-13 10:12:19 +01:00
8ef19b14c7 Nouvelles versions des ref. de comp. GACO, QLIO, SD fournies par Orebut. 2022-12-13 10:12:19 +01:00
7af381becc Fix: do_formsemestre_inscription_with_modules args 2022-12-13 10:12:19 +01:00
c9b4058717 Fix: bul. classique JSON format long_mat avec UE cap. 2022-12-13 10:12:19 +01:00
42b03dbdfa Fix: bug rare si cache modimpl_results non en accord avec modimpl.evaluations 2022-12-13 10:12:19 +01:00
a87dbd9927 BUT: dispenses d'UE capitalisées. Voir #537. 2022-12-13 10:12:19 +01:00
ae9aad0619 Modification calcul bonus IUT St Nazaire 2022-12-13 10:12:19 +01:00
e38d4bde81 Prépare tests unitaires jury BUT avec parcours 2022-12-13 10:12:18 +01:00
25d1132a06 Correction ref compétences 2022-12-13 10:12:18 +01:00
4c730a6302 API: formsemestre/bulletins au format long_mat. 2022-12-13 10:12:18 +01:00
287e4df74e Suppress warning on 0/0 in compute_mat_moys_classic 2022-12-13 10:12:18 +01:00
77348c2cdf API: bulletins: re-ecriture et format json classic avec matières (long_mat, short_mat). 2022-12-13 10:12:18 +01:00
f67a11519e Jury BUT: par défaut, autorise à passer après un semestre impair 2022-12-13 10:12:18 +01:00
f9a9c2088d Bulletin BUT: poids des évaluations restreint au parcours de l'étudiant. Closes #524 (part 2). 2022-12-13 10:12:18 +01:00
afbb1fb0e2 formsemestre_recap_parcours_table: UE du parcours. Closes #524 2022-12-13 10:12:18 +01:00
346701d91e Améliore tests unitaires: create_module 2022-12-13 10:12:18 +01:00
f647ff1139 Jury BUT: cosmetic 2022-12-13 10:12:18 +01:00
f5988b9e34 Jury BUT: pas de saisie décision annuelle sur sem. impairs 2022-12-13 10:12:18 +01:00
f318f35c1b Bulletin JSON classique: format 'long_mat' avec matières. Closes #535 2022-12-13 10:12:18 +01:00
4626cb9a3e Bulletin JSON classique: ajoute matières. Closes #535 2022-12-13 10:12:18 +01:00
0a58437fa9 WIP: jury BUT: prise en compte des UE capitalisées dans les RCUEs 2022-12-13 10:12:18 +01:00
c906cd7f16 minor code cleaning 2022-12-13 10:12:18 +01:00
ee86fba3d3 min/max evals sur bul. json classic. + Tests unitaires bulletin. 2022-12-13 10:12:18 +01:00
5018298d12 Améliore gestion font pdf manquant 2022-12-13 10:12:18 +01:00
9d64caa749 Adaptation du script diagnostic.sh pour ScoDoc 9 2022-12-13 10:12:18 +01:00
6f257dc80d Fix: bul. compat. XML 2022-12-13 10:12:18 +01:00
49d176c603 9.4.3 2022-12-13 10:12:18 +01:00
559b66de8b Fix: bulletin HTML sur démissionnaires sans groupes 2022-12-13 10:12:18 +01:00
d7f1114a42 Paramétrage dates annees scolaires (pivots) + tous test unitaires OK 2022-12-13 10:12:18 +01:00
a2ea7d7a02 FIX: calcul notes moyennes avec rattrapages ou session 2 + test unitaire 2022-12-13 10:12:18 +01:00
f6d8de5a20 pylint: force chargement plugins flask 2022-12-13 10:12:17 +01:00
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
528d5c8863 Fix: bonus sport Ville Avray 2022-12-13 10:12:17 +01:00
7b28e0ba6b dates antipodiques: ajout get_periode, tests et intégration 2022-12-13 10:12:17 +01:00
588f2f26eb WIP: paramétrage dates antipodiques 2022-12-13 10:12:17 +01:00
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
d055c17c6b Fix #525 (lien intranet) 2022-12-13 10:12:17 +01:00
ea0a49d837 Calcul moyenne LP UE stages&projets: bug fix #388 2022-12-13 10:12:17 +01:00
59a6ee3b3e Fix: page creation module 2022-12-13 10:12:17 +01:00
111634db99 Fix version num 2022-12-13 10:12:17 +01:00
26dcc31ffb Fix: modif semestre avec inscriptions sans parcours 2022-12-13 10:12:17 +01:00
18f4b9cd42 Améliore traitement arguments etud_info_html et ue_table 2022-12-13 10:12:17 +01:00
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
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
3e0f43d5ea 9.4.0 2022-11-02 08:00:25 +01:00
dcdd83d2e8 Liens navigation sur saisie jury BUT semestriel. #425 2022-11-02 08:00:25 +01:00
4d453d5d14 Interdit changement du ref. de comp. si formsemestres existants. Closes #506. 2022-11-02 08:00:25 +01:00
20b13b05cf Améliore synchro groupes de parcours / parcours du formsemestre. Closes #508. 2022-11-02 08:00:25 +01:00
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
dab6bad08f Modification Bonus Sport IUT Amiens 2022-11-02 08:00:25 +01:00
e435dd10db Bul. HTML: desactive affichage min/max/moy du groupe (non calculé actuellement) 2022-11-02 08:00:25 +01:00
95100ed429 Relevé : ajout rang partition 2022-11-02 08:00:25 +01:00
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
d50107079b Fix: résiste aux mélanges de référentiels de compétences... 2022-11-02 08:00:24 +01:00
9535ff1e91 Desactive upload referentiels competences en prod. 2022-11-02 08:00:24 +01:00
f4d8f4dded minor fix 2022-11-02 08:00:24 +01:00
ba003d7c02 9.3.60 2022-11-02 08:00:24 +01:00
7ca3290357 BUT: calcul moy. gen. indicative ne considérant que les UE du parcours 2022-11-02 08:00:24 +01:00
7cb98e3f31 BUT: édition des coefs: légende 2022-11-02 08:00:24 +01:00
365e54f7e1 BUT: édition des coefs: visualise mods hors parcours 2022-11-02 08:00:24 +01:00
9da5506361 BUT: édition des coefs: UE et mod de tronc commun 2022-11-02 08:00:24 +01:00
979359257b BUT: édition des coefs: filtre par parcours 2022-11-02 08:00:24 +01:00
cc674b4e65 BUT: edition programme: affiche parcours des modules 2022-11-02 08:00:24 +01:00
c103111aa1 BUT: autorise plusieurs UE vers le même niveau du tronc commun 2022-11-02 08:00:24 +01:00
b9d6688250 BUT: associe UE aux parcours. Modification pour #487. 2022-11-02 08:00:24 +01:00
93e54982b6 test unitaire: test_but_assoc_refcomp 2022-11-02 08:00:24 +01:00
eefdd5458e Modifie refcomp_desassoc (#506) 2022-11-02 08:00:24 +01:00
072d839b75 Fix pour ReportLab: transforme les <br> en <br/> 2022-11-02 08:00:24 +01:00
0de35b5400 API: /formsemestres/query?ine=xxxx 2022-11-02 08:00:24 +01:00
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
404 changed files with 20737 additions and 6443 deletions

View File

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

View File

@ -1,4 +1,3 @@
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). (c) Emmanuel Viennet 1999 - 2022 (voir LICENCE.txt).
@ -9,29 +8,23 @@ Documentation utilisateur: <https://scodoc.org>
## Version ScoDoc 9 ## Version ScoDoc 9
La version ScoDoc 9 est parue en septembre 2021. La version ScoDoc 9 est parue en septembre 2021. Elle représente une évolution
Elle représente une évolution majeure du projet, maintenant basé sur majeure du projet, maintenant basé sur Flask (au lieu de Zope) et sur **python
Flask (au lieu de Zope) et sur **python 3.9+**. 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, 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). Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes).
### État actuel (dec 22)
- 9.4.x est en production
### État actuel (26 jan 22) - le prochain jalon est 9.5. Voir branches sur gitea.
- 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 ### Lignes de commandes
Voir [https://scodoc.org/GuideConfig](le guide de configuration). Voir [https://scodoc.org/GuideConfig](le guide de configuration).
## Organisation des fichiers ## Organisation des fichiers
L'installation comporte les fichiers de l'application, sous `/opt/scodoc/`, et L'installation comporte les fichiers de l'application, sous `/opt/scodoc/`, et
@ -40,6 +33,7 @@ les fichiers locaux (archives, photos, configurations, logs) sous
postgresql et la configuration du système Linux. postgresql et la configuration du système Linux.
### Fichiers locaux ### Fichiers locaux
Sous `/opt/scodoc-data`, fichiers et répertoires appartenant à l'utilisateur `scodoc`. 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 Ils ne doivent pas être modifiés à la main, sauf certains fichiers de configuration sous
`/opt/scodoc-data/config`. `/opt/scodoc-data/config`.
@ -107,7 +101,7 @@ Certains tests ont besoin d'un département déjà créé, qui n'est pas créé
scripts de tests: scripts de tests:
Lancer au préalable: 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: Puis dérouler les tests unitaires:
@ -117,14 +111,14 @@ Ou avec couverture (`pip install pytest-cov`)
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/* pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
#### Utilisation des tests unitaires pour initialiser la base de dev #### 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 On peut aussi utiliser les tests unitaires pour mettre la base de données de
utilisée par les tests: 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 export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
@ -133,8 +127,8 @@ normalement, par exemple:
pytest tests/unit/test_sco_basic.py pytest tests/unit/test_sco_basic.py
Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins) Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins) un
un utilisateur: utilisateur:
flask user-password admin flask user-password admin
@ -182,8 +176,6 @@ puis
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
# Paquet Debian 11 # Paquet Debian 11
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
@ -192,4 +184,3 @@ upgrade de scodoc9).
La préparation d'une release se fait à l'aide du script La préparation d'une release se fait à l'aide du script
`tools/build_release.sh`. `tools/build_release.sh`.

View File

@ -26,11 +26,13 @@ from flask_mail import Mail
from flask_bootstrap import Bootstrap from flask_bootstrap import Bootstrap
from flask_moment import Moment from flask_moment import Moment
from flask_caching import Cache from flask_caching import Cache
from jinja2 import select_autoescape
import sqlalchemy import sqlalchemy
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
ScoBugCatcher, ScoBugCatcher,
ScoException,
ScoGenError, ScoGenError,
ScoValueError, ScoValueError,
APIInvalidParams, APIInvalidParams,
@ -60,11 +62,11 @@ cache = Cache(
def handle_sco_value_error(exc): 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): 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 internal_server_error(exc): def internal_server_error(exc):
@ -74,7 +76,7 @@ def internal_server_error(exc):
return ( return (
render_template( render_template(
"error_500.html", "error_500.j2",
SCOVERSION=sco_version.SCOVERSION, SCOVERSION=sco_version.SCOVERSION,
date=datetime.datetime.now().isoformat(), date=datetime.datetime.now().isoformat(),
exc=exc, exc=exc,
@ -92,6 +94,9 @@ def handle_sco_bug(exc):
"""Un bug, en général rare, sur lequel les dev cherchent des """Un bug, en général rare, sur lequel les dev cherchent des
informations pour le corriger. informations pour le corriger.
""" """
if current_app.config["TESTING"] or current_app.config["DEBUG"]:
raise ScoException # for development servers only
else:
Thread( Thread(
target=_async_dump, args=(current_app._get_current_object(), request.url) target=_async_dump, args=(current_app._get_current_object(), request.url)
).start() ).start()
@ -142,7 +147,7 @@ def render_raw_html(template_filename: str, **args) -> str:
def postgresql_server_error(e): def postgresql_server_error(e):
"""Erreur de connection au serveur postgresql (voir notesdb.open_db_connection)""" """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): class LogRequestFormatter(logging.Formatter):
@ -271,6 +276,9 @@ def create_app(config_class=DevConfig):
from app.api import api_bp from app.api import api_bp
from app.api import api_web_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 # https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp) app.register_blueprint(scodoc_bp)
# https://scodoc.fr/ScoDoc/RT/Scolarite/... # https://scodoc.fr/ScoDoc/RT/Scolarite/...
@ -435,8 +443,6 @@ def initialize_scodoc_database(erase=False, create_all=False):
SQL tables and functions. SQL tables and functions.
If erase is True, _erase_ all database content. If erase is True, _erase_ all database content.
""" """
from app import models
# - ERASE (the truncation sql function has been defined above) # - ERASE (the truncation sql function has been defined above)
if erase: if erase:
truncate_database() truncate_database()
@ -463,6 +469,26 @@ def truncate_database():
except: except:
db.session.rollback() db.session.rollback()
raise 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(): def clear_scodoc_cache():
@ -480,12 +506,10 @@ def clear_scodoc_cache():
# --------- Logging # --------- Logging
def log(msg: str, silent_test=True): def log(msg: str):
"""log a message. """log a message.
If Flask app, use configured logger, else stderr. If Flask app, use configured logger, else stderr.
""" """
if silent_test and current_app and current_app.config["TESTING"]:
return
try: try:
dept = getattr(g, "scodoc_dept", "") dept = getattr(g, "scodoc_dept", "")
msg = f" ({dept}) {msg}" msg = f" ({dept}) {msg}"
@ -530,3 +554,22 @@ def scodoc_flash_status_messages():
f"Mode test: mails redirigés vers {email_test_mode_address}", f"Mode test: mails redirigés vers {email_test_mode_address}",
category="warning", 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,7 +2,8 @@
""" """
from flask import Blueprint 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 import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException from app.scodoc.sco_exceptions import ScoException
@ -31,9 +32,26 @@ def requested_format(default_format="json", allowed_formats=None):
return 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 tokens
from app.api import ( from app.api import (
absences, absences,
assiduites,
billets_absences, billets_absences,
departements, departements,
etudiants, etudiants,
@ -41,6 +59,7 @@ from app.api import (
formations, formations,
formsemestres, formsemestres,
jury, jury,
justificatifs,
logos, logos,
partitions, partitions,
users, users,

View File

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

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

@ -0,0 +1,575 @@
##############################################################################
# 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
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.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",
}
"""
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?formsemstre_id=[int]
ex query?formsemestre_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)
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
"""
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):
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
):
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
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,
)
db.session.add(nouv_assiduite)
db.session.commit()
return (200, {"assiduite_id": nouv_assiduite.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_cdelete():
"""
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
}
"""
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
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")
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")
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")
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")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin")
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)
return assiduites_query

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -10,12 +10,13 @@
Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/), Note: les routes /departement[s] sont publiées sur l'API (/ScoDoc/api/),
mais évidemment pas sur l'API web (/ScoDoc/<dept>/api). mais évidemment pas sur l'API web (/ScoDoc/<dept>/api).
""" """
from datetime import datetime
from flask import jsonify, request from flask import jsonify, request
from flask_login import login_required from flask_login import login_required
import app import app
from app import db, log from app import db
from app.api import api_bp as bp from app.api import api_bp as bp
from app.scodoc.sco_utils import json_error from app.scodoc.sco_utils import json_error
from app.decorators import scodoc, permission_required from app.decorators import scodoc, permission_required
@ -42,7 +43,7 @@ def get_departement(dept_ident: str) -> Departement:
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def departements_list(): def departements_list():
"""Liste les départements""" """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") @bp.route("/departements_ids")
@ -66,13 +67,14 @@ def departement(acronym: str):
{ {
"id": 1, "id": 1,
"acronym": "TAPI", "acronym": "TAPI",
"dept_name" : "TEST",
"description": null, "description": null,
"visible": true, "visible": true,
"date_creation": "Fri, 15 Apr 2022 12:19:28 GMT" "date_creation": "Fri, 15 Apr 2022 12:19:28 GMT"
} }
""" """
dept = Departement.query.filter_by(acronym=acronym).first_or_404() 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>") @bp.route("/departement/id/<int:dept_id>")
@ -256,15 +258,18 @@ def dept_formsemestres_courants(acronym: str):
] ]
""" """
dept = Departement.query.filter_by(acronym=acronym).first_or_404() 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 # Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter( formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id, FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= app.db.func.now(), FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= app.db.func.now(), FormSemestre.date_fin >= test_date,
) )
return jsonify([d.to_dict_api() for d in formsemestres])
return jsonify([d.to_dict(convert_objects=True) for d in formsemestres])
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants") @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 # Le département, spécifié par un id ou un acronyme
dept = Departement.query.get_or_404(dept_id) 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 # Les semestres en cours de ce département
formsemestres = FormSemestre.query.filter( formsemestres = FormSemestre.query.filter(
FormSemestre.dept_id == dept.id, FormSemestre.dept_id == dept.id,
FormSemestre.date_debut <= app.db.func.now(), FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= app.db.func.now(), 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 # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
""" """
API : accès aux étudiants 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 current_user
from flask_login import login_required from flask_login import login_required
from sqlalchemy import desc, or_ from sqlalchemy import desc, or_
@ -75,11 +76,16 @@ def etudiants_courants(long=False):
""" """
allowed_depts = current_user.get_depts_with_permission(Permission.ScoView) 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( etuds = Identite.query.filter(
Identite.id == FormSemestreInscription.etudid, Identite.id == FormSemestreInscription.etudid,
FormSemestreInscription.formsemestre_id == FormSemestre.id, FormSemestreInscription.formsemestre_id == FormSemestre.id,
FormSemestre.date_debut <= app.db.func.now(), FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= app.db.func.now(), FormSemestre.date_fin >= test_date,
) )
if not None in allowed_depts: if not None in allowed_depts:
# restreint aux départements autorisés: # restreint aux départements autorisés:
@ -204,160 +210,75 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None)
@bp.route( @bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin", "/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin",
methods=["GET"],
defaults={"version": "long", "pdf": False},
) )
@bp.route( @bp.route(
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin", "/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>",
methods=["GET"],
defaults={"version": "long", "pdf": False},
) )
@bp.route( @bp.route(
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin", "/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
methods=["GET"], defaults={"pdf": True},
defaults={"version": "long", "pdf": False},
)
@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},
) )
@api_web_bp.route( @api_web_bp.route(
"/etudiant/etudid/<int:etudid>/formsemestre/<int:formsemestre_id>/bulletin", "/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin",
methods=["GET"],
defaults={"version": "long", "pdf": False},
) )
@api_web_bp.route( @api_web_bp.route(
"/etudiant/nip/<string:nip>/formsemestre/<int:formsemestre_id>/bulletin", "/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>",
methods=["GET"],
defaults={"version": "long", "pdf": False},
) )
@api_web_bp.route( @api_web_bp.route(
"/etudiant/ine/<string:ine>/formsemestre/<int:formsemestre_id>/bulletin", "/etudiant/<string:code_type>/<string:code>/formsemestre/<int:formsemestre_id>/bulletin/<string:version>/pdf",
methods=["GET"], defaults={"pdf": True},
defaults={"version": "long", "pdf": False},
)
@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},
) )
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def etudiant_bulletin_semestre( def bulletin(
formsemestre_id, code_type: str = "etudid",
etudid: int = None, code: str = None,
nip: str = None, formsemestre_id: int = None,
ine: str = None, version: str = "long",
version="long",
pdf: bool = False, pdf: bool = False,
): ):
""" """
Retourne le bulletin d'un étudiant en fonction de son id et d'un semestre donné Retourne le bulletin d'un étudiant en fonction de son id et d'un semestre donné
formsemestre_id : l'id d'un formsemestre formsemestre_id : l'id d'un formsemestre
etudid : l'etudid d'un étudiant code_type : "etudid", "nip" ou "ine"
nip : le code nip d'un étudiant code : valeur du code INE, NIP ou etudid, selon code_type.
ine : le code ine d'un étudiant version : type de bulletin (par défaut, "long"): short, long, long_mat
Exemple de résultat : voir https://scodoc.org/ScoDoc9API/#bulletin 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() formsemestre = FormSemestre.query.filter_by(id=formsemestre_id).first_or_404()
dept = Departement.query.filter_by(id=formsemestre.dept_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: if g.scodoc_dept and dept.acronym != g.scodoc_dept:
return json_error(404, "formsemestre non trouve") return json_error(404, "formsemestre non trouve")
if etudid is not None: app.set_sco_dept(dept.acronym)
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")
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() etud = query.first()
if etud is None: if etud is None:
return json_error(404, message="etudiant inexistant") return json_error(404, message="etudiant inexistant")
app.set_sco_dept(dept.acronym)
if pdf: if pdf:
pdf_response, _ = do_formsemestre_bulletinetud( pdf_response, _ = do_formsemestre_bulletinetud(
formsemestre, etud.id, version=version, format="pdf" formsemestre, etud.id, version=version, format="pdf"
) )
return pdf_response return pdf_response
return sco_bulletins.get_formsemestre_bulletin_etud_json( return sco_bulletins.get_formsemestre_bulletin_etud_json(
formsemestre, etud, version=version formsemestre, etud, version=version
) )

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -22,6 +22,44 @@ from app.scodoc.sco_permissions import Permission
import app.scodoc.sco_utils as scu 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") @bp.route("/moduleimpl/<int:moduleimpl_id>/evaluations")
@api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/evaluations") @api_web_bp.route("/moduleimpl/<int:moduleimpl_id>/evaluations")
@login_required @login_required
@ -33,39 +71,16 @@ def evaluations(moduleimpl_id: int):
moduleimpl_id : l'id d'un moduleimpl moduleimpl_id : l'id d'un moduleimpl
Exemple de résultat : Exemple de résultat : voir /evaluation
[
{
"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
},
...
]
""" """
query = Evaluation.query.filter_by(id=moduleimpl_id) query = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id)
if g.scodoc_dept: if g.scodoc_dept:
query = ( query = (
query.join(ModuleImpl) query.join(ModuleImpl)
.join(FormSemestre) .join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id) .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") @bp.route("/evaluation/<int:evaluation_id>/notes")

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -22,6 +22,8 @@ from app.models import (
Evaluation, Evaluation,
FormSemestre, FormSemestre,
FormSemestreEtape, FormSemestreEtape,
FormSemestreInscription,
Identite,
ModuleImpl, ModuleImpl,
NotesNotes, NotesNotes,
) )
@ -95,11 +97,14 @@ def formsemestres_query():
annee_scolaire : année de début de l'année scolaire annee_scolaire : année de début de l'année scolaire
dept_acronym : acronyme du département (eg "RT") dept_acronym : acronyme du département (eg "RT")
dept_id : id du département 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") etape_apo = request.args.get("etape_apo")
annee_scolaire = request.args.get("annee_scolaire") annee_scolaire = request.args.get("annee_scolaire")
dept_acronym = request.args.get("dept_acronym") dept_acronym = request.args.get("dept_acronym")
dept_id = request.args.get("dept_id") dept_id = request.args.get("dept_id")
nip = request.args.get("nip")
ine = request.args.get("ine")
formsemestres = FormSemestre.query formsemestres = FormSemestre.query
if g.scodoc_dept: if g.scodoc_dept:
formsemestres = formsemestres.filter_by(dept_id=g.scodoc_dept_id) formsemestres = formsemestres.filter_by(dept_id=g.scodoc_dept_id)
@ -125,16 +130,30 @@ def formsemestres_query():
formsemestres = formsemestres.join(FormSemestreEtape).filter( formsemestres = formsemestres.join(FormSemestreEtape).filter(
FormSemestreEtape.etape_apo == etape_apo 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]) return jsonify([formsemestre.to_dict_api() for formsemestre in formsemestres])
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins") @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")
@api_web_bp.route("/formsemestre/<int:formsemestre_id>/bulletins/<string:version>")
@login_required @login_required
@scodoc @scodoc
@permission_required(Permission.ScoView) @permission_required(Permission.ScoView)
def bulletins(formsemestre_id: int): def bulletins(formsemestre_id: int, version: str = "long"):
""" """
Retourne les bulletins d'un formsemestre donné Retourne les bulletins d'un formsemestre donné
@ -145,12 +164,16 @@ def bulletins(formsemestre_id: int):
query = FormSemestre.query.filter_by(id=formsemestre_id) query = FormSemestre.query.filter_by(id=formsemestre_id)
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) 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) app.set_sco_dept(formsemestre.departement.acronym)
data = [] data = []
for etu in formsemestre.etuds: 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) data.append(bul_etu.json)
return jsonify(data) return jsonify(data)
@ -381,7 +404,7 @@ def etat_evals(formsemestre_id: int):
for evaluation_id in modimpl_results.evaluations_etat: for evaluation_id in modimpl_results.evaluations_etat:
eval_etat = modimpl_results.evaluations_etat[evaluation_id] eval_etat = modimpl_results.evaluations_etat[evaluation_id]
evaluation = Evaluation.query.get_or_404(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["etat"] = eval_etat.to_dict()
eval_dict["nb_inscrits"] = modimpl_results.nb_inscrits_module eval_dict["nb_inscrits"] = modimpl_results.nb_inscrits_module

View File

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

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

@ -0,0 +1,580 @@
##############################################################################
# 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
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 is_period_conflicting
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
# @bp.route("/justificatif/remove")
# @api_web_bp.route("/justificatif/remove")
# @scodoc
# def justremove():
# """ """
# archiver: JustificatifArchiver = JustificatifArchiver()
# archiver.delete_justificatif(etudid=1, archive_id="2023-02-01-10-29-20")
# return jsonify("done")
# 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",
}
"""
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
"""
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
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,
)
db.session.add(nouv_justificatif)
db.session.commit()
return (200, {"justif_id": nouv_justificatif.id})
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)
# 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, 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, 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 ")
# Vérification du conflit d'horaire
if (deb is not None) or (fin is not None):
deb = deb if deb is not None else justificatif_unique.date_debut
fin = fin if fin is not None else justificatif_unique.date_fin
justificatifs_list: list[Justificatif] = Justificatif.query.filter_by(
etuid=justificatif_unique.etudid
).all()
if is_period_conflicting(deb, fin, justificatifs_list):
errors.append(
"Modification de la plage horaire impossible: conflit avec les autres justificatifs"
)
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({"OK": 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/import/<int:justif_id>", methods=["POST"])
@api_web_bp.route("/justificatif/import/<int:justif_id>", 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/export/<int:justif_id>/<filename>", methods=["POST"])
@api_web_bp.route("/justificatif/export/<int:justif_id>/<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/remove/<int:justif_id>", methods=["POST"])
@api_web_bp.route("/justificatif/remove/<int:justif_id>", 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/list/<int:justif_id>", methods=["GET"])
@api_web_bp.route("/justificatif/list/<int:justif_id>", 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/justified/<int:justif_id>", methods=["GET"])
@api_web_bp.route("/justificatif/justified/<int:justif_id>", methods=["GET"])
@scodoc
@login_required
@permission_required(Permission.ScoView)
# @permission_required(Permission.ScoAssiduiteChange)
def justif_justified(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")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
justificatifs_query: Justificatif = scass.filter_by_date(
justificatifs_query, Justificatif, deb, fin
)
return justificatifs_query

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # 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 @scodoc
@permission_required(Permission.ScoSuperAdmin) @permission_required(Permission.ScoSuperAdmin)
def api_get_glob_logos(): def api_get_glob_logos():
"""Liste tous les logos"""
logos = list_logos()[None] logos = list_logos()[None]
return jsonify(list(logos.keys())) return jsonify(list(logos.keys()))

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -19,6 +19,7 @@ from app.models import FormSemestre, FormSemestreInscription, Identite
from app.models import GroupDescr, Partition from app.models import GroupDescr, Partition
from app.models.groups import group_membership from app.models.groups import group_membership
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_groups
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -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) query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
) )
group = query.first_or_404() 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}: 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") return json_error(404, "etud non inscrit au formsemestre du groupe")
groups = (
GroupDescr.query.filter_by(partition_id=group.partition.id) sco_groups.change_etud_group_in_partition(
.join(group_membership) etudid, group_id, group.partition.to_dict()
.filter_by(etudid=etudid)
) )
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}) return jsonify({"group_id": group_id, "etudid": etudid})
@ -207,6 +199,8 @@ def group_remove_etud(group_id: int, etudid: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
) )
group = query.first_or_404() group = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if etud in group.etuds: if etud in group.etuds:
group.etuds.remove(etud) group.etuds.remove(etud)
db.session.commit() db.session.commit()
@ -232,6 +226,8 @@ def partition_remove_etud(partition_id: int, etudid: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition = query.first_or_404() partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
groups = ( groups = (
GroupDescr.query.filter_by(partition_id=partition_id) GroupDescr.query.filter_by(partition_id=partition_id)
.join(group_membership) .join(group_membership)
@ -262,8 +258,10 @@ def group_create(partition_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404() partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.groups_editable: 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 data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name") group_name = data.get("group_name")
if group_name is None: if group_name is None:
@ -294,8 +292,10 @@ def group_delete(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
) )
group: GroupDescr = query.first_or_404() group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable: 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 formsemestre_id = group.partition.formsemestre_id
log(f"deleting {group}") log(f"deleting {group}")
db.session.delete(group) db.session.delete(group)
@ -318,8 +318,10 @@ def group_edit(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
) )
group: GroupDescr = query.first_or_404() group: GroupDescr = query.first_or_404()
if not group.partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not group.partition.groups_editable: 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 data = request.get_json(force=True) # may raise 400 Bad Request
group_name = data.get("group_name") group_name = data.get("group_name")
if group_name is not None: if group_name is not None:
@ -358,6 +360,8 @@ def partition_create(formsemestre_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_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 data = request.get_json(force=True) # may raise 400 Bad Request
partition_name = data.get("partition_name") partition_name = data.get("partition_name")
if partition_name is None: if partition_name is None:
@ -406,6 +410,8 @@ def formsemestre_order_partitions(formsemestre_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.filter_by(dept_id=g.scodoc_dept_id) query = query.filter_by(dept_id=g.scodoc_dept_id)
formsemestre: FormSemestre = query.first_or_404(formsemestre_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 partition_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(partition_ids, int) and not all( if not isinstance(partition_ids, int) and not all(
isinstance(x, int) for x in partition_ids isinstance(x, int) for x in partition_ids
@ -443,6 +449,8 @@ def partition_order_groups(partition_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404() 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 group_ids = request.get_json(force=True) # may raise 400 Bad Request
if not isinstance(group_ids, int) and not all( if not isinstance(group_ids, int) and not all(
isinstance(x, int) for x in group_ids isinstance(x, int) for x in group_ids
@ -484,6 +492,8 @@ def partition_edit(partition_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404() 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 data = request.get_json(force=True) # may raise 400 Bad Request
modified = False modified = False
partition_name = data.get("partition_name") partition_name = data.get("partition_name")
@ -542,6 +552,8 @@ def partition_delete(partition_id: int):
if g.scodoc_dept: if g.scodoc_dept:
query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id) query = query.join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
partition: Partition = query.first_or_404() partition: Partition = query.first_or_404()
if not partition.formsemestre.etat:
return json_error(403, "formsemestre verrouillé")
if not partition.partition_name: if not partition.partition_name:
return json_error(404, "ne peut pas supprimer la partition par défaut") return json_error(404, "ne peut pas supprimer la partition par défaut")
is_parcours = partition.is_parcours() is_parcours = partition.is_parcours()

View File

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

View File

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

View File

@ -11,5 +11,5 @@ def send_password_reset_email(user):
sender=current_app.config["SCODOC_MAIL_FROM"], sender=current_app.config["SCODOC_MAIL_FROM"],
recipients=[user.email], recipients=[user.email],
text_body=render_template("email/reset_password.txt", user=user, token=token), 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

@ -42,7 +42,7 @@ def login():
return form.redirect("scodoc.index") return form.redirect("scodoc.index")
message = request.args.get("message", "") message = request.args.get("message", "")
return render_template( return render_template(
"auth/login.html", title=_("Sign In"), form=form, message=message "auth/login.j2", title=_("Sign In"), form=form, message=message
) )
@ -65,9 +65,7 @@ def create_user():
db.session.commit() db.session.commit()
flash(f"Utilisateur {user.user_name} créé") flash(f"Utilisateur {user.user_name} créé")
return redirect(url_for("scodoc.index")) return redirect(url_for("scodoc.index"))
return render_template( return render_template("auth/register.j2", title="Création utilisateur", form=form)
"auth/register.html", title="Création utilisateur", form=form
)
@bp.route("/reset_password_request", methods=["GET", "POST"]) @bp.route("/reset_password_request", methods=["GET", "POST"])
@ -98,7 +96,7 @@ def reset_password_request():
) )
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
return render_template( return render_template(
"auth/reset_password_request.html", title=_("Reset Password"), form=form "auth/reset_password_request.j2", title=_("Reset Password"), form=form
) )
@ -116,7 +114,7 @@ def reset_password(token):
db.session.commit() db.session.commit()
flash(_("Votre mot de passe a été changé.")) flash(_("Votre mot de passe a été changé."))
return redirect(url_for("auth.login")) 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"]) @bp.route("/reset_standard_roles_permissions", methods=["GET", "POST"])

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -8,14 +8,14 @@
Edition associations UE <-> Ref. Compétence Edition associations UE <-> Ref. Compétence
""" """
from flask import g, url_for from flask import g, url_for
from app import db, log from app.models import ApcReferentielCompetences, Formation, UniteEns
from app.models import Formation, UniteEns
from app.models.but_refcomp import ApcNiveau
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str: def form_ue_choix_niveau(ue: UniteEns) -> str:
"""Form. HTML pour associer une UE à un niveau de compétence""" """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 != sco_codes_parcours.UE_STANDARD: if ue.type != sco_codes_parcours.UE_STANDARD:
return "" return ""
ref_comp = ue.formation.referentiel_competence ref_comp = ue.formation.referentiel_competence
@ -27,11 +27,70 @@ def form_ue_choix_niveau(formation: Formation, ue: UniteEns) -> str:
}">associer un référentiel de compétence</a> }">associer un référentiel de compétence</a>
</div> </div>
</div>""" </div>"""
annee = 1 if ue.semestre_idx is None else (ue.semestre_idx + 1) // 2 # 1, 2, 3 # Les parcours:
niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee) 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 # 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 = { niveaux_autres_ues = {
oue.niveau_competence_id for oue in autres_ues if oue.id != ue.id 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 if niveaux_by_parcours["TC"]: # TC pour Tronc Commun
options.append("""<optgroup label="Tronc commun">""") options.append("""<optgroup label="Tronc commun">""")
for n in niveaux_by_parcours["TC"]: for n in niveaux_by_parcours["TC"]:
if n.id in niveaux_autres_ues:
disabled = "disabled"
else:
disabled = ""
options.append( options.append(
f"""<option value="{n.id}" {'selected' f"""<option value="{n.id}" {
if ue.niveau_competence == n else ''} 'selected' if ue.niveau_competence == n else ''}
{disabled}>{n.annee} {n.competence.titre_long} >{n.annee} {n.competence.titre_long}
niveau {n.ordre}</option>""" niveau {n.ordre}</option>"""
) )
options.append("""</optgroup>""") options.append("""</optgroup>""")
for parcour in ref_comp.parcours: for parcour in parcours:
if len(niveaux_by_parcours[parcour.id]): if len(niveaux_by_parcours[parcour.id]):
options.append(f"""<optgroup label="Parcours {parcour.libelle}">""") options.append(f"""<optgroup label="Parcours {parcour.libelle}">""")
for n in niveaux_by_parcours[parcour.id]: 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>""" niveau {n.ordre}</option>"""
) )
options.append("""</optgroup>""") options.append("""</optgroup>""")
options_str = "\n".join(options) return (
return f""" f"""<option value="" {'selected' if ue.niveau_competence is None else ''}>aucun</option>"""
<div class="ue_choix_niveau"> + "\n".join(options)
<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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -80,6 +80,9 @@ class BulletinBUT:
""" """
res = self.res res = self.res
if (etud.id, ue.id) in self.res.dispense_ues:
return {}
if ue.type == UE_SPORT: if ue.type == UE_SPORT:
modimpls_spo = [ modimpls_spo = [
modimpl modimpl
@ -239,6 +242,7 @@ class BulletinBUT:
self.etud_eval_results(etud, e) self.etud_eval_results(etud, e)
for e in modimpl.evaluations for e in modimpl.evaluations
if (e.visibulletin or version == "long") if (e.visibulletin or version == "long")
and (e.id in modimpl_results.evaluations_etat)
and ( and (
modimpl_results.evaluations_etat[e.id].is_complete modimpl_results.evaluations_etat[e.id].is_complete
or self.prefs["bul_show_all_evals"] or self.prefs["bul_show_all_evals"]
@ -256,10 +260,11 @@ class BulletinBUT:
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id] modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
try: try:
etud_ues_ids = self.res.etud_ues_ids(etud.id)
poids = { poids = {
ue.acronyme: modimpls_evals_poids[ue.id][e.id] ue.acronyme: modimpls_evals_poids[ue.id][e.id]
for ue in self.res.ues 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: except KeyError:
poids = collections.defaultdict(lambda: 0.0) poids = collections.defaultdict(lambda: 0.0)
@ -356,7 +361,7 @@ class BulletinBUT:
"formsemestre_id": formsemestre.id, "formsemestre_id": formsemestre.id,
"etat_inscription": etat_inscription, "etat_inscription": etat_inscription,
"options": sco_preferences.bulletin_option_affichage( "options": sco_preferences.bulletin_option_affichage(
formsemestre.id, self.prefs formsemestre, self.prefs
), ),
} }
if not published: if not published:
@ -460,6 +465,7 @@ class BulletinBUT:
"ressources": {}, "ressources": {},
"saes": {}, "saes": {},
"ues": {}, "ues": {},
"ues_capitalisees": {},
} }
) )

View File

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

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # 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 from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app.scodoc import sco_abs
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
from app.scodoc import sco_edit_ue from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc import sco_photos from app.scodoc import sco_photos
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_xml from app.scodoc import sco_xml
from app.scodoc.sco_xml import quote_xml_attr
def bulletin_but_xml_compat( def bulletin_but_xml_compat(
@ -108,13 +108,13 @@ def bulletin_but_xml_compat(
etudid=str(etudid), etudid=str(etudid),
code_nip=etud.code_nip or "", code_nip=etud.code_nip or "",
code_ine=etud.code_ine or "", code_ine=etud.code_ine or "",
nom=scu.quote_xml_attr(etud.nom), nom=quote_xml_attr(etud.nom),
prenom=scu.quote_xml_attr(etud.prenom), prenom=quote_xml_attr(etud.prenom),
civilite=scu.quote_xml_attr(etud.civilite_str), civilite=quote_xml_attr(etud.civilite_str),
sexe=scu.quote_xml_attr(etud.civilite_str), # compat sexe=quote_xml_attr(etud.civilite_str), # compat
photo_url=scu.quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)), photo_url=quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)),
email=scu.quote_xml_attr(etud.get_first_email() or ""), email=quote_xml_attr(etud.get_first_email() or ""),
emailperso=scu.quote_xml_attr(etud.get_first_email("emailperso") or ""), emailperso=quote_xml_attr(etud.get_first_email("emailperso") or ""),
) )
) )
# Disponible pour publication ? # Disponible pour publication ?
@ -153,10 +153,10 @@ def bulletin_but_xml_compat(
x_ue = Element( x_ue = Element(
"ue", "ue",
id=str(ue.id), id=str(ue.id),
numero=scu.quote_xml_attr(ue.numero), numero=quote_xml_attr(ue.numero),
acronyme=scu.quote_xml_attr(ue.acronyme or ""), acronyme=quote_xml_attr(ue.acronyme or ""),
titre=scu.quote_xml_attr(ue.titre or ""), titre=quote_xml_attr(ue.titre or ""),
code_apogee=scu.quote_xml_attr(ue.code_apogee or ""), code_apogee=quote_xml_attr(ue.code_apogee or ""),
) )
doc.append(x_ue) doc.append(x_ue)
if ue.type != sco_codes_parcours.UE_SPORT: if ue.type != sco_codes_parcours.UE_SPORT:
@ -192,11 +192,9 @@ def bulletin_but_xml_compat(
code=str(modimpl.module.code or ""), code=str(modimpl.module.code or ""),
coefficient=str(coef), coefficient=str(coef),
numero=str(modimpl.module.numero or 0), numero=str(modimpl.module.numero or 0),
titre=scu.quote_xml_attr(modimpl.module.titre or ""), titre=quote_xml_attr(modimpl.module.titre or ""),
abbrev=scu.quote_xml_attr(modimpl.module.abbrev or ""), abbrev=quote_xml_attr(modimpl.module.abbrev or ""),
code_apogee=scu.quote_xml_attr( code_apogee=quote_xml_attr(modimpl.module.code_apogee or ""),
modimpl.module.code_apogee or ""
),
) )
# XXX TODO rangs et effectifs # XXX TODO rangs et effectifs
# --- notes de chaque eval: # --- notes de chaque eval:
@ -215,7 +213,7 @@ def bulletin_but_xml_compat(
coefficient=str(e.coefficient), coefficient=str(e.coefficient),
# pas les poids en XML compat # pas les poids en XML compat
evaluation_type=str(e.evaluation_type), 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: # notes envoyées sur 20, ceci juste pour garder trace:
note_max_origin=str(e.note_max), note_max_origin=str(e.note_max),
) )
@ -262,7 +260,7 @@ def bulletin_but_xml_compat(
), ),
) )
x_situation = Element("situation") 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) doc.append(x_situation)
if dpv: if dpv:
decision = dpv["decisions"][0] decision = dpv["decisions"][0]
@ -297,9 +295,9 @@ def bulletin_but_xml_compat(
Element( Element(
"decision_ue", "decision_ue",
ue_id=str(ue["ue_id"]), ue_id=str(ue["ue_id"]),
numero=scu.quote_xml_attr(ue["numero"]), numero=quote_xml_attr(ue["numero"]),
acronyme=scu.quote_xml_attr(ue["acronyme"]), acronyme=quote_xml_attr(ue["acronyme"]),
titre=scu.quote_xml_attr(ue["titre"]), titre=quote_xml_attr(ue["titre"]),
code=decision["decisions_ue"][ue_id]["code"], code=decision["decisions_ue"][ue_id]["code"],
) )
) )
@ -322,7 +320,7 @@ def bulletin_but_xml_compat(
"appreciation", "appreciation",
date=ndb.DateDMYtoISO(appr["date"]), 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) doc.append(x_appr)
if is_appending: if is_appending:

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -13,7 +13,7 @@ Classe raccordant avec ScoDoc 7:
avec la même interface. avec la même interface.
""" """
import collections
from typing import Union from typing import Union
from flask import g, url_for from flask import g, url_for
@ -47,12 +47,14 @@ from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import sco_codes_parcours as sco_codes from app.scodoc import sco_codes_parcours as sco_codes
from app.scodoc.sco_codes_parcours import RED, UE_STANDARD from app.scodoc.sco_codes_parcours import RED, UE_STANDARD
from app.scodoc import sco_utils as scu 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 from app.scodoc import sco_cursus_dut
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic): class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
"""Pour compat ScoDoc 7: à revoir pour le BUT"""
def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT): def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT):
super().__init__(etud, formsemestre_id, res) super().__init__(etud, formsemestre_id, res)
# Ajustements pour le BUT # Ajustements pour le BUT
@ -65,3 +67,117 @@ class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
def parcours_validated(self): def parcours_validated(self):
"True si le parcours est validé" "True si le parcours est validé"
return False # XXX TODO 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, ... } }"
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()
}

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -8,7 +8,7 @@
""" """
from flask_wtf import FlaskForm 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 from wtforms import SelectField, SubmitField

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -64,7 +64,7 @@ import re
from typing import Union from typing import Union
import numpy as np import numpy as np
from flask import g, url_for from flask import flash, g, url_for
from app import db from app import db
from app import log from app import log
@ -91,9 +91,15 @@ from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_codes_parcours as sco_codes from app.scodoc import sco_codes_parcours as sco_codes
from app.scodoc.sco_codes_parcours import RED, UE_STANDARD from app.scodoc.sco_codes_parcours import (
BUT_CODES_ORDERED,
CODES_RCUE_VALIDES,
CODES_UE_VALIDES,
RED,
UE_STANDARD,
)
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException, ScoValueError from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
class NoRCUEError(ScoValueError): class NoRCUEError(ScoValueError):
@ -126,7 +132,7 @@ class DecisionsProposees:
"""Une décision de jury proposé, constituée d'une liste de codes et d'une explication. """Une décision de jury proposé, constituée d'une liste de codes et d'une explication.
Super-classe, spécialisée pour les UE, les RCUE, les années et le diplôme. Super-classe, spécialisée pour les UE, les RCUE, les années et le diplôme.
validation : None ou une instance de d'une classe avec un champ code validation : None ou une instance d'une classe avec un champ code
ApcValidationRCUE, ApcValidationAnnee ou ScolarFormSemestreValidation ApcValidationRCUE, ApcValidationAnnee ou ScolarFormSemestreValidation
""" """
@ -170,7 +176,7 @@ class DecisionsProposees:
def __repr__(self) -> str: def __repr__(self) -> str:
return f"""<{self.__class__.__name__} valid={self.code_valide return f"""<{self.__class__.__name__} valid={self.code_valide
} codes={self.codes} explanation={self.explanation}""" } codes={self.codes} explanation={self.explanation}>"""
class DecisionsProposeesAnnee(DecisionsProposees): class DecisionsProposeesAnnee(DecisionsProposees):
@ -204,7 +210,12 @@ class DecisionsProposeesAnnee(DecisionsProposees):
etud: Identite, etud: Identite,
formsemestre: FormSemestre, formsemestre: FormSemestre,
): ):
assert formsemestre.formation.is_apc()
if formsemestre.formation.referentiel_competence is None:
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
super().__init__(etud=etud) super().__init__(etud=etud)
self.formsemestre = formsemestre
"le formsemestre utilisé pour construire ce deca"
self.formsemestre_id = formsemestre.id self.formsemestre_id = formsemestre.id
"l'id du formsemestre utilisé pour construire ce deca" "l'id du formsemestre utilisé pour construire ce deca"
formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre) formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre)
@ -219,19 +230,34 @@ class DecisionsProposeesAnnee(DecisionsProposees):
) )
) )
) )
# Si les années scolaires sont distinctes, on est "à cheval"
self.a_cheval = (
formsemestre_impair
and formsemestre_pair
and formsemestre_impair.annee_scolaire()
!= formsemestre_pair.annee_scolaire()
)
"vrai si on groupe deux semestres d'années scolaires différentes"
# Si on part d'un semestre IMPAIR, il n'y aura pas de décision année proposée
# (mais on pourra évidemment valider des UE et même des RCUE)
self.jury_annuel: bool = formsemestre.semestre_id in (2, 4, 6)
"vrai si jury de fin d'année scolaire (propose code annuel)"
self.formsemestre_impair = formsemestre_impair self.formsemestre_impair = formsemestre_impair
"le 1er semestre de l'année scolaire considérée (S1, S3, S5)" "le 1er semestre du groupement (S1, S3, S5)"
self.formsemestre_pair = formsemestre_pair self.formsemestre_pair = formsemestre_pair
"le second formsemestre de la même année scolaire (S2, S4, S6)" "le second formsemestre (S2, S4, S6), de la même année scolaire ou d'une précédente"
formsemestre_last = formsemestre_pair or formsemestre_impair formsemestre_last = formsemestre_pair or formsemestre_impair
"le formsemestre le plus avancé dans cette année" "le formsemestre le plus avancé (en indice de semestre) dans le groupement"
self.annee_but = (formsemestre_last.semestre_id + 1) // 2 self.annee_but = (formsemestre_last.semestre_id + 1) // 2
"le rang de l'année dans le BUT: 1, 2, 3" "le rang de l'année dans le BUT: 1, 2, 3"
assert self.annee_but in (1, 2, 3) assert self.annee_but in (1, 2, 3)
self.rcues_annee = [] self.rcues_annee = []
"RCUEs de l'année" """RCUEs de l'année
(peuvent concerner l'année scolaire antérieur pour les redoublants
avec UE capitalisées)
"""
self.inscription_etat = etud.inscription_etat(formsemestre_last.id) self.inscription_etat = etud.inscription_etat(formsemestre_last.id)
"état de l'inscription dans le semestre le plus avancé (pair si année complète)" "état de l'inscription dans le semestre le plus avancé (pair si année complète)"
self.inscription_etat_pair = ( self.inscription_etat_pair = (
@ -293,10 +319,18 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if self.formsemestre_impair if self.formsemestre_impair
else self.formsemestre_pair.formation else self.formsemestre_pair.formation
) )
self.niveaux_competences = ApcNiveau.niveaux_annee_de_parcours( (
self.parcour, self.annee_but, formation.referentiel_competence parcours,
).all() # non triés niveaux_by_parcours,
"liste des niveaux de compétences associés à cette année" ) = formation.referentiel_competence.get_niveaux_by_parcours(
self.annee_but, self.parcour
)
self.niveaux_competences = niveaux_by_parcours["TC"] + (
niveaux_by_parcours[self.parcour.id] if self.parcour else []
)
"""liste non triée des niveaux de compétences associés à cette année pour cet étudiant.
= niveaux du tronc commun + niveau du parcours de l'étudiant.
"""
self.decisions_rcue_by_niveau = self.compute_decisions_niveaux() self.decisions_rcue_by_niveau = self.compute_decisions_niveaux()
"les décisions rcue associées aux niveau_id" "les décisions rcue associées aux niveau_id"
self.dec_rcue_by_ue = self._dec_rcue_by_ue() self.dec_rcue_by_ue = self._dec_rcue_by_ue()
@ -322,8 +356,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
) )
"vrai si l'année est réussie, tous niveaux validables ou validés par le jury" "vrai si l'année est réussie, tous niveaux validables ou validés par le jury"
self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2) self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2)
# Peut passer si plus de la moitié validables et tous > 8 "Vrai si plus de la moitié des RCUE validables"
self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0) self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0)
"Vrai si peut passer dans l'année BUT suivante: plus de la moitié validables et tous > 8"
# XXX TODO ajouter condition pour passage en S5 # XXX TODO ajouter condition pour passage en S5
# Enfin calcule les codes des UE: # Enfin calcule les codes des UE:
@ -331,12 +366,13 @@ class DecisionsProposeesAnnee(DecisionsProposees):
dec_ue.compute_codes() dec_ue.compute_codes()
# Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR # Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR
expl_rcues = ( plural = self.nb_validables > 1
f"{self.nb_validables} niveau validable(s) sur {self.nb_competences}" expl_rcues = f"""{self.nb_validables} niveau{"x" if plural else ""} validable{
) "s" if plural else ""} sur {self.nb_competences}"""
if self.admis: if self.admis:
self.codes = [sco_codes.ADM] + self.codes self.codes = [sco_codes.ADM] + self.codes
self.explanation = expl_rcues # elif not self.jury_annuel:
# self.codes = [] # pas de décision annuelle sur semestres impairs
elif self.inscription_etat != scu.INSCRIT: elif self.inscription_etat != scu.INSCRIT:
self.codes = [ self.codes = [
sco_codes.DEM sco_codes.DEM
@ -350,9 +386,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
sco_codes.ABL, sco_codes.ABL,
sco_codes.EXCLU, sco_codes.EXCLU,
] ]
expl_rcues = ""
elif self.passage_de_droit: elif self.passage_de_droit:
self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes
self.explanation = expl_rcues
elif self.valide_moitie_rcue: # mais au moins 1 rcue insuffisante elif self.valide_moitie_rcue: # mais au moins 1 rcue insuffisante
self.codes = [ self.codes = [
sco_codes.RED, sco_codes.RED,
@ -360,7 +396,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
sco_codes.PAS1NCI, sco_codes.PAS1NCI,
sco_codes.ADJ, sco_codes.ADJ,
] + self.codes ] + self.codes
self.explanation = expl_rcues + f" et {self.nb_rcues_under_8} < 8" expl_rcues += f" et {self.nb_rcues_under_8} < 8"
else: else:
self.codes = [ self.codes = [
sco_codes.RED, sco_codes.RED,
@ -369,50 +405,57 @@ class DecisionsProposeesAnnee(DecisionsProposees):
sco_codes.ADJ, sco_codes.ADJ,
sco_codes.PASD, # voir #488 (discutable, conventions locales) sco_codes.PASD, # voir #488 (discutable, conventions locales)
] + self.codes ] + self.codes
self.explanation = ( expl_rcues += f""" et {self.nb_rcues_under_8} niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
expl_rcues
+ f""" et {self.nb_rcues_under_8}
niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
)
# Si l'un des semestres est extérieur, propose ADM # Si l'un des semestres est extérieur, propose ADM
if ( if (
self.formsemestre_impair and self.formsemestre_impair.modalite == "EXT" self.formsemestre_impair and self.formsemestre_impair.modalite == "EXT"
) or (self.formsemestre_pair and self.formsemestre_pair.modalite == "EXT"): ) or (self.formsemestre_pair and self.formsemestre_pair.modalite == "EXT"):
self.codes.insert(0, sco_codes.ADM) self.codes.insert(0, sco_codes.ADM)
self.explanation = f"<div>{expl_rcues}</div>"
messages = self.descr_pb_coherence()
if messages:
self.explanation += (
'<div class="warning">'
+ '</div><div class="warning">'.join(messages)
+ "</div>"
)
# #
def infos(self) -> str: def infos(self) -> str:
"informations, for debugging purpose" """informations, for debugging purpose."""
return f"""<b>DecisionsProposeesAnnee</b> text = f"""<b>DecisionsProposeesAnnee</b>
<ul> <ul>
<li>Etudiant: <a href="{url_for("scolar.ficheEtud", <li>Etudiant: <a href="{url_for("scolar.ficheEtud",
scodoc_dept=g.scodoc_dept, etudid=self.etud.id) scodoc_dept=g.scodoc_dept, etudid=self.etud.id)
}">{self.etud.nomprenom}</a> }">{self.etud.nomprenom}</a>
</li> </li>
<li>formsemestre_impair: <a href="{url_for("notes.formsemestre_status", """
scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre_impair.id) for formsemestre, title in (
}">{html.escape(str(self.formsemestre_impair))}</a> (self.formsemestre_impair, "formsemestre_impair"),
(self.formsemestre_pair, "formsemestre_pair"),
):
text += f"<li>{title}:"
if formsemestre is not None:
text += f"""
<a href="{url_for("notes.formsemestre_status",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}">{html.escape(str(formsemestre))}</a>
<ul> <ul>
<li>Formation: <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, <li>Formation: <a href="{url_for('notes.ue_table',
semestre_idx=self.formsemestre_impair.semestre_id, scodoc_dept=g.scodoc_dept,
formation_id=self.formsemestre_impair.formation.id)}"> semestre_idx=formsemestre.semestre_id,
{self.formsemestre_impair.formation.to_html()} ({self.formsemestre_impair.formation.id})</a> formation_id=formsemestre.formation.id)}">
{formsemestre.formation.to_html()} ({
formsemestre.formation.id})</a>
</li> </li>
</ul> </ul>
</li> """
<li>formsemestre_pair: <a href="{url_for("notes.formsemestre_status", else:
scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre_pair.id) text += " aucun."
}">{html.escape(str(self.formsemestre_pair))}</a> text += "</li>"
<ul>
<li>Formation: <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
semestre_idx=self.formsemestre_pair.semestre_id,
formation_id=self.formsemestre_pair.formation.id)}">
{self.formsemestre_pair.formation.to_html()} ({self.formsemestre_pair.formation.id})</a>
</li>
</ul>
</li>
text += f"""
<li>RCUEs: {html.escape(str(self.rcues_annee))}</li> <li>RCUEs: {html.escape(str(self.rcues_annee))}</li>
<li>nb_competences: {getattr(self, "nb_competences", "-")}</li> <li>nb_competences: {getattr(self, "nb_competences", "-")}</li>
<li>nb_validables: {getattr(self, "nb_validables", "-")}</li> <li>nb_validables: {getattr(self, "nb_validables", "-")}</li>
@ -420,6 +463,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
<li>explanation: {self.explanation}</li> <li>explanation: {self.explanation}</li>
</ul> </ul>
""" """
return text
def annee_scolaire(self) -> int: def annee_scolaire(self) -> int:
"L'année de début de l'année scolaire" "L'année de début de l'année scolaire"
@ -434,14 +478,19 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def comp_formsemestres( def comp_formsemestres(
self, formsemestre: FormSemestre self, formsemestre: FormSemestre
) -> tuple[FormSemestre, FormSemestre]: ) -> tuple[FormSemestre, FormSemestre]:
"""les deux formsemestres de l'année scolaire à laquelle appartient formsemestre.""" """Les deux formsemestres du niveau auquel appartient formsemestre.
Complète le niveau avec le formsemestre antérieur le plus récent.
L'"autre" formsemestre peut ainsi appartenir à l'année scolaire
antérieure (redoublants).
-> S_impair, S_pair
"""
if not formsemestre.formation.is_apc(): # garde fou if not formsemestre.formation.is_apc(): # garde fou
return None, None return None, None
if formsemestre.semestre_id % 2 == 0: if formsemestre.semestre_id % 2 == 0:
other_semestre_id = formsemestre.semestre_id - 1 other_semestre_id = formsemestre.semestre_id - 1
else: else:
other_semestre_id = formsemestre.semestre_id + 1 other_semestre_id = formsemestre.semestre_id + 1
annee_scolaire = formsemestre.annee_scolaire()
other_formsemestre = None other_formsemestre = None
for inscr in self.etud.formsemestre_inscriptions: for inscr in self.etud.formsemestre_inscriptions:
if ( if (
@ -452,8 +501,13 @@ class DecisionsProposeesAnnee(DecisionsProposees):
) )
# L'autre semestre # L'autre semestre
and (inscr.formsemestre.semestre_id == other_semestre_id) and (inscr.formsemestre.semestre_id == other_semestre_id)
# de la même année scolaire: # Antérieur
and (inscr.formsemestre.annee_scolaire() == annee_scolaire) and inscr.formsemestre.date_debut < formsemestre.date_debut
# Et plus le récent possible
and (
(other_formsemestre is None)
or (other_formsemestre.date_debut < inscr.formsemestre.date_debut)
)
): ):
other_formsemestre = inscr.formsemestre other_formsemestre = inscr.formsemestre
if formsemestre.semestre_id % 2 == 0: if formsemestre.semestre_id % 2 == 0:
@ -462,7 +516,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def compute_ues_annee(self) -> list[list[UniteEns], list[UniteEns]]: def compute_ues_annee(self) -> list[list[UniteEns], list[UniteEns]]:
"""UEs à valider cette année pour cet étudiant, selon son parcours. """UEs à valider cette année pour cet étudiant, selon son parcours.
Ramène [ listes des UE du semestre impair, liste des UE du semestre pair ]. Affecte self.parcour suivant l'inscription de l'étudiant et
ramène [ listes des UE du semestre impair, liste des UE du semestre pair ].
""" """
ues_sems = [] ues_sems = []
for (formsemestre, res) in ( for (formsemestre, res) in (
@ -497,9 +552,8 @@ class DecisionsProposeesAnnee(DecisionsProposees):
def compute_rcues_annee(self) -> list[RegroupementCoherentUE]: def compute_rcues_annee(self) -> list[RegroupementCoherentUE]:
"""Liste des regroupements d'UE à considérer cette année. """Liste des regroupements d'UE à considérer cette année.
Pour le moment on ne considère pas de RCUE à cheval sur plusieurs années (redoublants). On peut avoir un RCUE à cheval sur plusieurs années (redoublants avec UE capitalisées).
Si on n'a pas les deux semestres, aucun RCUE. Si on n'a pas les deux semestres, aucun RCUE.
Raises ScoValueError s'il y a des UE sans RCUE.
""" """
if self.formsemestre_pair is None or self.formsemestre_impair is None: if self.formsemestre_pair is None or self.formsemestre_impair is None:
return [] return []
@ -508,30 +562,47 @@ class DecisionsProposeesAnnee(DecisionsProposees):
for ue_pair in self.ues_pair: for ue_pair in self.ues_pair:
rcue = None rcue = None
for ue_impair in self.ues_impair: for ue_impair in self.ues_impair:
if self.a_cheval:
# l'UE paire DOIT être capitalisée pour être utilisée
if (
self.decisions_ues[ue_pair.id].code_valide
not in CODES_UE_VALIDES
):
continue # ignore cette UE antérieure non capitalisée
# et l'UE impaire doit être actuellement meilleure que
# celle éventuellement capitalisée
if (
self.decisions_ues[ue_impair.id].ue_status
and self.decisions_ues[ue_impair.id].ue_status["is_capitalized"]
):
continue # ignore cette UE car capitalisée et actuelle moins bonne
if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id: if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id:
rcue = RegroupementCoherentUE( rcue = RegroupementCoherentUE(
self.etud, self.etud,
self.formsemestre_impair, self.formsemestre_impair,
ue_impair, self.decisions_ues[ue_impair.id],
self.formsemestre_pair, self.formsemestre_pair,
ue_pair, self.decisions_ues[ue_pair.id],
self.inscription_etat, self.inscription_etat,
) )
ues_impair_sans_rcue.discard(ue_impair.id) ues_impair_sans_rcue.discard(ue_impair.id)
break break
if rcue is None: # if rcue is None and not self.a_cheval:
raise NoRCUEError(deca=self, ue=ue_pair) # raise NoRCUEError(deca=self, ue=ue_pair)
if rcue is not None:
rcues_annee.append(rcue) rcues_annee.append(rcue)
if len(ues_impair_sans_rcue) > 0: # Si jury annuel (pas à cheval), on doit avoir tous les RCUEs:
ue = UniteEns.query.get(ues_impair_sans_rcue.pop()) # if len(ues_impair_sans_rcue) > 0 and not self.a_cheval:
raise NoRCUEError(deca=self, ue=ue) # ue = UniteEns.query.get(ues_impair_sans_rcue.pop())
# raise NoRCUEError(deca=self, ue=ue)
return rcues_annee return rcues_annee
def compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]: def compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]:
"""Pour chaque niveau de compétence de cette année, construit """Pour chaque niveau de compétence de cette année, construit
le DecisionsProposeesRCUE, le DecisionsProposeesRCUE, ou None s'il n'y en a pas
ou None s'il n'y en a pas
(ne devrait pas arriver car compute_rcues_annee vérifie déjà cela). (ne devrait pas arriver car compute_rcues_annee vérifie déjà cela).
Appelé à la construction du deca, donc avant décisions manuelles.
Return: { niveau_id : DecisionsProposeesRCUE } Return: { niveau_id : DecisionsProposeesRCUE }
""" """
# Retrouve le RCUE associé à chaque niveau # Retrouve le RCUE associé à chaque niveau
@ -562,22 +633,42 @@ class DecisionsProposeesAnnee(DecisionsProposees):
d[dec_rcue.rcue.ue_2.id] = dec_rcue d[dec_rcue.rcue.ue_2.id] = dec_rcue
return d return d
def next_annee_semestre_id(self, code: str) -> int: def next_semestre_ids(self, code: str) -> set[int]:
"""L'indice du semestre dans lequel l'étudiant est autorisé à """Les indices des semestres dans lequels l'étudiant est autorisé
poursuivre l'année suivante. None si aucun.""" à poursuivre après le semestre courant.
if self.formsemestre_pair is None: """
return None # seulement sur année ids = set()
if code == RED: # La poursuite d'études dans un semestre pair dune même année
return self.formsemestre_pair.semestre_id - 1 # est de droit pour tout étudiant:
elif ( if (self.formsemestre.semestre_id % 2) and sco_codes.ParcoursBUT.NB_SEM:
code in sco_codes.BUT_CODES_PASSAGE ids.add(self.formsemestre.semestre_id + 1)
# La poursuite détudes dans un semestre impair est possible si
# et seulement si létudiant a obtenu :
# - la moyenne à plus de la moitié des regroupements cohérents dUE ;
# - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE.
#
# La condition a paru trop stricte à de nombreux collègues.
# ScoDoc ne contraint donc pas à la respecter strictement.
# Si le code est dans BUT_CODES_PASSAGE (ADM, ADJ, PASD, PAS1NCI, ATJ),
# autorise à passer dans le semestre suivant
if (
self.jury_annuel
and code in sco_codes.BUT_CODES_PASSAGE
and self.formsemestre_pair.semestre_id < sco_codes.ParcoursBUT.NB_SEM and self.formsemestre_pair.semestre_id < sco_codes.ParcoursBUT.NB_SEM
): ):
return self.formsemestre_pair.semestre_id + 1 ids.add(self.formsemestre.semestre_id + 1)
return None
if code == RED:
ids.add(
self.formsemestre.semestre_id - (self.formsemestre.semestre_id + 1) % 2
)
return ids
def record_form(self, form: dict): def record_form(self, form: dict):
"""Enregistre les codes de jury en base """Enregistre les codes de jury en base
à partir d'un dict représentant le formulaire jury BUT:
form dict: form dict:
- 'code_ue_1896' : 'AJ' code pour l'UE id 1896 - 'code_ue_1896' : 'AJ' code pour l'UE id 1896
- 'code_rcue_6" : 'ADM' code pour le RCUE du niveau 6 - 'code_rcue_6" : 'ADM' code pour le RCUE du niveau 6
@ -587,7 +678,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
et qu'il n'y en a pas déjà, enregistre ceux par défaut. et qu'il n'y en a pas déjà, enregistre ceux par défaut.
""" """
log("jury_but.DecisionsProposeesAnnee.record_form") log("jury_but.DecisionsProposeesAnnee.record_form")
with sco_cache.DeferredSemCacheManager(): code_annee = None
codes_rcues = [] # [ (dec_rcue, code), ... ]
codes_ues = [] # [ (dec_ue, code), ... ]
for key in form: for key in form:
code = form[key] code = form[key]
# Codes d'UE # Codes d'UE
@ -597,7 +690,7 @@ class DecisionsProposeesAnnee(DecisionsProposees):
dec_ue = self.decisions_ues.get(ue_id) dec_ue = self.decisions_ues.get(ue_id)
if not dec_ue: if not dec_ue:
raise ScoValueError(f"UE invalide ue_id={ue_id}") raise ScoValueError(f"UE invalide ue_id={ue_id}")
dec_ue.record(code) codes_ues.append((dec_ue, code))
else: else:
# Codes de RCUE # Codes de RCUE
m = re.match(r"^code_rcue_(\d+)$", key) m = re.match(r"^code_rcue_(\d+)$", key)
@ -606,31 +699,39 @@ class DecisionsProposeesAnnee(DecisionsProposees):
dec_rcue = self.decisions_rcue_by_niveau.get(niveau_id) dec_rcue = self.decisions_rcue_by_niveau.get(niveau_id)
if not dec_rcue: if not dec_rcue:
raise ScoValueError(f"RCUE invalide niveau_id={niveau_id}") raise ScoValueError(f"RCUE invalide niveau_id={niveau_id}")
dec_rcue.record(code) codes_rcues.append((dec_rcue, code))
elif key == "code_annee": elif key == "code_annee":
# Code annuel # Code annuel
self.record(code) code_annee = code
with sco_cache.DeferredSemCacheManager():
# Enregistre les codes, dans l'ordre UE, RCUE, Année
for dec_ue, code in codes_ues:
dec_ue.record(code)
for dec_rcue, code in codes_rcues:
dec_rcue.record(code)
self.record(code_annee)
self.record_all() self.record_all()
db.session.commit() db.session.commit()
def record(self, code: str, no_overwrite=False): def record(self, code: str, no_overwrite=False) -> bool:
"""Enregistre le code de l'année, et au besoin l'autorisation d'inscription. """Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
Si no_overwrite, ne fait rien si un code est déjà enregistré. Si no_overwrite, ne fait rien si un code est déjà enregistré.
Si l'étudiant est DEM ou DEF, ne fait rien. Si l'étudiant est DEM ou DEF, ne fait rien.
""" """
if self.inscription_etat != scu.INSCRIT: if self.inscription_etat != scu.INSCRIT:
return return False
if code and not code in self.codes: if code and not code in self.codes:
raise ScoValueError( raise ScoValueError(
f"code annee <tt>{html.escape(code)}</tt> invalide pour formsemestre {html.escape(self.formsemestre)}" f"code annee <tt>{html.escape(code)}</tt> invalide pour formsemestre {html.escape(self.formsemestre)}"
) )
if code == self.code_valide or (self.code_valide is not None and no_overwrite):
self.recorded = True if code != self.code_valide and (self.code_valide is None or not no_overwrite):
return # no change # Enregistrement du code annuel BUT
if self.validation: if self.validation:
db.session.delete(self.validation) db.session.delete(self.validation)
db.session.flush() db.session.commit()
if code is None: if code is None:
self.validation = None self.validation = None
else: else:
@ -641,31 +742,32 @@ class DecisionsProposeesAnnee(DecisionsProposees):
annee_scolaire=self.annee_scolaire(), annee_scolaire=self.annee_scolaire(),
code=code, code=code,
) )
db.session.add(self.validation)
db.session.commit()
log(f"Recording {self}: {code}")
Scolog.logdb( Scolog.logdb(
method="jury_but", method="jury_but",
etudid=self.etud.id, etudid=self.etud.id,
msg=f"Validation année BUT{self.annee_but}: {code}", msg=f"Validation année BUT{self.annee_but}: {code}",
) )
db.session.add(self.validation)
# --- Autorisation d'inscription dans semestre suivant ? # --- Autorisation d'inscription dans semestre suivant ?
if self.formsemestre_pair is not None:
if code is None:
ScolarAutorisationInscription.delete_autorisation_etud( ScolarAutorisationInscription.delete_autorisation_etud(
etudid=self.etud.id, etudid=self.etud.id,
origin_formsemestre_id=self.formsemestre_pair.id, origin_formsemestre_id=self.formsemestre.id,
) )
else: for next_semestre_id in self.next_semestre_ids(code):
next_semestre_id = self.next_annee_semestre_id(code)
if next_semestre_id is not None:
ScolarAutorisationInscription.autorise_etud( ScolarAutorisationInscription.autorise_etud(
self.etud.id, self.etud.id,
self.formsemestre_pair.formation.formation_code, self.formsemestre.formation.formation_code,
self.formsemestre_pair.id, self.formsemestre.id,
next_semestre_id, next_semestre_id,
) )
db.session.commit()
self.recorded = True self.recorded = True
self.invalidate_formsemestre_cache() self.invalidate_formsemestre_cache()
return True
def invalidate_formsemestre_cache(self): def invalidate_formsemestre_cache(self):
"invalide le résultats des deux formsemestres" "invalide le résultats des deux formsemestres"
@ -676,29 +778,72 @@ class DecisionsProposeesAnnee(DecisionsProposees):
if self.formsemestre_pair is not None: if self.formsemestre_pair is not None:
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id) sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
def record_all(self): def record_all(
self, no_overwrite: bool = True, only_validantes: bool = False
) -> bool:
"""Enregistre les codes qui n'ont pas été spécifiés par le formulaire, """Enregistre les codes qui n'ont pas été spécifiés par le formulaire,
et sont donc en mode "automatique" et sont donc en mode "automatique".
- Si "à cheval", ne modifie pas les codes UE de l'année scolaire précédente.
- Pour les RCUE: n'enregistre que si la nouvelle décision est plus favorable que l'ancienne.
Si only_validantes, n'enregistre que des décisions "validantes" de droit: ADM ou CMP.
Return: True si au moins un code modifié et enregistré.
""" """
decisions = ( modif = False
list(self.decisions_ues.values()) # Toujours valider dans l'ordre UE, RCUE, Année
+ list(self.decisions_rcue_by_niveau.values()) annee_scolaire = self.formsemestre.annee_scolaire()
+ [self] # UEs
) for dec_ue in self.decisions_ues.values():
for dec in decisions: if (
if not dec.recorded: not dec_ue.recorded
) and dec_ue.formsemestre.annee_scolaire() == annee_scolaire:
# rappel: le code par défaut est en tête # rappel: le code par défaut est en tête
code = dec.codes[0] if dec.codes else None code = dec_ue.codes[0] if dec_ue.codes else None
# s'il n'y a pas de code, efface if (not only_validantes) or code in sco_codes.CODES_UE_VALIDES_DE_DROIT:
dec.record(code, no_overwrite=True) # enregistre le code jury seulement s'il n'y a pas déjà de code
# (no_overwrite=True) sauf en mode test yaml
modif |= dec_ue.record(code, no_overwrite=no_overwrite)
# RCUE :
for dec_rcue in self.decisions_rcue_by_niveau.values():
code = dec_rcue.codes[0] if dec_rcue.codes else None
if (
(not dec_rcue.recorded)
and ( # enregistre seulement si pas déjà validé "mieux"
(not dec_rcue.validation)
or BUT_CODES_ORDERED.get(dec_rcue.validation.code, 0)
< BUT_CODES_ORDERED.get(code, 0)
)
and ( # décision validante de droit ?
(
(not only_validantes)
or code in sco_codes.CODES_RCUE_VALIDES_DE_DROIT
)
)
):
modif |= dec_rcue.record(code, no_overwrite=no_overwrite)
# Année:
if not self.recorded:
# rappel: le code par défaut est en tête
code = self.codes[0] if self.codes else None
# enregistre le code jury seulement s'il n'y a pas déjà de code
# (no_overwrite=True) sauf en mode test yaml
if (
not only_validantes
) or code in sco_codes.CODES_ANNEE_BUT_VALIDES_DE_DROIT:
modif |= self.record(code, no_overwrite=no_overwrite)
return modif
def erase(self, only_one_sem=False): def erase(self, only_one_sem=False):
"""Efface les décisions de jury de cet étudiant """Efface les décisions de jury de cet étudiant
pour cette année: décisions d'UE, de RCUE, d'année, pour cette année: décisions d'UE, de RCUE, d'année,
et autorisations d'inscription émises. et autorisations d'inscription émises.
Efface même si étudiant DEM ou DEF. Efface même si étudiant DEM ou DEF.
Si à cheval, n'efface que pour le semestre d'origine du deca.
(commite la session.)
""" """
if only_one_sem: if only_one_sem or self.a_cheval:
# N'efface que les autorisations venant de ce semestre, # N'efface que les autorisations venant de ce semestre,
# et les validations de ses UEs # et les validations de ses UEs
ScolarAutorisationInscription.delete_autorisation_etud( ScolarAutorisationInscription.delete_autorisation_etud(
@ -727,22 +872,37 @@ class DecisionsProposeesAnnee(DecisionsProposees):
) )
for validation in validations: for validation in validations:
db.session.delete(validation) db.session.delete(validation)
db.session.flush() Scolog.logdb(
"jury_but",
etudid=self.etud.id,
msg=f"Validation année BUT{self.annee_but}: effacée",
)
# Efface éventuelles validations de semestre
# (en principe inutilisées en BUT)
# et autres UEs (en cas de changement d'architecture de formation depuis le jury ?)
#
for validation in ScolarFormSemestreValidation.query.filter_by(
etudid=self.etud.id, formsemestre_id=self.formsemestre_id
):
db.session.delete(validation)
db.session.commit()
self.invalidate_formsemestre_cache() self.invalidate_formsemestre_cache()
def get_autorisations_passage(self) -> list[int]: def get_autorisations_passage(self) -> list[int]:
"""Les liste des indices de semestres auxquels on est autorisé à """Liste des indices de semestres auxquels on est autorisé à
s'inscrire depuis cette année""" s'inscrire depuis le semestre courant.
formsemestre = self.formsemestre_pair or self.formsemestre_impair """
if not formsemestre: return sorted(
return [] [
return [
a.semestre_id a.semestre_id
for a in ScolarAutorisationInscription.query.filter_by( for a in ScolarAutorisationInscription.query.filter_by(
etudid=self.etud.id, etudid=self.etud.id,
origin_formsemestre_id=formsemestre.id, origin_formsemestre_id=self.formsemestre.id,
) )
] ]
)
def descr_niveaux_validation(self, line_sep: str = "\n") -> str: def descr_niveaux_validation(self, line_sep: str = "\n") -> str:
"""Description textuelle des niveaux validés (enregistrés) """Description textuelle des niveaux validés (enregistrés)
@ -770,11 +930,34 @@ class DecisionsProposeesAnnee(DecisionsProposees):
validations.append(", ".join(v for v in valids if v)) validations.append(", ".join(v for v in valids if v))
return line_sep.join(validations) return line_sep.join(validations)
def descr_pb_coherence(self) -> list[str]:
"""Description d'éventuels problèmes de cohérence entre
les décisions *enregistrées* d'UE et de RCUE.
Note: en principe, la cohérence RCUE/UE est assurée au moment de
l'enregistrement (record).
Mais la base peut avoir été modifiée par d'autres voies.
"""
messages = []
for dec_rcue in self.decisions_rcue_by_niveau.values():
if dec_rcue.code_valide in CODES_RCUE_VALIDES:
for ue in (dec_rcue.rcue.ue_1, dec_rcue.rcue.ue_2):
dec_ue = self.decisions_ues.get(ue.id)
if dec_ue:
if dec_ue.code_valide not in CODES_UE_VALIDES:
messages.append(
f"L'UE {ue.acronyme} n'est pas validée mais son RCUE l'est !"
)
else:
messages.append(f"L'UE {ue.acronyme} n'a pas décision (???)")
return messages
def list_ue_parcour_etud( def list_ue_parcour_etud(
formsemestre: FormSemestre, etud: Identite, res: ResultatsSemestreBUT formsemestre: FormSemestre, etud: Identite, res: ResultatsSemestreBUT
) -> tuple[ApcParcours, list[UniteEns]]: ) -> tuple[ApcParcours, list[UniteEns]]:
"""Parcour dans lequel l'étudiant est inscrit, et liste des UEs pour ce semestre""" """Parcour dans lequel l'étudiant est inscrit,
et liste des UEs à valider pour ce semestre (sans les UE "dispensées")
"""
if res.etuds_parcour_id[etud.id] is None: if res.etuds_parcour_id[etud.id] is None:
parcour = None parcour = None
# pas de parcour: prend toutes les UEs (non bonus) # pas de parcour: prend toutes les UEs (non bonus)
@ -788,6 +971,7 @@ def list_ue_parcour_etud(
.order_by(UniteEns.numero) .order_by(UniteEns.numero)
.all() .all()
) )
ues = [ue for ue in ues if (etud.id, ue.id) not in res.dispense_ues]
return parcour, ues return parcour, ues
@ -813,6 +997,7 @@ class DecisionsProposeesRCUE(DecisionsProposees):
inscription_etat: str = scu.INSCRIT, inscription_etat: str = scu.INSCRIT,
): ):
super().__init__(etud=dec_prop_annee.etud) super().__init__(etud=dec_prop_annee.etud)
self.deca = dec_prop_annee
self.rcue = rcue self.rcue = rcue
if rcue is None: # RCUE non dispo, eg un seul semestre if rcue is None: # RCUE non dispo, eg un seul semestre
self.codes = [] self.codes = []
@ -844,30 +1029,48 @@ class DecisionsProposeesRCUE(DecisionsProposees):
or dec_prop_annee.formsemestre_pair.modalite == "EXT" or dec_prop_annee.formsemestre_pair.modalite == "EXT"
): ):
self.codes.insert(0, sco_codes.ADM) self.codes.insert(0, sco_codes.ADM)
# S'il y a une décision enregistrée: si elle est plus favorable que celle que l'on
# proposerait, la place en tête.
# Sinon, la place en seconde place
if self.code_valide and self.code_valide != self.codes[0]:
code_default = self.codes[0]
if self.code_valide in self.codes:
self.codes.remove(self.code_valide)
if sco_codes.BUT_CODES_ORDERED.get(
self.code_valide, 0
) > sco_codes.BUT_CODES_ORDERED.get(code_default, 0):
self.codes.insert(0, self.code_valide)
else:
self.codes.insert(1, self.code_valide)
def record(self, code: str, no_overwrite=False): def __repr__(self) -> str:
"""Enregistre le code""" return f"""<{self.__class__.__name__} rcue={self.rcue} valid={self.code_valide
} codes={self.codes} explanation={self.explanation}"""
def record(self, code: str, no_overwrite=False) -> bool:
"""Enregistre le code RCUE.
Note:
- si le RCUE est ADJ, les UE non validées sont passées à ADJ
XXX on pourra imposer ici d'autres règles de cohérence
"""
if self.rcue is None: if self.rcue is None:
return # pas de RCUE a enregistrer return False # pas de RCUE a enregistrer
if self.inscription_etat != scu.INSCRIT: if self.inscription_etat != scu.INSCRIT:
return return False
if code and not code in self.codes: if code and not code in self.codes:
raise ScoValueError( raise ScoValueError(
f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}" f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
) )
if code == self.code_valide or (self.code_valide is not None and no_overwrite): if code == self.code_valide or (self.code_valide is not None and no_overwrite):
self.recorded = True self.recorded = True
return # no change return False # no change
parcours_id = self.parcour.id if self.parcour is not None else None parcours_id = self.parcour.id if self.parcour is not None else None
if self.validation: if self.validation:
db.session.delete(self.validation) db.session.delete(self.validation)
db.session.flush() db.session.commit()
if code is None: if code is None:
self.validation = None self.validation = None
else: else:
# log(
# f"RCUE.record(etudid={self.etud.id}, ue1_id={self.rcue.ue_1.id}, ue2_id={self.rcue.ue_2.id}, code={code} )"
# )
self.validation = ApcValidationRCUE( self.validation = ApcValidationRCUE(
etudid=self.etud.id, etudid=self.etud.id,
formsemestre_id=self.rcue.formsemestre_2.id, formsemestre_id=self.rcue.formsemestre_2.id,
@ -876,12 +1079,31 @@ class DecisionsProposeesRCUE(DecisionsProposees):
parcours_id=parcours_id, parcours_id=parcours_id,
code=code, code=code,
) )
db.session.add(self.validation)
db.session.commit()
Scolog.logdb( Scolog.logdb(
method="jury_but", method="jury_but",
etudid=self.etud.id, etudid=self.etud.id,
msg=f"Validation RCUE {repr(self.rcue)}", msg=f"Validation {self.rcue}: {code}",
commit=True,
) )
db.session.add(self.validation) log(f"rcue.record {self}: {code}")
# Modifie au besoin les codes d'UE
if code == "ADJ":
deca = self.deca
for ue_id in (self.rcue.ue_1.id, self.rcue.ue_2.id):
dec_ue = deca.decisions_ues.get(ue_id)
if dec_ue and dec_ue.code_valide not in CODES_UE_VALIDES:
log(f"rcue.record: force ADJR sur {dec_ue}")
flash(
f"""UEs du RCUE "{dec_ue.ue.niveau_competence.competence.titre}" passées en ADJR"""
)
dec_ue.record(sco_codes.ADJR)
# Valide les niveaux inférieurs de la compétence (code ADSUP)
# TODO
if self.rcue.formsemestre_1 is not None: if self.rcue.formsemestre_1 is not None:
sco_cache.invalidate_formsemestre( sco_cache.invalidate_formsemestre(
formsemestre_id=self.rcue.formsemestre_1.id formsemestre_id=self.rcue.formsemestre_1.id
@ -890,13 +1112,16 @@ class DecisionsProposeesRCUE(DecisionsProposees):
sco_cache.invalidate_formsemestre( sco_cache.invalidate_formsemestre(
formsemestre_id=self.rcue.formsemestre_2.id formsemestre_id=self.rcue.formsemestre_2.id
) )
self.code_valide = code # mise à jour état
self.recorded = True self.recorded = True
return True
def erase(self): def erase(self):
"""Efface la décision de jury de cet étudiant pour cet RCUE""" """Efface la décision de jury de cet étudiant pour cet RCUE"""
# par prudence, on requete toutes les validations, en cas de doublons # par prudence, on requete toutes les validations, en cas de doublons
validations = self.rcue.query_validations() validations = self.rcue.query_validations()
for validation in validations: for validation in validations:
log(f"DecisionsProposeesRCUE: deleting {validation}")
db.session.delete(validation) db.session.delete(validation)
db.session.flush() db.session.flush()
@ -928,7 +1153,7 @@ class DecisionsProposeesUE(DecisionsProposees):
sinon si compensation dans RCUE: CMP sinon si compensation dans RCUE: CMP
sinon: ADJ, AJ sinon: ADJ, AJ
et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL (codes_communs) et proposer toujours: RAT, DEF, ABAN, ADJR, DEM, UEBSL (codes_communs)
""" """
# Codes toujours proposés sauf si include_communs est faux: # Codes toujours proposés sauf si include_communs est faux:
@ -936,6 +1161,7 @@ class DecisionsProposeesUE(DecisionsProposees):
sco_codes.RAT, sco_codes.RAT,
sco_codes.DEF, sco_codes.DEF,
sco_codes.ABAN, sco_codes.ABAN,
sco_codes.ADJR,
sco_codes.ATJ, sco_codes.ATJ,
sco_codes.DEM, sco_codes.DEM,
sco_codes.UEBSL, sco_codes.UEBSL,
@ -950,20 +1176,24 @@ class DecisionsProposeesUE(DecisionsProposees):
): ):
# Une UE peut être validée plusieurs fois en cas de redoublement (qu'elle soit capitalisée ou non) # Une UE peut être validée plusieurs fois en cas de redoublement (qu'elle soit capitalisée ou non)
# mais ici on a restreint au formsemestre donc une seule (prend la première) # mais ici on a restreint au formsemestre donc une seule (prend la première)
self.validation = ScolarFormSemestreValidation.query.filter_by( validation = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id
).first() ).first()
super().__init__( super().__init__(
etud=etud, etud=etud,
code_valide=self.validation.code if self.validation is not None else None, code_valide=validation.code if validation is not None else None,
) )
# log(f"built {self}") self.validation = validation
self.formsemestre = formsemestre self.formsemestre = formsemestre
self.ue: UniteEns = ue self.ue: UniteEns = ue
self.rcue: RegroupementCoherentUE = None self.rcue: RegroupementCoherentUE = None
"Le rcue auquel est rattaché cette UE, ou None" "Le rcue auquel est rattaché cette UE, ou None"
self.inscription_etat = inscription_etat self.inscription_etat = inscription_etat
"inscription: I, DEM, DEF dans le semestre de cette UE" "inscription: I, DEM, DEF dans le semestre de cette UE"
self.moy_ue = np.NaN
self.moy_ue_with_cap = np.NaN
self.ue_status = {}
if ue.type == sco_codes.UE_SPORT: if ue.type == sco_codes.UE_SPORT:
self.explanation = "UE bonus, pas de décision de jury" self.explanation = "UE bonus, pas de décision de jury"
self.codes = [] # aucun code proposé self.codes = [] # aucun code proposé
@ -974,22 +1204,29 @@ class DecisionsProposeesUE(DecisionsProposees):
self.codes = [ self.codes = [
sco_codes.DEM if inscription_etat == scu.DEMISSION else sco_codes.DEF sco_codes.DEM if inscription_etat == scu.DEMISSION else sco_codes.DEF
] ]
self.moy_ue = np.NaN
return return
# Moyenne de l'UE ? # Moyenne de l'UE ?
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
# Safety checks:
if not ue.id in res.etud_moy_ue: if not ue.id in res.etud_moy_ue:
self.explanation = "UE sans résultat" self.explanation = "UE sans résultat"
return return
if not etud.id in res.etud_moy_ue[ue.id]: if not etud.id in res.etud_moy_ue[ue.id]:
self.explanation = "Étudiant sans résultat dans cette UE" self.explanation = "Étudiant sans résultat dans cette UE"
return return
self.moy_ue = res.etud_moy_ue[ue.id][etud.id] ue_status = res.get_etud_ue_status(etud.id, ue.id)
self.moy_ue = ue_status["cur_moy_ue"]
self.moy_ue_with_cap = ue_status["moy"]
self.ue_status = ue_status
def __repr__(self) -> str:
return f"""<{self.__class__.__name__} ue={self.ue.acronyme} valid={self.code_valide
} codes={self.codes} explanation={self.explanation}>"""
def set_rcue(self, rcue: RegroupementCoherentUE): def set_rcue(self, rcue: RegroupementCoherentUE):
"""Rattache cette UE à un RCUE. Cela peut modifier les codes """Rattache cette UE à un RCUE. Cela peut modifier les codes
proposés (si compensation)""" proposés par compute_codes() (si compensation)"""
self.rcue = rcue self.rcue = rcue
def compute_codes(self): def compute_codes(self):
@ -1000,7 +1237,7 @@ class DecisionsProposeesUE(DecisionsProposees):
self.moy_ue > (sco_codes.ParcoursBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE) self.moy_ue > (sco_codes.ParcoursBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE)
) or self.formsemestre.modalite == "EXT": ) or self.formsemestre.modalite == "EXT":
self.codes.insert(0, sco_codes.ADM) self.codes.insert(0, sco_codes.ADM)
self.explanation = (f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20",) self.explanation = f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20"
elif self.rcue and self.rcue.est_compensable(): elif self.rcue and self.rcue.est_compensable():
self.codes.insert(0, sco_codes.CMP) self.codes.insert(0, sco_codes.CMP)
self.explanation = "compensable dans le RCUE" self.explanation = "compensable dans le RCUE"
@ -1009,9 +1246,10 @@ class DecisionsProposeesUE(DecisionsProposees):
self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
self.explanation = "notes insuffisantes" self.explanation = "notes insuffisantes"
def record(self, code: str, no_overwrite=False): def record(self, code: str, no_overwrite=False) -> bool:
"""Enregistre le code jury pour cette UE. """Enregistre le code jury pour cette UE.
Si no_overwrite, n'enregistre pas s'il y a déjà un code. Si no_overwrite, n'enregistre pas s'il y a déjà un code.
Return: True si code enregistré (modifié)
""" """
if code and not code in self.codes: if code and not code in self.codes:
raise ScoValueError( raise ScoValueError(
@ -1019,10 +1257,16 @@ class DecisionsProposeesUE(DecisionsProposees):
) )
if code == self.code_valide or (self.code_valide is not None and no_overwrite): if code == self.code_valide or (self.code_valide is not None and no_overwrite):
self.recorded = True self.recorded = True
return # no change return False # no change
self.erase() self.erase()
if code is None: if code is None:
self.validation = None self.validation = None
Scolog.logdb(
method="jury_but",
etudid=self.etud.id,
msg=f"Validation UE {self.ue.id} {self.ue.acronyme}: effacée",
commit=True,
)
else: else:
self.validation = ScolarFormSemestreValidation( self.validation = ScolarFormSemestreValidation(
etudid=self.etud.id, etudid=self.etud.id,
@ -1031,16 +1275,20 @@ class DecisionsProposeesUE(DecisionsProposees):
code=code, code=code,
moy_ue=self.moy_ue, moy_ue=self.moy_ue,
) )
db.session.add(self.validation)
db.session.commit()
Scolog.logdb( Scolog.logdb(
method="jury_but", method="jury_but",
etudid=self.etud.id, etudid=self.etud.id,
msg=f"Validation UE {self.ue.id}", msg=f"Validation UE {self.ue.id} {self.ue.acronyme}({self.moy_ue}): {code}",
commit=True,
) )
db.session.add(self.validation)
log(f"DecisionsProposeesUE: recording {self.validation}") log(f"DecisionsProposeesUE: recording {self.validation}")
sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id) sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id)
self.code_valide = code # mise à jour
self.recorded = True self.recorded = True
return True
def erase(self): def erase(self):
"""Efface la décision de jury de cet étudiant pour cette UE""" """Efface la décision de jury de cet étudiant pour cette UE"""
@ -1051,7 +1299,13 @@ class DecisionsProposeesUE(DecisionsProposees):
for validation in validations: for validation in validations:
log(f"DecisionsProposeesUE: deleting {validation}") log(f"DecisionsProposeesUE: deleting {validation}")
db.session.delete(validation) db.session.delete(validation)
db.session.flush() Scolog.logdb(
method="jury_but",
etudid=self.etud.id,
msg=f"Validation UE {validation.ue.id} {validation.ue.acronyme}: effacée",
)
db.session.commit()
def descr_validation(self) -> str: def descr_validation(self) -> str:
"""Description validation niveau enregistrée, pour PV jury. """Description validation niveau enregistrée, pour PV jury.
@ -1067,7 +1321,7 @@ class BUTCursusEtud: # WIP TODO
def __init__(self, formsemestre: FormSemestre, etud: Identite): def __init__(self, formsemestre: FormSemestre, etud: Identite):
if formsemestre.formation.referentiel_competence is None: if formsemestre.formation.referentiel_competence is None:
raise ScoException("BUTCursusEtud: pas de référentiel de compétences") raise ScoNoReferentielCompetences(formation=formsemestre.formation)
assert len(etud.formsemestre_inscriptions) > 0 assert len(etud.formsemestre_inscriptions) > 0
self.formsemestre = formsemestre self.formsemestre = formsemestre
self.etud = etud self.etud = etud

View File

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

View File

@ -1,12 +1,13 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
"""Jury BUT: table recap annuelle et liens saisie """Jury BUT: table recap annuelle et liens saisie
""" """
import collections
import time import time
import numpy as np import numpy as np
from flask import g, url_for from flask import g, url_for
@ -31,7 +32,7 @@ from app.scodoc.sco_codes_parcours import (
from app.scodoc import sco_formsemestre_status from app.scodoc import sco_formsemestre_status
from app.scodoc import sco_pvjury from app.scodoc import sco_pvjury
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
def formsemestre_saisie_jury_but( def formsemestre_saisie_jury_but(
@ -58,20 +59,13 @@ def formsemestre_saisie_jury_but(
# DecisionsProposeesAnnee(etud, formsemestre2) # DecisionsProposeesAnnee(etud, formsemestre2)
# Pour le 1er etud, faire un check_ues_ready_jury(self) -> page d'erreur # 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 # -> rcue .ue_1, .ue_2 -> stroe moy ues, rcue.moy_rcue, etc
if formsemestre2.semestre_id % 2 != 0: # XXX if formsemestre2.semestre_id % 2 != 0:
raise ScoValueError("Cette page ne fonctionne que sur les semestres pairs") # raise ScoValueError("Cette page ne fonctionne que sur les semestres pairs")
if formsemestre2.formation.referentiel_competence is None: if formsemestre2.formation.referentiel_competence is None:
raise ScoValueError( raise ScoNoReferentielCompetences(formation=formsemestre2.formation)
"""
<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( rows, titles, column_ids, jury_stats = get_jury_but_table(
formsemestre2, read_only=read_only, mode=mode formsemestre2, read_only=read_only, mode=mode
) )
if not rows: if not rows:
@ -153,6 +147,28 @@ def formsemestre_saisie_jury_but(
f""" f"""
</div> </div>
<div class="jury_stats">
<div>Nb d'étudiants avec décision annuelle:
{sum(jury_stats["codes_annuels"].values())} / {jury_stats["nb_etuds"]}
</div>
<div><b>Codes annuels octroyés:</b></div>
<table class="jury_stats_codes">
"""
)
for code in sorted(jury_stats["codes_annuels"].keys()):
H.append(
f"""<tr>
<td>{code}</td>
<td style="text-align:right">{jury_stats["codes_annuels"][code]}</td>
<td style="text-align:right">{
(100*jury_stats["codes_annuels"][code] / jury_stats["nb_etuds"]):2.1f}%
</td>
</tr>"""
)
H.append(
f"""
</table>
</div>
{html_sco_header.sco_footer()} {html_sco_header.sco_footer()}
""" """
) )
@ -262,12 +278,16 @@ class RowCollector:
# --- Codes (seront cachés, mais exportés en excel) # --- Codes (seront cachés, mais exportés en excel)
self.add_cell("etudid", "etudid", etud.id, "codes") self.add_cell("etudid", "etudid", etud.id, "codes")
self.add_cell("code_nip", "code_nip", etud.code_nip or "", "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) # --- Identité étudiant (adapté de res_common/get_table_recap, à factoriser XXX TODO)
self.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail") self.add_cell("civilite_str", "Civ.", etud.civilite_str, "identite_detail")
self.add_cell("nom_disp", "Nom", etud.nom_disp(), "identite_detail") self.add_cell("nom_disp", "Nom", etud.nom_disp(), "identite_detail")
self["_nom_disp_order"] = etud.sort_key self["_nom_disp_order"] = etud.sort_key
self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail") self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court") self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court")
self["_nom_short_data"] = {
"etudid": etud.id,
"nomprenom": etud.nomprenom,
}
if with_links: if with_links:
self["_nom_short_order"] = etud.sort_key self["_nom_short_order"] = etud.sort_key
self["_nom_short_target"] = url_for( self["_nom_short_target"] = url_for(
@ -352,10 +372,6 @@ class RowCollector:
+ ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""), + ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""),
"col_rcue col_rcues_validables" + klass, "col_rcue col_rcues_validables" + klass,
) )
self["_rcues_validables_data"] = {
"etudid": deca.etud.id,
"nomprenom": deca.etud.nomprenom,
}
if len(deca.rcues_annee) > 0: if len(deca.rcues_annee) > 0:
# permet un tri par nb de niveaux validables + moyenne gen indicative S_pair # 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: if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen:
@ -377,10 +393,17 @@ class RowCollector:
def get_jury_but_table( def get_jury_but_table(
formsemestre2: FormSemestre, read_only: bool = False, mode="jury", with_links=True formsemestre2: FormSemestre, read_only: bool = False, mode="jury", with_links=True
) -> tuple[list[dict], list[str], list[str]]: ) -> tuple[list[dict], list[str], list[str], dict]:
"""Construit la table des résultats annuels pour le jury BUT""" """Construit la table des résultats annuels pour le jury BUT
=> rows_dict, titles, column_ids, jury_stats
jury_stats est un dict donnant des comptages sur le jury.
"""
res2: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre2) res2: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre2)
titles = {} # column_id : title titles = {} # column_id : title
jury_stats = {
"nb_etuds": len(formsemestre2.etuds_inscriptions),
"codes_annuels": collections.Counter(),
}
column_classes = {} column_classes = {}
rows = [] rows = []
for etudid in formsemestre2.etuds_inscriptions: for etudid in formsemestre2.etuds_inscriptions:
@ -417,6 +440,8 @@ def get_jury_but_table(
f"""{deca.code_valide or ''}""", f"""{deca.code_valide or ''}""",
"col_code_annee", "col_code_annee",
) )
if deca.code_valide:
jury_stats["codes_annuels"][deca.code_valide] += 1
# --- Le lien de saisie # --- Le lien de saisie
if mode != "recap" and with_links: if mode != "recap" and with_links:
row.add_cell( row.add_cell(
@ -439,11 +464,14 @@ def get_jury_but_table(
rows.append(row) rows.append(row)
rows_dict = [row.get_row_dict() for row in rows] rows_dict = [row.get_row_dict() for row in rows]
if len(rows_dict) > 0: if len(rows_dict) > 0:
res2.recap_add_partitions(rows_dict, titles, col_idx=row.last_etud_cell_idx + 1) col_idx = res2.recap_add_partitions(
rows_dict, titles, col_idx=row.last_etud_cell_idx + 1
)
res2.recap_add_cursus(rows_dict, titles, col_idx=col_idx + 1)
column_ids = [title for title in titles if not title.startswith("_")] 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)) column_ids.sort(key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000))
rows_dict.sort(key=lambda row: row["_nom_disp_order"]) rows_dict.sort(key=lambda row: row["_nom_disp_order"])
return rows_dict, titles, column_ids return rows_dict, titles, column_ids, jury_stats
def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]: def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -15,20 +15,32 @@ from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
def formsemestre_validation_auto_but(formsemestre: FormSemestre) -> int: def formsemestre_validation_auto_but(
"""Calcul automatique des décisions de jury sur une année BUT. formsemestre: FormSemestre, only_adm: bool = True, no_overwrite: bool = True
Returns: nombre d'étudiants "admis" ) -> 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(): if not formsemestre.formation.is_apc():
raise ScoValueError("fonction réservée aux formations BUT") raise ScoValueError("fonction réservée aux formations BUT")
nb_admis = 0 nb_etud_modif = 0
with sco_cache.DeferredSemCacheManager(): with sco_cache.DeferredSemCacheManager():
for etudid in formsemestre.etuds_inscriptions: for etudid in formsemestre.etuds_inscriptions:
etud: Identite = Identite.query.get(etudid) etud: Identite = Identite.query.get(etudid)
deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
if deca.admis: # année réussie nb_etud_modif += deca.record_all(
deca.record_all() no_overwrite=no_overwrite, only_validantes=only_adm
nb_admis += 1 )
db.session.commit() db.session.commit()
return nb_admis return nb_etud_modif

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -8,25 +8,34 @@
""" """
import re import re
import numpy as np
import flask import flask
from flask import flash, url_for from flask import flash, render_template, url_for
from flask import g, request from flask import g, request
from app import db from app import db
from app.but import jury_but from app.but import jury_but
from app.but.jury_but import DecisionsProposeesAnnee, DecisionsProposeesUE from app.but.jury_but import (
DecisionsProposeesAnnee,
DecisionsProposeesRCUE,
DecisionsProposeesUE,
)
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_but import ResultatsSemestreBUT
from app.models import ( from app.models import (
ApcNiveau,
FormSemestre, FormSemestre,
FormSemestreInscription, FormSemestreInscription,
Identite, Identite,
UniteEns, UniteEns,
ScolarAutorisationInscription, ScolarAutorisationInscription,
ScolarFormSemestreValidation,
) )
from app.models.config import ScoDocSiteConfig
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -35,14 +44,8 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
Si pas read_only, menus sélection codes jury. Si pas read_only, menus sélection codes jury.
""" """
H = [] 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 = ""
if deca.jury_annuel:
H.append( H.append(
f""" f"""
<div class="but_section_annee"> <div class="but_section_annee">
@ -51,21 +54,40 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
_gen_but_select("code_annee", deca.codes, deca.code_valide, _gen_but_select("code_annee", deca.codes, deca.code_valide,
disabled=True, klass="manual") disabled=True, klass="manual")
} }
<span>({'non ' if deca.code_valide is None else ''}enregistrée)</span> <span>({deca.code_valide or 'non'} enregistrée)</span>
<span>{erase_span}</span>
</div> </div>
<div class="but_explanation">{deca.explanation}</div>
</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( H.append(
f""" 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="but_annee">
<div class="titre"></div> <div class="titre"></div>
<div class="titre">S{1}</div> <div class="titre">{"S" +str(formsemestre_1.semestre_id)
<div class="titre">S{2}</div> 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> <div class="titre">RCUE</div>
""" """
) )
@ -75,44 +97,52 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
<div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div> <div title="{niveau.competence.titre_long}">{niveau.competence.titre}</div>
</div>""" </div>"""
) )
dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) # peut être None
if dec_rcue is None: ues = [
break ue
# Semestre impair 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( H.append(
_gen_but_niveau_ue( _gen_but_niveau_ue(
dec_rcue.rcue.ue_1, ue,
deca.decisions_ues[dec_rcue.rcue.ue_1.id].moy_ue, deca.decisions_ues[ue.id],
# dec_rcue.rcue.moy_ue_1, disabled=read_only or ue_read_only,
deca.decisions_ues[dec_rcue.rcue.ue_1.id], annee_prec=ue_read_only,
disabled=read_only, niveau_id=ue.niveau_competence.id,
) )
) )
# Semestre pair else:
H.append( H.append("""<div class="niveau_vide"></div>""")
_gen_but_niveau_ue(
dec_rcue.rcue.ue_2, # Colonne RCUE
deca.decisions_ues[dec_rcue.rcue.ue_2.id].moy_ue, H.append(_gen_but_rcue(dec_rcue, niveau))
# 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"
)
}</div>
</div>"""
)
H.append("</div>") # but_annee H.append("</div>") # but_annee
return "\n".join(H) return "\n".join(H)
@ -123,9 +153,12 @@ def _gen_but_select(
code_valide: str, code_valide: str,
disabled: bool = False, disabled: bool = False,
klass: str = "", klass: str = "",
data: dict = {},
) -> str: ) -> str:
"Le menu html select avec les codes" "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 ''} {'selected' if code == code_valide else ''}
@ -136,46 +169,139 @@ def _gen_but_select(
) )
return f"""<select required name="{name}" return f"""<select required name="{name}"
class="but_code {klass}" 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);" onchange="change_menu_code(this);"
{"disabled" if disabled else ""} {"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( 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 { return f"""<div class="but_niveau_ue {
'recorded' if dec_ue.code_valide is not None else ''} 'recorded' if dec_ue.code_valide is not None else ''}
{'annee_prec' if annee_prec else ''}
"> ">
<div title="{ue.titre}">{ue.acronyme}</div> <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">{ <div class="but_code">{
_gen_but_select("code_ue_"+str(ue.id), _gen_but_select("code_ue_"+str(ue.id),
dec_ue.codes, dec_ue.codes,
dec_ue.code_valide, disabled=disabled dec_ue.code_valide,
disabled=disabled,
klass=f"code_ue ue_rcue_{niveau_id}" if not disabled else ""
) )
}</div> }</div>
</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( def jury_but_semestriel(
formsemestre: FormSemestre, etud: Identite, read_only: bool formsemestre: FormSemestre,
etud: Identite,
read_only: bool,
navigation_div: str = "",
) -> str: ) -> str:
"""Formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)""" """Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)."""
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res) parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res)
inscription_etat = etud.inscription_etat(formsemestre.id) inscription_etat = etud.inscription_etat(formsemestre.id)
semestre_terminal = ( semestre_terminal = (
formsemestre.semestre_id >= formsemestre.formation.get_parcours().NB_SEM formsemestre.semestre_id >= formsemestre.formation.get_parcours().NB_SEM
) )
est_autorise_a_passer = (formsemestre.semestre_id + 1) in ( autorisations_passage = ScolarAutorisationInscription.query.filter_by(
a.semestre_id
for a in ScolarAutorisationInscription.query.filter_by(
etudid=etud.id, etudid=etud.id,
origin_formsemestre_id=formsemestre.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 = { decisions_ues = {
ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat) ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat)
for ue in ues for ue in ues
@ -188,9 +314,9 @@ def jury_but_semestriel(
for key in request.form: for key in request.form:
code = request.form[key] code = request.form[key]
# Codes d'UE # Codes d'UE
m = re.match(r"^code_ue_(\d+)$", key) code_match = re.match(r"^code_ue_(\d+)$", key)
if m: if code_match:
ue_id = int(m.group(1)) ue_id = int(code_match.group(1))
dec_ue = decisions_ues.get(ue_id) dec_ue = decisions_ues.get(ue_id)
if not dec_ue: if not dec_ue:
raise ScoValueError(f"UE invalide ue_id={ue_id}") raise ScoValueError(f"UE invalide ue_id={ue_id}")
@ -199,7 +325,9 @@ def jury_but_semestriel(
flash("codes enregistrés") flash("codes enregistrés")
if not semestre_terminal: if not semestre_terminal:
if request.form.get("autorisation_passage"): if request.form.get("autorisation_passage"):
if not est_autorise_a_passer: if not formsemestre.semestre_id + 1 in (
a.semestre_id for a in autorisations_passage
):
ScolarAutorisationInscription.autorise_etud( ScolarAutorisationInscription.autorise_etud(
etud.id, etud.id,
formsemestre.formation.formation_code, formsemestre.formation.formation_code,
@ -208,7 +336,8 @@ def jury_but_semestriel(
) )
db.session.commit() db.session.commit()
flash( flash(
f"autorisation de passage en S{formsemestre.semestre_id + 1} enregistrée" f"""autorisation de passage en S{formsemestre.semestre_id + 1
} enregistrée"""
) )
else: else:
if est_autorise_a_passer: if est_autorise_a_passer:
@ -237,7 +366,7 @@ def jury_but_semestriel(
warning = "" warning = ""
H = [ H = [
html_sco_header.sco_header( html_sco_header.sco_header(
page_title="Validation BUT", page_title=f"Validation BUT S{formsemestre.semestre_id}",
formsemestre_id=formsemestre.id, formsemestre_id=formsemestre.id,
etudid=etud.id, etudid=etud.id,
cssstyles=("css/jury_but.css",), cssstyles=("css/jury_but.css",),
@ -258,27 +387,47 @@ def jury_but_semestriel(
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a> }">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
</div> </div>
</div> </div>
<h3>Jury sur un semestre BUT isolé</h3> <h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
{warning} {warning}
</div> </div>
<form method="POST"> <form method="post" id="jury_but">
""", """,
] ]
if (not read_only) and any([dec.code_valide for dec in decisions_ues.values()]):
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="{ erase_span = f"""<a href="{
url_for("notes.formsemestre_jury_but_erase", url_for("notes.formsemestre_jury_but_erase",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
etudid=etud.id, only_one_sem=1)}" class="stdlink">effacer décisions</a>""" etudid=etud.id, only_one_sem=1)
}" class="stdlink">effacer les décisions enregistrées</a>"""
else: else:
erase_span = "aucune décision enregistrée pour ce semestre" erase_span = (
"Cet étudiant n'a aucune décision enregistrée pour ce semestre."
)
H.append( H.append(
f""" f"""
<div class="but_section_annee"> <div class="but_section_annee">
<span>{erase_span}</span>
</div> </div>
<div><b>Unités d'enseignement de S{formsemestre.semestre_id}:</b></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="but_annee">
<div class="titre"></div> <div class="titre"></div>
<div class="titre"></div> <div class="titre"></div>
@ -292,7 +441,6 @@ def jury_but_semestriel(
H.append( H.append(
_gen_but_niveau_ue( _gen_but_niveau_ue(
ue, ue,
dec_ue.moy_ue,
dec_ue, dec_ue,
disabled=read_only, disabled=read_only,
) )
@ -303,11 +451,27 @@ def jury_but_semestriel(
) )
H.append("</div>") # but_annee 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: if read_only:
H.append( H.append(
"""<div class="but_explanation"> f"""<div class="but_explanation">
Vous n'avez pas la permission de modifier ces décisions. {"Vous n'avez pas la permission de modifier ces décisions."
Les champs entourés en vert sont enregistrés.</div>""" if formsemestre.etat
else "Semestre verrouillé."}
Les champs entourés en vert sont enregistrés.
</div>
"""
) )
else: else:
if formsemestre.semestre_id < formsemestre.formation.get_parcours().NB_SEM: if formsemestre.semestre_id < formsemestre.formation.get_parcours().NB_SEM:
@ -324,12 +488,26 @@ def jury_but_semestriel(
else: else:
H.append("""<div class="help">dernier semestre de la formation.</div>""") H.append("""<div class="help">dernier semestre de la formation.</div>""")
H.append( H.append(
""" f"""
<div class="but_buttons"> <div class="but_buttons">
<input type="submit" value="Enregistrer ces décisions"> <span><input type="submit" value="Enregistrer ces décisions"></span>
<span>{erase_span}</span>
</div> </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) return "\n".join(H)
@ -355,7 +533,6 @@ def infos_fiche_etud_html(etudid: int) -> str:
# temporaire quick & dirty: affiche le dernier # temporaire quick & dirty: affiche le dernier
try: try:
deca = DecisionsProposeesAnnee(etud, formsemestres_but[-1]) 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)} {show_etud(deca, read_only=True)}
</div> </div>

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -18,21 +18,11 @@ import pandas as pd
from flask import g 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 UE_SPORT
from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono
from app.scodoc.sco_utils import ModuleType 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: class BonusSport:
"""Calcul du bonus sport. """Calcul du bonus sport.
@ -65,7 +55,7 @@ class BonusSport:
def __init__( def __init__(
self, self,
formsemestre: FormSemestre, formsemestre: "FormSemestre",
sem_modimpl_moys: np.array, sem_modimpl_moys: np.array,
ues: list, ues: list,
modimpl_inscr_df: pd.DataFrame, modimpl_inscr_df: pd.DataFrame,
@ -362,18 +352,37 @@ class BonusAisneStQuentin(BonusSportAdditif):
class BonusAmiens(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. sur toutes les moyennes d'UE.
</p>
""" """
name = "bonus_amiens" name = "bonus_amiens"
displayed_name = "IUT d'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 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. # 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): class BonusBethune(BonusSportMultiplicatif):
""" """
Calcul bonus modules optionnels (sport, culture), règle IUT de Béthune. 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 : dans la limite de 10 points. 6% de ces points cumulés s'ajoutent :
<ul> <ul>
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant. <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> </ul>
""" """
@ -1199,7 +1227,7 @@ class BonusStDenis(BonusSportAdditif):
bonus_max = 0.5 bonus_max = 0.5
class BonusStNazaire(BonusSportMultiplicatif): class BonusStNazaire(BonusSport):
"""IUT de Saint-Nazaire """IUT de Saint-Nazaire
Trois bonifications sont possibles : sport, culture et engagement citoyen Trois bonifications sont possibles : sport, culture et engagement citoyen
@ -1221,9 +1249,37 @@ class BonusStNazaire(BonusSportMultiplicatif):
name = "bonus_iutSN" name = "bonus_iutSN"
displayed_name = "IUT de Saint-Nazaire" displayed_name = "IUT de Saint-Nazaire"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP 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% amplitude = 0.01 / 4 # 4pt => 1%
factor_max = 0.1 # 10% max 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): class BonusTarbes(BonusIUTRennes1):
@ -1302,7 +1358,45 @@ class BonusIUTvannes(BonusSportAdditif):
classic_use_bonus_ues = False # seulement sur moy gen. classic_use_bonus_ues = False # seulement sur moy gen.
class BonusVilleAvray(BonusSport): class BonusValenciennes(BonusDirect):
"""Article 7 des RCC de lIUT de Valenciennes
<p>
Une bonification maximale de 0.25 point (1/4 de point) peut être ajoutée
à la moyenne de chaque Unité dEnseignement 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 lUPHF peut être attribuée
aux sportifs de haut niveau. Cette bonification est appliquée à lensemble des
Unités dEnseignement. 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. """Bonus modules optionnels (sport, culture), règle IUT Ville d'Avray.
Les étudiants de l'IUT peuvent suivre des enseignements optionnels Les étudiants de l'IUT peuvent suivre des enseignements optionnels
@ -1351,7 +1445,7 @@ class BonusIUTV(BonusSportAdditif):
name = "bonus_iutv" name = "bonus_iutv"
displayed_name = "IUT de Villetaneuse" 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): def get_bonus_class_dict(start=BonusSport, d=None):

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -122,6 +122,10 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
event_date : 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 = """ query = """
SELECT DISTINCT SFV.*, ue.ue_code SELECT DISTINCT SFV.*, ue.ue_code
FROM FROM

View File

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

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # 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 import dataclasses
from dataclasses import dataclass from dataclasses import dataclass
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import app
from app import db from app import db
from app.models import ModuleImpl, Evaluation, EvaluationUEPoids from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc import sco_cache
from app.scodoc.sco_exceptions import ScoBugCatcher from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -217,12 +219,19 @@ class ModuleImplResults:
] ]
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array: 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) Résultat: 2d-array of floats, shape (nb_evals, 1)
""" """
return ( return (
np.array( 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, dtype=float,
) )
* self.evaluations_completes * self.evaluations_completes
@ -236,8 +245,8 @@ class ModuleImplResults:
] ]
def get_eval_notes_sur_20(self, moduleimpl: ModuleImpl) -> np.array: def get_eval_notes_sur_20(self, moduleimpl: ModuleImpl) -> np.array:
"""Les notes des évaluations, """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. 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 Résultat: 2d array of floats, shape nb_etuds x nb_evaluations
""" """
return np.where( return np.where(
@ -368,7 +377,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
etuds_moy_module = np.where( etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module 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( self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
) )
@ -429,7 +438,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
def moduleimpl_is_conforme( def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modules_coefficients: pd.DataFrame moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool: ) -> bool:
"""Vérifie que les évaluations de ce moduleimpl sont bien conformes """Vérifie que les évaluations de ce moduleimpl sont bien conformes
au PN. au PN.
@ -438,7 +447,7 @@ def moduleimpl_is_conforme(
Arguments: Arguments:
evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs 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: les UEs dans evals_poids sont sans le bonus sport
""" """
nb_evals, nb_ues = evals_poids.shape nb_evals, nb_ues = evals_poids.shape
@ -446,18 +455,18 @@ def moduleimpl_is_conforme(
return True # modules vides conformes return True # modules vides conformes
if nb_ues == 0: if nb_ues == 0:
return False # situation absurde (pas d'UE) 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... # il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre() sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent") 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 ? # soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre() sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer") raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0 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): class ModuleImplResultsClassic(ModuleImplResults):
@ -476,7 +485,8 @@ class ModuleImplResultsClassic(ModuleImplResults):
if nb_etuds == 0: if nb_etuds == 0:
return pd.Series() return pd.Series()
evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1) 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) evals_notes_20 = self.get_eval_notes_sur_20(modimpl)
# Les coefs des évals pour chaque étudiant: là où il a des notes # Les coefs des évals pour chaque étudiant: là où il a des notes
# non neutralisées # non neutralisées

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # 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( 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: ) -> pd.Series:
"""Calcule les moyennes générales indicatives de tous les étudiants """Calcule les moyennes générales indicatives de tous les étudiants
= moyenne des moyennes d'UE, pondérée par leurs ECTS. = moyenne des moyennes d'UE, pondérée par leurs ECTS.
etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid 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. 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. 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: try:
if skip_empty_ues: if skip_empty_ues:
# annule les coefs des UE sans notes (NaN) # annule les coefs des UE sans notes (NaN)
ects = np.where(etud_moy_ue_df.isna(), 0.0, np.array(ects, dtype=float)) ects = np.where(etud_moy_ue_df.isna(), 0.0, ects_df.to_numpy())
# ects est devenu nb_etuds x nb_ues
moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / ects.sum(axis=1)
else: 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: except TypeError:
if None in ects: if None in ects:
formation = Formation.query.get(formation_id) formation = Formation.query.get(formation_id)

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -32,9 +32,14 @@ import pandas as pd
from app import db from app import db
from app import models 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.comp import moy_mod
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_codes_parcours from app.scodoc import sco_codes_parcours
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_codes_parcours import UE_SPORT
@ -69,9 +74,7 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data
& (UniteEns.type == sco_codes_parcours.UE_SPORT) & (UniteEns.type == sco_codes_parcours.UE_SPORT)
) )
) )
.order_by( .order_by(Module.semestre_id, Module.module_type, Module.numero, Module.code)
Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code
)
) )
if semestre_idx is not None: if semestre_idx is not None:
ues = ues.filter_by(semestre_idx=semestre_idx) ues = ues.filter_by(semestre_idx=semestre_idx)
@ -140,7 +143,8 @@ def df_load_modimpl_coefs(
mod_coef.ue_id mod_coef.ue_id
] = mod_coef.coef ] = mod_coef.coef
except IndexError: 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 pass
# Initialisation des poids non fixés: # Initialisation des poids non fixés:
# 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse
@ -199,7 +203,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
modimpls_results[modimpl.id] = mod_results modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module) modimpls_notes.append(etuds_moy_module)
if len(modimpls_notes): if len(modimpls_notes) > 0:
cube = notes_sem_assemble_cube(modimpls_notes) cube = notes_sem_assemble_cube(modimpls_notes)
else: else:
nb_etuds = formsemestre.etuds.count() nb_etuds = formsemestre.etuds.count()
@ -215,10 +219,11 @@ def compute_ue_moys_apc(
sem_cube: np.array, sem_cube: np.array,
etuds: list, etuds: list,
modimpls: list, modimpls: list,
ues: list,
modimpl_inscr_df: pd.DataFrame, modimpl_inscr_df: pd.DataFrame,
modimpl_coefs_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame,
modimpl_mask: np.array, modimpl_mask: np.array,
dispense_ues: set[tuple[int, int]],
block: bool = False,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Calcul de la moyenne d'UE en mode APC (BUT). """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 La moyenne d'UE est un nombre (note/20), ou NaN si pas de notes disponibles
@ -229,18 +234,17 @@ def compute_ue_moys_apc(
etuds : liste des étudiants (dim. 0 du cube) etuds : liste des étudiants (dim. 0 du cube)
modimpls : liste des module_impl (dim. 1 du cube) modimpls : liste des module_impl (dim. 1 du cube)
ues : liste des UE (dim. 2 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_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. modimpl_mask: liste de booléens, indiquants le module doit être pris ou pas.
(utilisé pour éliminer les bonus, et pourra servir à cacluler (utilisé pour éliminer les bonus, et pourra servir à cacluler
sur des sous-ensembles de modules) 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 Résultat: DataFrame columns UE (sans bonus), rows etudid
""" """
nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape
nb_ues_tot = len(ues)
assert len(modimpls) == nb_modules 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( return pd.DataFrame(
index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index
) )
@ -277,11 +281,16 @@ def compute_ue_moys_apc(
etud_moy_ue = np.sum( etud_moy_ue = np.sum(
modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1 modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1
) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1)
return pd.DataFrame( etud_moy_ue_df = pd.DataFrame(
etud_moy_ue, etud_moy_ue,
index=modimpl_inscr_df.index, # les etudids index=modimpl_inscr_df.index, # les etudids
columns=modimpl_coefs_df.index, # les UE sans les UE bonus sport 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( def compute_ue_moys_classic(
@ -291,6 +300,7 @@ def compute_ue_moys_classic(
modimpl_inscr_df: pd.DataFrame, modimpl_inscr_df: pd.DataFrame,
modimpl_coefs: np.array, modimpl_coefs: np.array,
modimpl_mask: np.array, modimpl_mask: np.array,
block: bool = False,
) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]: ) -> 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, ...). """Calcul de la moyenne d'UE et de la moy. générale en mode classique (DUT, LMD, ...).
@ -312,6 +322,7 @@ def compute_ue_moys_classic(
modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl)
modimpl_coefs: vecteur des coefficients de modules modimpl_coefs: vecteur des coefficients de modules
modimpl_mask: masque des modimpls à prendre en compte modimpl_mask: masque des modimpls à prendre en compte
block: si vrai, ne calcule rien et renvoie des NaNs
Résultat: Résultat:
- moyennes générales: pd.Series, index etudid - moyennes générales: pd.Series, index etudid
@ -320,13 +331,14 @@ def compute_ue_moys_classic(
les coefficients effectifs de chaque UE pour chaque étudiant les coefficients effectifs de chaque UE pour chaque étudiant
(sommes de coefs de modules pris en compte) (sommes de coefs de modules pris en compte)
""" """
if (not len(modimpl_mask)) or ( if (
sem_matrix.shape[0] == 0 block or (len(modimpl_mask) == 0) or (sem_matrix.shape[0] == 0)
): # aucun module ou aucun étudiant ): # aucun module ou aucun étudiant
# etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df # etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df
val = np.nan if block else 0.0
return ( return (
pd.Series( 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),
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 +443,7 @@ def compute_mat_moys_classic(
Résultat: Résultat:
- moyennes: pd.Series, index etudid - moyennes: pd.Series, index etudid
""" """
if (not len(modimpl_mask)) or ( if (0 == len(modimpl_mask)) or (
sem_matrix.shape[0] == 0 sem_matrix.shape[0] == 0
): # aucun module ou aucun étudiant ): # aucun module ou aucun étudiant
# etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df # etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df
@ -462,6 +474,7 @@ def compute_mat_moys_classic(
if modimpl_coefs_etuds_no_nan.dtype == object: # arrive sur des tableaux vides 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) modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float)
with np.errstate(invalid="ignore"): # il peut y avoir des NaN
etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum( etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum(
axis=1 axis=1
) / modimpl_coefs_etuds_no_nan.sum(axis=1) ) / modimpl_coefs_etuds_no_nan.sum(axis=1)

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -16,7 +16,7 @@ from app.comp.res_compat import NotesTableCompat
from app.comp.bonus_spo import BonusSport from app.comp.bonus_spo import BonusSport
from app.models import ScoDocSiteConfig from app.models import ScoDocSiteConfig
from app.models.moduleimpls import ModuleImpl from app.models.moduleimpls import ModuleImpl
from app.models.ues import UniteEns from app.models.ues import DispenseUE, UniteEns
from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
@ -39,6 +39,7 @@ class ResultatsSemestreBUT(NotesTableCompat):
"""ndarray (etuds x modimpl x ue)""" """ndarray (etuds x modimpl x ue)"""
self.etuds_parcour_id = None self.etuds_parcour_id = None
"""Parcours de chaque étudiant { etudid : parcour_id }""" """Parcours de chaque étudiant { etudid : parcour_id }"""
if not self.load_cached(): if not self.load_cached():
t0 = time.time() t0 = time.time()
self.compute() self.compute()
@ -71,15 +72,18 @@ class ResultatsSemestreBUT(NotesTableCompat):
modimpl.module.ue.type != UE_SPORT modimpl.module.ue.type != UE_SPORT
for modimpl in self.formsemestre.modimpls_sorted 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.etud_moy_ue = moy_ue.compute_ue_moys_apc(
self.sem_cube, self.sem_cube,
self.etuds, self.etuds,
self.formsemestre.modimpls_sorted, self.formsemestre.modimpls_sorted,
self.ues,
self.modimpl_inscr_df, self.modimpl_inscr_df,
self.modimpl_coefs_df, self.modimpl_coefs_df,
modimpls_mask, modimpls_mask,
self.dispense_ues,
block=self.formsemestre.block_moyennes,
) )
# Les coefficients d'UE ne sont pas utilisés en APC # Les coefficients d'UE ne sont pas utilisés en APC
self.etud_coef_ue_df = pd.DataFrame( self.etud_coef_ue_df = pd.DataFrame(
@ -114,6 +118,10 @@ class ResultatsSemestreBUT(NotesTableCompat):
# Nanifie les moyennes d'UE hors parcours pour chaque étudiant # Nanifie les moyennes d'UE hors parcours pour chaque étudiant
self.etud_moy_ue *= self.ues_inscr_parcours_df 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: # Moyenne générale indicative:
# (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte # (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte
@ -121,9 +129,14 @@ class ResultatsSemestreBUT(NotesTableCompat):
# self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_coefs( # self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_coefs(
# self.etud_moy_ue, self.modimpl_coefs_df # self.etud_moy_ue, self.modimpl_coefs_df
# ) # )
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_gen = moy_sem.compute_sem_moys_apc_using_ects(
self.etud_moy_ue, self.etud_moy_ue,
[ue.ects for ue in self.ues if ue.type != UE_SPORT], ects,
formation_id=self.formsemestre.formation_id, formation_id=self.formsemestre.formation_id,
skip_empty_ues=sco_preferences.get_preference( skip_empty_ues=sco_preferences.get_preference(
"but_moy_skip_empty_ues", self.formsemestre.id "but_moy_skip_empty_ues", self.formsemestre.id
@ -204,27 +217,33 @@ class ResultatsSemestreBUT(NotesTableCompat):
} }
self.etuds_parcour_id = etuds_parcour_id self.etuds_parcour_id = etuds_parcour_id
ue_ids = [ue.id for ue in self.ues if ue.type != UE_SPORT] 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( if self.formsemestre.formation.referentiel_competence is None:
return pd.DataFrame(
1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float 1.0, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float
) )
if self.formsemestre.formation.referentiel_competence is None: # matrice de NaN: inscrits par défaut à AUCUNE UE:
return ues_inscr_parcours_df ues_inscr_parcours_df = pd.DataFrame(
np.nan, index=etuds_parcour_id.keys(), columns=ue_ids, dtype=float # XXX
)
# 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} ue_by_parcours = {} # parcours_id : {ue_id:0|1}
for parcour in self.formsemestre.formation.referentiel_competence.parcours: for (
ue_by_parcours[parcour.id] = { 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 ue.id: 1.0
for ue in self.formsemestre.formation.query_ues_parcour( for ue in self.formsemestre.formation.query_ues_parcour(
parcour parcour
).filter_by(semestre_idx=self.formsemestre.semestre_id) ).filter_by(semestre_idx=self.formsemestre.semestre_id)
} }
#
for etudid in etuds_parcour_id: for etudid in etuds_parcour_id:
parcour = etuds_parcour_id[etudid] parcour_id = etuds_parcour_id[etudid]
if parcour is not None: if parcour_id in ue_by_parcours:
ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[ if ue_by_parcours[parcour_id]:
etuds_parcour_id[etudid] ues_inscr_parcours_df.loc[etudid] = ue_by_parcours[parcour_id]
]
return ues_inscr_parcours_df return ues_inscr_parcours_df
def etud_ues_ids(self, etudid: int) -> list[int]: def etud_ues_ids(self, etudid: int) -> list[int]:

View File

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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -90,6 +90,7 @@ class ResultatsSemestreClassic(NotesTableCompat):
self.modimpl_inscr_df, self.modimpl_inscr_df,
self.modimpl_coefs, self.modimpl_coefs,
modimpl_standards_mask, modimpl_standards_mask,
block=self.formsemestre.block_moyennes,
) )
# --- Modules de MALUS sur les UEs et la moyenne générale # --- Modules de MALUS sur les UEs et la moyenne générale
self.malus = moy_ue.compute_malus( self.malus = moy_ue.compute_malus(

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -48,12 +48,13 @@ class ResultatsSemestre(ResultatsCache):
_cached_attrs = ( _cached_attrs = (
"bonus", "bonus",
"bonus_ues", "bonus_ues",
"dispense_ues",
"etud_coef_ue_df",
"etud_moy_gen_ranks", "etud_moy_gen_ranks",
"etud_moy_gen", "etud_moy_gen",
"etud_moy_ue", "etud_moy_ue",
"modimpl_inscr_df", "modimpl_inscr_df",
"modimpls_results", "modimpls_results",
"etud_coef_ue_df",
"moyennes_matieres", "moyennes_matieres",
) )
@ -66,6 +67,8 @@ class ResultatsSemestre(ResultatsCache):
"Bonus sur moy. gen. Series de float, index etudid" "Bonus sur moy. gen. Series de float, index etudid"
self.bonus_ues: pd.DataFrame = None # virtuel self.bonus_ues: pd.DataFrame = None # virtuel
"DataFrame de float, index etudid, columns: ue.id" "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 # ResultatsSemestreBUT ou ResultatsSemestreClassic
self.etud_moy_ue = {} self.etud_moy_ue = {}
"etud_moy_ue: DataFrame columns UE, rows etudid" "etud_moy_ue: DataFrame columns UE, rows etudid"
@ -316,7 +319,7 @@ class ResultatsSemestre(ResultatsCache):
"""L'état de l'UE pour cet étudiant. """L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre. Result: dict, ou None si l'UE n'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: if ue.type == UE_SPORT:
return { return {
"is_capitalized": False, "is_capitalized": False,
@ -439,7 +442,7 @@ class ResultatsSemestre(ResultatsCache):
allow_html=True, allow_html=True,
): ):
"""Table récap. des résultats. """Table récap. des résultats.
allow_html: si vri, peut-mettre du HTML dans les valeurs allow_html: si vrai, peut mettre du HTML dans les valeurs
Result: tuple avec Result: tuple avec
- rows: liste de dicts { column_id : value } - rows: liste de dicts { column_id : value }
@ -491,7 +494,7 @@ class ResultatsSemestre(ResultatsCache):
classes: str = "", classes: str = "",
idx: int = 100, idx: int = 100,
): ):
"Add a row to our table. classes is a list of css class names" "Add a cell to our table. classes is a list of css class names"
row[col_id] = content row[col_id] = content
if classes: if classes:
row[f"_{col_id}_class"] = classes + f" c{idx}" row[f"_{col_id}_class"] = classes + f" c{idx}"
@ -516,6 +519,7 @@ class ResultatsSemestre(ResultatsCache):
row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx
) )
# --- Rang # --- Rang
if not self.formsemestre.block_moyenne_generale:
idx = add_cell( idx = add_cell(
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
) )
@ -539,12 +543,17 @@ class ResultatsSemestre(ResultatsCache):
formsemestre_id=self.formsemestre.id, formsemestre_id=self.formsemestre.id,
etudid=etudid, etudid=etudid,
) )
row["_nom_short_data"] = {
"etudid": etud.id,
"nomprenom": etud.nomprenom,
}
row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"' row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
row["_nom_disp_target"] = row["_nom_short_target"] row["_nom_disp_target"] = row["_nom_short_target"]
row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"] row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]
idx = 30 # début des colonnes de notes idx = 30 # début des colonnes de notes
# --- Moyenne générale # --- Moyenne générale
if not self.formsemestre.block_moyenne_generale:
moy_gen = self.etud_moy_gen.get(etudid, False) moy_gen = self.etud_moy_gen.get(etudid, False)
note_class = "" note_class = ""
if moy_gen is False: if moy_gen is False:
@ -564,7 +573,8 @@ class ResultatsSemestre(ResultatsCache):
) )
# --- Moyenne d'UE # --- Moyenne d'UE
nb_ues_validables, nb_ues_warning = 0, 0 nb_ues_validables, nb_ues_warning = 0, 0
for ue in ues_sans_bonus: idx_ue_start = idx
for idx_ue, ue in enumerate(ues_sans_bonus):
ue_status = self.get_etud_ue_status(etudid, ue.id) ue_status = self.get_etud_ue_status(etudid, ue.id)
if ue_status is not None: if ue_status is not None:
col_id = f"moy_ue_{ue.id}" col_id = f"moy_ue_{ue.id}"
@ -585,7 +595,7 @@ class ResultatsSemestre(ResultatsCache):
ue.acronyme, ue.acronyme,
fmt_note(val), fmt_note(val),
"col_ue" + note_class, "col_ue" + note_class,
idx, idx_ue * 10000 + idx_ue_start,
) )
titles_bot[ titles_bot[
f"_{col_id}_target_attrs" f"_{col_id}_target_attrs"
@ -606,7 +616,7 @@ class ResultatsSemestre(ResultatsCache):
f"Bonus {ue.acronyme}", f"Bonus {ue.acronyme}",
val_fmt_html if allow_html else val_fmt, val_fmt_html if allow_html else val_fmt,
"col_ue_bonus", "col_ue_bonus",
idx, idx_ue * 10000 + idx_ue_start + 1,
) )
row[f"_bonus_ue_{ue.id}_xls"] = val_fmt row[f"_bonus_ue_{ue.id}_xls"] = val_fmt
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE # Les moyennes des modules (ou ressources et SAÉs) dans cette UE
@ -651,7 +661,11 @@ class ResultatsSemestre(ResultatsCache):
val_fmt_html, val_fmt_html,
# class col_res mod_ue_123 # class col_res mod_ue_123
f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}", f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}",
idx, idx_ue * 10000
+ idx_ue_start
+ 1
+ (modimpl.module.module_type or 0) * 1000
+ (modimpl.module.numero or 0),
) )
row[f"_{col_id}_xls"] = val_fmt row[f"_{col_id}_xls"] = val_fmt
if modimpl.module.module_type == scu.ModuleType.MALUS: if modimpl.module.module_type == scu.ModuleType.MALUS:
@ -701,7 +715,7 @@ class ResultatsSemestre(ResultatsCache):
else: else:
jury_code_sem = "" jury_code_sem = ""
else: else:
# formations classiqes: code semestre # formations classiques: code semestre
dec_sem = self.validations.decisions_jury.get(etudid) dec_sem = self.validations.decisions_jury.get(etudid)
jury_code_sem = dec_sem["code"] if dec_sem else "" jury_code_sem = dec_sem["code"] if dec_sem else ""
idx = add_cell( idx = add_cell(
@ -719,17 +733,22 @@ class ResultatsSemestre(ResultatsCache):
f"""<a href="{url_for('notes.formsemestre_validation_etud_form', f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid
) )
}">{"saisir" if not jury_code_sem else "modifier"} décision</a>""", }">{("saisir" if not jury_code_sem else "modifier")
if self.formsemestre.etat else "voir"} décisions</a>""",
"col_jury_link", "col_jury_link",
idx, idx,
) )
rows.append(row) rows.append(row)
self.recap_add_partitions(rows, titles) col_idx = self.recap_add_partitions(rows, titles)
self.recap_add_cursus(rows, titles, col_idx=col_idx + 1)
self._recap_add_admissions(rows, titles) self._recap_add_admissions(rows, titles)
# tri par rang croissant # tri par rang croissant
if not self.formsemestre.block_moyenne_generale:
rows.sort(key=lambda e: e["_rang_order"]) rows.sort(key=lambda e: e["_rang_order"])
else:
rows.sort(key=lambda e: e["_ues_validables_order"], reverse=True)
# INFOS POUR FOOTER # INFOS POUR FOOTER
bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note) bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note)
@ -746,6 +765,20 @@ class ResultatsSemestre(ResultatsCache):
for row in bottom_infos.values(): for row in bottom_infos.values():
row[c_class] = row.get(c_class, "") + " col_empty" row[c_class] = row.get(c_class, "") + " col_empty"
# Ligne avec la classe de chaque colonne
# récupère le type à partir des classes css (hack...)
row_class = {}
for col_id in titles:
klass = titles.get(f"_{col_id}_class")
if klass:
row_class[col_id] = " ".join(
cls[4:] for cls in klass.split() if cls.startswith("col_")
)
# cette case (nb d'UE validables) a deux classes col_xxx, on en garde une seule:
if "ues_validables" in row_class[col_id]:
row_class[col_id] = "ues_validables"
bottom_infos["type_col"] = row_class
# --- TABLE FOOTER: ECTS, moyennes, min, max... # --- TABLE FOOTER: ECTS, moyennes, min, max...
footer_rows = [] footer_rows = []
for (bottom_line, row) in bottom_infos.items(): for (bottom_line, row) in bottom_infos.items():
@ -769,7 +802,7 @@ class ResultatsSemestre(ResultatsCache):
return (rows, footer_rows, titles, column_ids) return (rows, footer_rows, titles, column_ids)
def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict: def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
"""Les informations à mettre en bas de la table: min, max, moy, ECTS""" """Les informations à mettre en bas de la table: min, max, moy, ECTS, Apo"""
row_min, row_max, row_moy, row_coef, row_ects, row_apo = ( row_min, row_max, row_moy, row_coef, row_ects, row_apo = (
{"_tr_class": "bottom_info", "_title": "Min."}, {"_tr_class": "bottom_info", "_title": "Min."},
{"_tr_class": "bottom_info"}, {"_tr_class": "bottom_info"},
@ -829,7 +862,7 @@ class ResultatsSemestre(ResultatsCache):
row_moy[f"_{colid}_class"] = "col_empty" row_moy[f"_{colid}_class"] = "col_empty"
row_apo[colid] = modimpl.module.code_apogee or "" row_apo[colid] = modimpl.module.code_apogee or ""
return { # { key : row } avec key = min, max, moy, coef return { # { key : row } avec key = min, max, moy, coef, ...
"min": row_min, "min": row_min,
"max": row_max, "max": row_max,
"moy": row_moy, "moy": row_moy,
@ -877,7 +910,7 @@ class ResultatsSemestre(ResultatsCache):
} }
first = True first = True
for i, cid in enumerate(fields): for i, cid in enumerate(fields):
titles[f"_{cid}_col_order"] = 10000 + i # tout à droite titles[f"_{cid}_col_order"] = 100000 + i # tout à droite
if first: if first:
titles[f"_{cid}_class"] = "admission admission_first" titles[f"_{cid}_class"] = "admission admission_first"
first = False first = False
@ -896,10 +929,29 @@ class ResultatsSemestre(ResultatsCache):
else: else:
row[f"_{cid}_class"] = "admission" row[f"_{cid}_class"] = "admission"
def recap_add_partitions(self, rows: list[dict], titles: dict, col_idx: int = None): def recap_add_cursus(self, rows: list[dict], titles: dict, col_idx: int = None):
"""Ajoute colonne avec code cursus, eg 'S1 S2 S1'"""
cid = "code_cursus"
titles[cid] = "Cursus"
titles[f"_{cid}_col_order"] = col_idx
formation_code = self.formsemestre.formation.formation_code
for row in rows:
etud = Identite.query.get(row["etudid"])
row[cid] = " ".join(
[
f"S{ins.formsemestre.semestre_id}"
for ins in reversed(etud.inscriptions())
if ins.formsemestre.formation.formation_code == formation_code
]
)
def recap_add_partitions(
self, rows: list[dict], titles: dict, col_idx: int = None
) -> int:
"""Ajoute les colonnes indiquant les groupes """Ajoute les colonnes indiquant les groupes
rows est une liste de dict avec une clé "etudid" rows est une liste de dict avec une clé "etudid"
Les colonnes ont la classe css "partition" Les colonnes ont la classe css "partition"
Renvoie l'indice de la dernière colonne utilisée
""" """
partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups( partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups(
self.formsemestre.id self.formsemestre.id
@ -948,6 +1000,7 @@ class ResultatsSemestre(ResultatsCache):
row[rg_cid] = rang.get(row["etudid"], "") row[rg_cid] = rang.get(row["etudid"], "")
first_partition = False first_partition = False
return col_order
def _recap_add_evaluations( def _recap_add_evaluations(
self, rows: list[dict], titles: dict, bottom_infos: dict self, rows: list[dict], titles: dict, bottom_infos: dict

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -25,7 +25,7 @@ class NotesTableCompat(ResultatsSemestre):
"""Implementation partielle de NotesTable """Implementation partielle de NotesTable
Les méthodes définies dans cette classe sont 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 il n'est pas recommandé de les utiliser dans de nouveaux
développements (API malcommode et peu efficace). développements (API malcommode et peu efficace).
""" """
@ -103,10 +103,9 @@ class NotesTableCompat(ResultatsSemestre):
"""Stats (moy/min/max) sur la moyenne générale""" """Stats (moy/min/max) sur la moyenne générale"""
return StatsMoyenne(self.etud_moy_gen) return StatsMoyenne(self.etud_moy_gen)
def get_ues_stat_dict( def get_ues_stat_dict(self, filter_sport=False, check_apc_ects=True) -> list[dict]:
self, filter_sport=False, check_apc_ects=True """Liste des UEs de toutes les UEs du semestre (tous parcours),
) -> list[dict]: # was get_ues() ordonnée par numero.
"""Liste des UEs, ordonnée par numero.
Si filter_sport, retire les UE de type SPORT. Si filter_sport, retire les UE de type SPORT.
Résultat: liste de dicts { champs UE U stats moyenne UE } Résultat: liste de dicts { champs UE U stats moyenne UE }
""" """

View File

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

View File

@ -16,6 +16,7 @@ import flask_login
import app import app
from app.auth.models import User from app.auth.models import User
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
class ZUser(object): class ZUser(object):
@ -180,18 +181,23 @@ def scodoc7func(func):
else: else:
arg_names = argspec.args arg_names = argspec.args
for arg_name in arg_names: # pour chaque arg de la fonction vue 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: # peut produire une KeyError s'il manque un argument attendu:
v = req_args[arg_name] v = req_args[arg_name]
# try to convert all arguments to INTEGERS # try to convert all arguments to INTEGERS
# necessary for db ids and boolean values # necessary for db ids and boolean values
try: try:
v = int(v) v = int(v) if v else v
except (ValueError, TypeError): except (ValueError, TypeError) as exc:
pass 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) pos_arg_values.append(v)
# current_app.logger.info("pos_arg_values=%s" % pos_arg_values) # current_app.logger.info("pos_arg_values=%s" % pos_arg_values)
# current_app.logger.info("req_args=%s" % req_args) # current_app.logger.info("req_args=%s" % req_args)

View File

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

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

@ -5,7 +5,7 @@
# #
# ScoDoc # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -63,6 +63,7 @@ class CodesDecisionsForm(FlaskForm):
ABL = _build_code_field("ABL") ABL = _build_code_field("ABL")
ADC = _build_code_field("ADC") ADC = _build_code_field("ADC")
ADJ = _build_code_field("ADJ") ADJ = _build_code_field("ADJ")
ADJR = _build_code_field("ADJR")
ADM = _build_code_field("ADM") ADM = _build_code_field("ADM")
AJ = _build_code_field("AJ") AJ = _build_code_field("AJ")
ATB = _build_code_field("ATB") ATB = _build_code_field("ATB")

View File

@ -5,7 +5,7 @@
# #
# ScoDoc # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -171,7 +171,7 @@ class AddLogoForm(FlaskForm):
class LogoForm(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)""" and all its data and UI action (change, delete)"""
dept_key = HiddenField() dept_key = HiddenField()
@ -434,7 +434,7 @@ def config_logos():
scu.flash_errors(form) scu.flash_errors(form)
return render_template( return render_template(
"config_logos.html", "config_logos.j2",
scodoc_dept=None, scodoc_dept=None,
title="Configuration ScoDoc", title="Configuration ScoDoc",
form=form, form=form,

View File

@ -5,7 +5,7 @@
# #
# ScoDoc # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -54,6 +54,22 @@ class BonusConfigurationForm(FlaskForm):
class ScoDocConfigurationForm(FlaskForm): class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée" "Panneau de configuration avancée"
enable_entreprises = BooleanField("activer le module <em>entreprises</em>") 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)
],
)
submit_scodoc = SubmitField("Valider") submit_scodoc = SubmitField("Valider")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True}) cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -67,7 +83,11 @@ def configuration():
} }
) )
form_scodoc = ScoDocConfigurationForm( 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(),
}
) )
if request.method == "POST" and ( if request.method == "POST" and (
form_bonus.cancel_bonus.data or form_scodoc.cancel_scodoc.data form_bonus.cancel_bonus.data or form_scodoc.cancel_scodoc.data
@ -94,10 +114,26 @@ def configuration():
"Module entreprise " "Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé") + ("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]
}"""
)
return redirect(url_for("scodoc.index")) return redirect(url_for("scodoc.index"))
return render_template( return render_template(
"configuration.html", "configuration.j2",
form_bonus=form_bonus, form_bonus=form_bonus,
form_scodoc=form_scodoc, form_scodoc=form_scodoc,
scu=scu, scu=scu,

View File

@ -5,7 +5,7 @@
# #
# ScoDoc # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -36,7 +36,7 @@ from app.models.etudiants import (
from app.models.events import Scolog, ScolarNews from app.models.events import Scolog, ScolarNews
from app.models.formations import Formation, Matiere from app.models.formations import Formation, Matiere
from app.models.modules import Module, ModuleUECoef, NotesTag, notes_modules_tags 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 ( from app.models.formsemestre import (
FormSemestre, FormSemestre,
FormSemestreEtape, FormSemestreEtape,
@ -72,12 +72,15 @@ from app.models.validations import (
from app.models.preferences import ScoPreference from app.models.preferences import ScoPreference
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcReferentielCompetences,
ApcCompetence,
ApcSituationPro,
ApcAppCritique, ApcAppCritique,
ApcCompetence,
ApcNiveau,
ApcParcours, ApcParcours,
ApcReferentielCompetences,
ApcSituationPro,
) )
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.config import ScoDocSiteConfig 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 db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True
) )
jour = db.Column(db.Date) jour = db.Column(db.Date)
# absent / justifié / absent+ justifié
estabs = db.Column(db.Boolean()) estabs = db.Column(db.Boolean())
estjust = db.Column(db.Boolean()) estjust = db.Column(db.Boolean())
matin = db.Column(db.Boolean()) matin = db.Column(db.Boolean())
# motif de l'absence: # motif de l'absence:
description = db.Column(db.Text()) description = db.Column(db.Text())

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

@ -0,0 +1,277 @@
# -*- 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,
is_period_overlapping,
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)
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())
def to_dict(self, format_api=True) -> dict:
etat = self.etat
if format_api:
etat = EtatAssiduite.inverse().get(self.etat).name
data = {
"assiduite_id": self.assiduite_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,
}
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,
) -> 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,
)
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,
)
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,
) -> 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,
)
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())
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,
}
return data
@classmethod
def create_justificatif(
cls,
etud: Identite,
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"""
# Vérification de non duplication des périodes
justificatifs: list[Justificatif] = etud.justificatifs
if is_period_conflicting(date_debut, date_fin, justificatifs, Justificatif):
raise ScoValueError(
"Duplication des justificatifs (la période rentrée rentre en conflit avec un justificatif enregistré)"
)
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
raison=raison,
entry_date=entry_date,
)
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

View File

@ -1,6 +1,6 @@
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
"""ScoDoc 9 models : Référentiel Compétence BUT 2021 """ScoDoc 9 models : Référentiel Compétence BUT 2021
@ -14,7 +14,7 @@ import sqlalchemy
from app import db from app import db
from app.scodoc.sco_utils import ModuleType 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 # from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
@ -54,13 +54,15 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"Référentiel de compétence d'une spécialité" "Référentiel de compétence d'une spécialité"
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
annexe = db.Column(db.Text()) annexe = db.Column(db.Text()) # '1', '22', ...
specialite = db.Column(db.Text()) specialite = db.Column(db.Text()) # 'CJ', 'RT', 'INFO', ...
specialite_long = db.Column(db.Text()) specialite_long = db.Column(
type_titre = db.Column(db.Text()) db.Text()
type_structure = 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" 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 _xml_attribs = { # Orébut xml attrib : attribute
"type": "type_titre", "type": "type_titre",
"version": "version_orebut", "version": "version_orebut",
@ -86,9 +88,16 @@ class ApcReferentielCompetences(db.Model, XMLModel):
def __repr__(self): def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>" 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. """Représentation complète du ref. de comp.
comme un dict. comme un dict.
Si parcours est une liste de parcours, restreint l'export aux parcours listés.
""" """
return { return {
"dept_id": self.dept_id, "dept_id": self.dept_id,
@ -103,29 +112,45 @@ class ApcReferentielCompetences(db.Model, XMLModel):
if self.scodoc_date_loaded if self.scodoc_date_loaded
else "", else "",
"scodoc_orig_filename": self.scodoc_orig_filename, "scodoc_orig_filename": self.scodoc_orig_filename,
"competences": {x.titre: x.to_dict() for x in self.competences}, "competences": {
"parcours": {x.code: x.to_dict() for x in self.parcours}, 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 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. 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: 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 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). la clé "TC" (toujours présente mais éventuellement liste vide si pas de tronc commun).
résultat: Résultat: couple
( [ ApcParcours ],
{ {
"TC" : [ ApcNiveau ], "TC" : [ ApcNiveau ],
parcour.id : [ 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 = { niveaux_by_parcours = {
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self) parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
for parcour in parcours for parcour in parcours_ref
} }
# Cherche tronc commun # Cherche tronc commun
if niveaux_by_parcours: if niveaux_by_parcours:
@ -154,7 +179,28 @@ class ApcReferentielCompetences(db.Model, XMLModel):
niveau for niveau in niveaux_parcours_1 if niveau.id in niveaux_ids_tc niveau for niveau in niveaux_parcours_1 if niveau.id in niveaux_ids_tc
] ]
niveaux_by_parcours_no_tc["TC"] = niveaux_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): class ApcCompetence(db.Model, XMLModel):
@ -197,7 +243,7 @@ class ApcCompetence(db.Model, XMLModel):
def __repr__(self): def __repr__(self):
return f"<ApcCompetence {self.id} {self.titre!r}>" 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" "repr dict recursive sur situations, composantes, niveaux"
return { return {
"id_orebut": self.id_orebut, "id_orebut": self.id_orebut,
@ -209,7 +255,10 @@ class ApcCompetence(db.Model, XMLModel):
"composantes_essentielles": [ "composantes_essentielles": [
x.to_dict() for x in self.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: def to_dict_bul(self) -> dict:
@ -275,13 +324,15 @@ class ApcNiveau(db.Model, XMLModel):
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={ return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
self.annee!r} {self.competence!r}>""" self.annee!r} {self.competence!r}>"""
def to_dict(self): def to_dict(self, with_app_critiques=True):
"as a dict, recursif sur les AC" "as a dict, recursif (ou non) sur les AC"
return { return {
"libelle": self.libelle, "libelle": self.libelle,
"annee": self.annee, "annee": self.annee,
"ordre": self.ordre, "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): def to_dict_bul(self):
@ -306,9 +357,8 @@ class ApcNiveau(db.Model, XMLModel):
if annee not in {1, 2, 3}: if annee not in {1, 2, 3}:
raise ValueError("annee invalide pour un parcours BUT") raise ValueError("annee invalide pour un parcours BUT")
if referentiel_competence is None: if referentiel_competence is None:
raise ScoValueError( raise ScoNoReferentielCompetences()
"Pas de référentiel de compétences associé à la formation !"
)
annee_formation = f"BUT{annee}" annee_formation = f"BUT{annee}"
if parcour is None: if parcour is None:
return ApcNiveau.query.filter( return ApcNiveau.query.filter(
@ -436,6 +486,7 @@ class ApcParcours(db.Model, XMLModel):
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
ues = db.relationship("UniteEns", back_populates="parcour")
def __repr__(self) -> str: def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>" return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>"
@ -453,6 +504,14 @@ class ApcParcours(db.Model, XMLModel):
d["annees"] = {x.ordre: x.to_dict() for x in self.annees} d["annees"] = {x.ordre: x.to_dict() for x in self.annees}
return d 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): class ApcAnneeParcours(db.Model, XMLModel):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View File

@ -2,19 +2,17 @@
"""Décisions de jury (validations) des RCUE et années du BUT """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 typing import Union
from app import db import flask_sqlalchemy
from app import db
from app.models import CODE_STR_LEN from app.models import CODE_STR_LEN
from app.models.but_refcomp import ApcNiveau from app.models.but_refcomp import ApcNiveau
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.ues import UniteEns
from app.models.formations import Formation from app.models.formations import Formation
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc import sco_codes_parcours as sco_codes from app.scodoc import sco_codes_parcours as sco_codes
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
@ -63,13 +61,32 @@ class ApcValidationRCUE(db.Model):
self.ue1}/{self.ue2}:{self.code!r}>""" self.ue1}/{self.ue2}:{self.code!r}>"""
def __str__(self): def __str__(self):
return f"""décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {self.code}""" 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: def niveau(self) -> ApcNiveau:
"""Le niveau de compétence associé à cet RCUE.""" """Le niveau de compétence associé à cet RCUE."""
# Par convention, il est donné par la seconde UE # Par convention, il est donné par la seconde UE
return self.ue2.niveau_competence 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: def to_dict_bul(self) -> dict:
"Export dict pour bulletins: le code et le niveau de compétence" "Export dict pour bulletins: le code et le niveau de compétence"
niveau = self.niveau() niveau = self.niveau()
@ -84,28 +101,24 @@ class RegroupementCoherentUE:
"""Le regroupement cohérent d'UE, dans la terminologie du BUT, est le couple d'UEs """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*. 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__( def __init__(
self, self,
etud: Identite, etud: Identite,
formsemestre_1: FormSemestre, formsemestre_1: FormSemestre,
ue_1: UniteEns, dec_ue_1: "DecisionsProposeesUE",
formsemestre_2: FormSemestre, formsemestre_2: FormSemestre,
ue_2: UniteEns, dec_ue_2: "DecisionsProposeesUE",
inscription_etat: str, inscription_etat: str,
): ):
from app.comp import res_sem ue_1 = dec_ue_1.ue
from app.comp.res_but import ResultatsSemestreBUT ue_2 = dec_ue_2.ue
# Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)... # Ordonne les UE dans le sens croissant (S1,S2) ou (S3,S4)...
if formsemestre_1.semestre_id > formsemestre_2.semestre_id: if formsemestre_1.semestre_id > formsemestre_2.semestre_id:
(ue_1, formsemestre_1), (ue_2, formsemestre_2) = ( (ue_1, formsemestre_1), (ue_2, formsemestre_2) = (
( (ue_2, formsemestre_2),
ue_2,
formsemestre_2,
),
(ue_1, formsemestre_1), (ue_1, formsemestre_1),
) )
assert formsemestre_1.semestre_id % 2 == 1 assert formsemestre_1.semestre_id % 2 == 1
@ -125,21 +138,12 @@ class RegroupementCoherentUE:
self.moy_ue_1 = self.moy_ue_2 = "-" self.moy_ue_1 = self.moy_ue_2 = "-"
self.moy_ue_1_val = self.moy_ue_2_val = 0.0 self.moy_ue_1_val = self.moy_ue_2_val = 0.0
return return
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1) self.moy_ue_1 = dec_ue_1.moy_ue_with_cap
if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]: self.moy_ue_1_val = self.moy_ue_1 if self.moy_ue_1 is not None else 0.0
self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id] self.moy_ue_2 = dec_ue_2.moy_ue_with_cap
self.moy_ue_1_val = self.moy_ue_1 # toujours float, peut être NaN self.moy_ue_2_val = self.moy_ue_2 if self.moy_ue_2 is not None else 0.0
else:
self.moy_ue_1 = None # Calcul de la moyenne au RCUE (utilise les moy d'UE capitalisées)
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
if (self.moy_ue_1 is not None) and (self.moy_ue_2 is not None): 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.) # Moyenne RCUE (les pondérations par défaut sont 1.)
self.moy_rcue = ( self.moy_rcue = (
@ -149,7 +153,14 @@ class RegroupementCoherentUE:
self.moy_rcue = None self.moy_rcue = None
def __repr__(self) -> str: 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( def query_validations(
self, self,
@ -181,8 +192,9 @@ class RegroupementCoherentUE:
return self.query_validations().count() > 0 return self.query_validations().count() > 0
def est_compensable(self): def est_compensable(self):
"""Vrai si ce RCUE est validable par compensation """Vrai si ce RCUE est validable (uniquement) par compensation
c'est à dire que sa moyenne est > 10 avec une UE < 10 c'est à dire que sa moyenne est > 10 avec une UE < 10.
Note: si ADM, est_compensable est faux.
""" """
return ( return (
(self.moy_rcue is not None) (self.moy_rcue is not None)
@ -218,62 +230,62 @@ class RegroupementCoherentUE:
# unused # unused
def find_rcues( # def find_rcues(
formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str # formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str
) -> list[RegroupementCoherentUE]: # ) -> list[RegroupementCoherentUE]:
"""Les RCUE (niveau de compétence) à considérer pour cet étudiant dans # """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans
ce semestre pour cette UE. # ce semestre pour cette UE.
Cherche les UEs du même niveau de compétence auxquelles l'étudiant est inscrit. # 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. # En cas de redoublement, il peut y en avoir plusieurs, donc plusieurs RCUEs.
Résultat: la liste peut être vide. # Résultat: la liste peut être vide.
""" # """
if (ue.niveau_competence is None) or (ue.semestre_idx is None): # if (ue.niveau_competence is None) or (ue.semestre_idx is None):
return [] # return []
if ue.semestre_idx % 2: # S1, S3, S5 # if ue.semestre_idx % 2: # S1, S3, S5
other_semestre_idx = ue.semestre_idx + 1 # other_semestre_idx = ue.semestre_idx + 1
else: # else:
other_semestre_idx = ue.semestre_idx - 1 # other_semestre_idx = ue.semestre_idx - 1
cursor = db.session.execute( # cursor = db.session.execute(
text( # text(
"""SELECT # """SELECT
ue.id, formsemestre.id # ue.id, formsemestre.id
FROM # FROM
notes_ue ue, # notes_ue ue,
notes_formsemestre_inscription inscr, # notes_formsemestre_inscription inscr,
notes_formsemestre formsemestre # notes_formsemestre formsemestre
WHERE # WHERE
inscr.etudid = :etudid # inscr.etudid = :etudid
AND inscr.formsemestre_id = formsemestre.id # AND inscr.formsemestre_id = formsemestre.id
AND formsemestre.semestre_id = :other_semestre_idx # AND formsemestre.semestre_id = :other_semestre_idx
AND ue.formation_id = formsemestre.formation_id # AND ue.formation_id = formsemestre.formation_id
AND ue.niveau_competence_id = :ue_niveau_competence_id # AND ue.niveau_competence_id = :ue_niveau_competence_id
AND ue.semestre_idx = :other_semestre_idx # AND ue.semestre_idx = :other_semestre_idx
""" # """
), # ),
{ # {
"etudid": etud.id, # "etudid": etud.id,
"other_semestre_idx": other_semestre_idx, # "other_semestre_idx": other_semestre_idx,
"ue_niveau_competence_id": ue.niveau_competence_id, # "ue_niveau_competence_id": ue.niveau_competence_id,
}, # },
) # )
rcues = [] # rcues = []
for ue_id, formsemestre_id in cursor: # for ue_id, formsemestre_id in cursor:
other_ue = UniteEns.query.get(ue_id) # other_ue = UniteEns.query.get(ue_id)
other_formsemestre = FormSemestre.query.get(formsemestre_id) # other_formsemestre = FormSemestre.query.get(formsemestre_id)
rcues.append( # rcues.append(
RegroupementCoherentUE( # RegroupementCoherentUE(
etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat # etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat
) # )
) # )
# safety check: 1 seul niveau de comp. concerné: # # safety check: 1 seul niveau de comp. concerné:
assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1 # assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1
return rcues # return rcues
class ApcValidationAnnee(db.Model): class ApcValidationAnnee(db.Model):
@ -281,7 +293,7 @@ class ApcValidationAnnee(db.Model):
__tablename__ = "apc_validation_annee" __tablename__ = "apc_validation_annee"
# Assure unicité de la décision: # 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) id = db.Column(db.Integer, primary_key=True)
etudid = db.Column( etudid = db.Column(
db.Integer, db.Integer,
@ -303,7 +315,8 @@ class ApcValidationAnnee(db.Model):
formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees") formsemestre = db.relationship("FormSemestre", backref="apc_validations_annees")
def __repr__(self): 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): def __str__(self):
return f"""décision sur année BUT{self.ordre} {self.annee_scolaire} : {self.code}""" return f"""décision sur année BUT{self.ordre} {self.annee_scolaire} : {self.code}"""
@ -340,7 +353,8 @@ def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict:
titres_rcues.append(f"""pas de compétence: code {dec_rcue["code"]}""") titres_rcues.append(f"""pas de compétence: code {dec_rcue["code"]}""")
else: else:
titres_rcues.append( 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_rcue"] = ", ".join(titres_rcues)
decisions["descr_decisions_niveaux"] = ( decisions["descr_decisions_niveaux"] = (

View File

@ -6,13 +6,14 @@
from flask import flash from flask import flash
from app import db, log from app import db, log
from app.comp import bonus_spo 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.sco_codes_parcours import (
ABAN, ABAN,
ABL, ABL,
ADC, ADC,
ADJ, ADJ,
ADJR,
ADM, ADM,
AJ, AJ,
ATB, ATB,
@ -34,6 +35,7 @@ CODES_SCODOC_TO_APO = {
ABL: "ABL", ABL: "ABL",
ADC: "ADMC", ADC: "ADMC",
ADJ: "ADM", ADJ: "ADM",
ADJR: "ADM",
ADM: "ADM", ADM: "ADM",
AJ: "AJ", AJ: "AJ",
ATB: "AJAC", ATB: "AJAC",
@ -83,6 +85,8 @@ class ScoDocSiteConfig(db.Model):
"INSTITUTION_CITY": str, "INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str, "DEFAULT_PDF_FOOTER_TEMPLATE": str,
"enable_entreprises": bool, "enable_entreprises": bool,
"month_debut_annee_scolaire": int,
"month_debut_periode2": int,
} }
def __init__(self, name, value): def __init__(self, name, value):
@ -223,3 +227,73 @@ class ScoDocSiteConfig(db.Model):
db.session.commit() db.session.commit()
return True return True
return False 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,10 +2,10 @@
"""ScoDoc models : departements """ScoDoc models : departements
""" """
from typing import Any
from app import db from app import db
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models.preferences import ScoPreference
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
@ -39,7 +39,7 @@ class Departement(db.Model):
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__name__}(id={self.id}, acronym='{self.acronym}')>" 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 = { data = {
"id": self.id, "id": self.id,
"acronym": self.acronym, "acronym": self.acronym,
@ -47,6 +47,17 @@ class Departement(db.Model):
"visible": self.visible, "visible": self.visible,
"date_creation": self.date_creation, "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 return data
@classmethod @classmethod

View File

@ -58,6 +58,16 @@ class Identite(db.Model):
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic") billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
# #
admission = db.relationship("Admission", backref="identite", 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): def __repr__(self):
return ( return (
@ -73,6 +83,14 @@ class Identite(db.Model):
args = make_etud_args(etudid=etudid, code_nip=code_nip) args = make_etud_args(etudid=etudid, code_nip=code_nip)
return Identite.query.filter_by(**args).first_or_404() return Identite.query.filter_by(**args).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 @property
def civilite_str(self): def civilite_str(self):
"""returns 'M.' ou 'Mme' ou '' (pour le genre neutre, """returns 'M.' ou 'Mme' ou '' (pour le genre neutre,

View File

@ -13,6 +13,8 @@ from app.models.ues import UniteEns
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
class Evaluation(db.Model): class Evaluation(db.Model):
"""Evaluation (contrôle, examen, ...)""" """Evaluation (contrôle, examen, ...)"""
@ -51,7 +53,7 @@ class Evaluation(db.Model):
self.description[:16] if self.description else ''}">""" self.description[:16] if self.description else ''}">"""
def to_dict(self) -> dict: def to_dict(self) -> dict:
"Représentation dict, pour json" "Représentation dict (riche, compat ScoDoc 7)"
e = dict(self.__dict__) e = dict(self.__dict__)
e.pop("_sa_instance_state", None) e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators # ScoDoc7 output_formators
@ -71,6 +73,34 @@ class Evaluation(db.Model):
e["poids"] = self.get_ue_poids_dict() # { ue_id : poids } e["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
return evaluation_enrich_dict(e) 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): def from_dict(self, data):
"""Set evaluation attributes from given dict values.""" """Set evaluation attributes from given dict values."""
check_evaluation_args(data) check_evaluation_args(data)
@ -83,12 +113,24 @@ class Evaluation(db.Model):
if self.heure_debut and ( if self.heure_debut and (
not self.heure_fin or self.heure_fin == self.heure_debut not self.heure_fin or self.heure_fin == self.heure_debut
): ):
return f"""à {self.heure_debut.strftime("%H:%M")}""" return f"""à {self.heure_debut.strftime("%Hh%M")}"""
elif self.heure_debut and self.heure_fin: elif self.heure_debut and self.heure_fin:
return f"""de {self.heure_debut.strftime("%H:%M")} à {self.heure_fin.strftime("%H:%M")}""" return f"""de {self.heure_debut.strftime("%Hh%M")} à {self.heure_fin.strftime("%Hh%M")}"""
else: else:
return "" 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=()): def clone(self, not_copying=()):
"""Clone, not copying the given attrs """Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain commit Attention: la copie n'a pas d'id avant le prochain commit
@ -227,7 +269,7 @@ def evaluation_enrich_dict(e: dict):
heure_fin_dt = e["heure_fin"] or datetime.time(8, 00) heure_fin_dt = e["heure_fin"] or datetime.time(8, 00)
e["heure_debut"] = ndb.TimefromISO8601(e["heure_debut"]) e["heure_debut"] = ndb.TimefromISO8601(e["heure_debut"])
e["heure_fin"] = ndb.TimefromISO8601(e["heure_fin"]) 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"] heure_debut, heure_fin = e["heure_debut"], e["heure_fin"]
d = ndb.TimeDuration(heure_debut, heure_fin) d = ndb.TimeDuration(heure_debut, heure_fin)
if d is not None: if d is not None:

View File

@ -36,6 +36,7 @@ class Formation(db.Model):
titre = db.Column(db.Text(), nullable=False) titre = db.Column(db.Text(), nullable=False)
titre_officiel = db.Column(db.Text(), nullable=False) titre_officiel = db.Column(db.Text(), nullable=False)
version = db.Column(db.Integer, default=1, server_default="1") version = db.Column(db.Integer, default=1, server_default="1")
commentaire = db.Column(db.Text())
formation_code = db.Column( formation_code = db.Column(
db.String(SHORT_STR_LEN), db.String(SHORT_STR_LEN),
server_default=db.text("notes_newid_fcod()"), server_default=db.text("notes_newid_fcod()"),
@ -55,18 +56,21 @@ class Formation(db.Model):
modules = db.relationship("Module", lazy="dynamic", backref="formation") modules = db.relationship("Module", lazy="dynamic", backref="formation")
def __repr__(self): 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: def to_html(self) -> str:
"titre complet pour affichage" "titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}""" return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
def to_dict(self, with_refcomp_attrs=False): def to_dict(self, with_refcomp_attrs=False):
""" "as a dict. """As a dict.
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp. Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
""" """
e = dict(self.__dict__) e = dict(self.__dict__)
e.pop("_sa_instance_state", None) e.pop("_sa_instance_state", None)
if "referentiel_competence" in e:
e.pop("referentiel_competence")
e["departement"] = self.departement.to_dict() e["departement"] = self.departement.to_dict()
e["formation_id"] = self.id # ScoDoc7 backward compat e["formation_id"] = self.id # ScoDoc7 backward compat
if with_refcomp_attrs and self.referentiel_competence: if with_refcomp_attrs and self.referentiel_competence:
@ -201,12 +205,17 @@ class Formation(db.Model):
def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery: def query_ues_parcour(self, parcour: ApcParcours) -> flask_sqlalchemy.BaseQuery:
"""Les UEs d'un parcours de la formation. """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 Exemple: pour avoir les UE du semestre 3, faire
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)` `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.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.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcAnneeParcours.parcours_id == parcour.id, ApcAnneeParcours.parcours_id == parcour.id,
@ -233,6 +242,21 @@ class Formation(db.Model):
.filter(ApcAnneeParcours.parcours_id == parcour.id) .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): class Matiere(db.Model):
"""Matières: regroupe les modules d'une UE """Matières: regroupe les modules d'une UE

View File

@ -1,48 +1,47 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
# pylint génère trop de faux positifs avec les colonnes date:
# pylint: disable=no-member,not-an-iterable
"""ScoDoc models: formsemestre """ScoDoc models: formsemestre
""" """
import datetime import datetime
from functools import cached_property from functools import cached_property
from flask import flash, g
import flask_sqlalchemy import flask_sqlalchemy
from flask import flash, g
from sqlalchemy import and_, or_
from sqlalchemy.sql import text from sqlalchemy.sql import text
from app import db import app.scodoc.sco_utils as scu
from app import log from app import db, log
from app.models import APO_CODE_STR_LEN from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
from app.models.but_refcomp import ( from app.models.but_refcomp import (
ApcAnneeParcours, ApcAnneeParcours,
ApcNiveau, ApcNiveau,
ApcParcours, ApcParcours,
ApcParcoursNiveauCompetence, ApcParcoursNiveauCompetence,
ApcReferentielCompetences, ApcReferentielCompetences,
parcours_formsemestre,
) )
from app.models.groups import GroupDescr, Partition from app.models.config import ScoDocSiteConfig
from app.scodoc.sco_exceptions import ScoValueError
import app.scodoc.sco_utils as scu
from app.models.but_refcomp import parcours_formsemestre
from app.models.etudiants import Identite from app.models.etudiants import Identite
from app.models.formations import Formation from app.models.formations import Formation
from app.models.modules import Module from app.models.groups import GroupDescr, Partition
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription from app.models.moduleimpls import ModuleImpl, ModuleImplInscription
from app.models.modules import Module
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import sco_codes_parcours, sco_preferences
from app.scodoc import sco_codes_parcours from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.sco_permissions import Permission from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_vdi import ApoEtapeVDI
class FormSemestre(db.Model): class FormSemestre(db.Model):
@ -57,51 +56,58 @@ class FormSemestre(db.Model):
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True) dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id")) formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1") semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
titre = db.Column(db.Text()) titre = db.Column(db.Text(), nullable=False)
date_debut = db.Column(db.Date()) date_debut = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date()) date_fin = db.Column(db.Date(), nullable=False)
etat = db.Column( etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
db.Boolean(), nullable=False, default=True, server_default="true" "False si verrouillé"
) # False si verrouillé
modalite = db.Column( modalite = db.Column(
db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite") 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( gestion_compensation = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" 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( bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" 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( block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" 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( gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" 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( 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( resp_can_edit = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false" 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( resp_can_change_ens = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true" 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( ens_can_edit_eval = db.Column(
db.Boolean(), nullable=False, default=False, server_default="False" 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 ! 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()) elt_annee_apo = db.Column(db.Text())
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
# Relations: # Relations:
etapes = db.relationship( etapes = db.relationship(
@ -111,6 +117,7 @@ class FormSemestre(db.Model):
"ModuleImpl", "ModuleImpl",
backref="formsemestre", backref="formsemestre",
lazy="dynamic", lazy="dynamic",
cascade="all, delete-orphan",
) )
etuds = db.relationship( etuds = db.relationship(
"Identite", "Identite",
@ -148,7 +155,12 @@ class FormSemestre(db.Model):
self.modalite = FormationModalite.DEFAULT_MODALITE self.modalite = FormationModalite.DEFAULT_MODALITE
def __repr__(self): def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>" return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
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: def to_dict(self, convert_objects=False) -> dict:
"""dict (compatible ScoDoc7). """dict (compatible ScoDoc7).
@ -173,7 +185,7 @@ class FormSemestre(db.Model):
d["responsables"] = [u.id for u in self.responsables] d["responsables"] = [u.id for u in self.responsables]
d["titre_formation"] = self.titre_formation() d["titre_formation"] = self.titre_formation()
if convert_objects: 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["departement"] = self.departement.to_dict()
d["formation"] = self.formation.to_dict() d["formation"] = self.formation.to_dict()
d["etape_apo"] = self.etapes_apo_str() d["etape_apo"] = self.etapes_apo_str()
@ -200,9 +212,10 @@ class FormSemestre(db.Model):
d["etape_apo"] = self.etapes_apo_str() d["etape_apo"] = self.etapes_apo_str()
d["formsemestre_id"] = self.id d["formsemestre_id"] = self.id
d["formation"] = self.formation.to_dict() 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["responsables"] = [u.id for u in self.responsables]
d["titre_court"] = self.formation.acronyme d["titre_court"] = self.formation.acronyme
d["titre_formation"] = self.titre_formation()
d["titre_num"] = self.titre_num() d["titre_num"] = self.titre_num()
d["session_id"] = self.session_id() d["session_id"] = self.session_id()
return d return d
@ -222,7 +235,8 @@ class FormSemestre(db.Model):
d["mois_debut_ord"] = self.date_debut.month d["mois_debut_ord"] = self.date_debut.month
d["mois_fin_ord"] = self.date_fin.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 # 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: if self.date_debut.month >= 8 and self.date_debut.month <= 10:
d["periode"] = 1 # typiquement, début en septembre: S1, S3... d["periode"] = 1 # typiquement, début en septembre: S1, S3...
else: else:
@ -241,17 +255,36 @@ class FormSemestre(db.Model):
d["etapes_apo_str"] = self.etapes_apo_str() d["etapes_apo_str"] = self.etapes_apo_str()
return d return d
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: def query_ues(self, with_sport=False) -> flask_sqlalchemy.BaseQuery:
"""UE des modules de ce semestre, triées par numéro. """UE des modules de ce semestre, triées par numéro.
- Formations classiques: les UEs auxquelles appartiennent - Formations classiques: les UEs auxquelles appartiennent
les modules mis en place dans ce semestre. les modules mis en place dans ce semestre.
- Formations APC / BUT: les UEs de la formation qui ont - Formations APC / BUT: les UEs de la formation qui
le même numéro de semestre que ce formsemestre. - 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_parcours().APC_SAE:
sem_ues = UniteEns.query.filter_by( sem_ues = UniteEns.query.filter_by(
formation=self.formation, semestre_idx=self.semestre_id 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: else:
sem_ues = db.session.query(UniteEns).filter( sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id, ModuleImpl.formsemestre_id == self.id,
@ -263,8 +296,11 @@ class FormSemestre(db.Model):
return sem_ues.order_by(UniteEns.numero) return sem_ues.order_by(UniteEns.numero)
def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery: 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. 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 Si voulez les UE d'un parcours, il est plus efficace de passer par
`formation.query_ues_parcour(parcour)`. `formation.query_ues_parcour(parcour)`.
@ -275,7 +311,13 @@ class FormSemestre(db.Model):
UniteEns.niveau_competence_id == ApcNiveau.id, UniteEns.niveau_competence_id == ApcNiveau.id,
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id, ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id, ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
or_(
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id, ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
and_(
FormSemestreInscription.parcour_id.is_(None),
UniteEns.parcour_id.is_(None),
),
),
) )
@cached_property @cached_property
@ -288,7 +330,7 @@ class FormSemestre(db.Model):
if self.formation.is_apc(): if self.formation.is_apc():
modimpls.sort( modimpls.sort(
key=lambda m: ( 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.numero or 0,
m.module.code or 0, m.module.code or 0,
) )
@ -327,7 +369,7 @@ class FormSemestre(db.Model):
return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor] return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor]
def can_be_edited_by(self, user): 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 user.has_permission(Permission.ScoImplement): # pas chef
if not self.resp_can_edit or user.id not in [ if not self.resp_can_edit or user.id not in [
resp.id for resp in self.responsables resp.id for resp in self.responsables
@ -341,7 +383,7 @@ class FormSemestre(db.Model):
(les dates de début et fin sont incluses) (les dates de début et fin sont incluses)
""" """
today = datetime.date.today() 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: def contient_periode(self, date_debut, date_fin) -> bool:
"""Vrai si l'intervalle [date_debut, date_fin] est """Vrai si l'intervalle [date_debut, date_fin] est
@ -354,29 +396,99 @@ class FormSemestre(db.Model):
"""Test si sem est entièrement sur la même année scolaire. """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 (ce n'est pas obligatoire mais si ce n'est pas le
cas les exports Apogée risquent de mal fonctionner) 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: 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 !") log(f"Warning: semestre {self.id} begins after ending !")
annee_debut = self.date_debut.year annee_debut = self.date_debut.year
if self.date_debut.month < 8: # août month_debut_annee = ScoDocSiteConfig.get_month_debut_annee_scolaire()
# considere que debut sur l'anne scolaire precedente 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_debut -= 1
annee_fin = self.date_fin.year annee_fin = self.date_fin.year
if self.date_fin.month < 9: if self.date_fin.month < (month_debut_annee + 1):
# 9 (sept) pour autoriser un début en sept et une fin en aout # 9 (sept) pour autoriser un début en sept et une fin en août
annee_fin -= 1 annee_fin -= 1
return annee_debut == annee_fin return annee_debut == annee_fin
def est_decale(self): def est_decale(self):
"""Vrai si semestre "décalé" """Vrai si semestre "décalé"
c'est à dire semestres impairs commençant entre janvier et juin c'est à dire semestres impairs commençant (par défaut)
et les pairs entre juillet et decembre entre janvier et juin et les pairs entre juillet et décembre.
""" """
if self.semestre_id <= 0: if self.semestre_id <= 0:
return False # formations sans semestres return False # formations sans semestres
return (self.semestre_id % 2 and self.date_debut.month <= 6) or ( return (
not self.semestre_id % 2 and self.date_debut.month > 6 # 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
)
)
@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]: def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
@ -429,7 +541,7 @@ class FormSemestre(db.Model):
def annee_scolaire(self) -> int: def annee_scolaire(self) -> int:
"""L'année de début de l'année scolaire. """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) return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
def annee_scolaire_str(self): def annee_scolaire_str(self):
@ -479,7 +591,9 @@ class FormSemestre(db.Model):
) )
def titre_annee(self) -> str: 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 = ( titre_annee = (
f"{self.titre_num()} {self.modalite or ''} {self.date_debut.year}" f"{self.titre_num()} {self.modalite or ''} {self.date_debut.year}"
) )
@ -585,14 +699,43 @@ class FormSemestre(db.Model):
db.session.add(partition) db.session.add(partition)
db.session.flush() # pour avoir un id db.session.flush() # pour avoir un id
flash("Partition Parcours créée.") 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: if parcour.code:
group = GroupDescr.query.filter_by( group = GroupDescr.query.filter_by(
partition_id=partition.id, group_name=parcour.code partition_id=partition.id, group_name=parcour.code
).first() ).first()
if not group: if not group:
partition.groups.append(GroupDescr(group_name=parcour.code)) 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() db.session.commit()
def update_inscriptions_parcours_from_groups(self) -> None: def update_inscriptions_parcours_from_groups(self) -> None:
@ -653,7 +796,7 @@ class FormSemestre(db.Model):
def etud_validations_description_html(self, etudid: int) -> str: def etud_validations_description_html(self, etudid: int) -> str:
"""Description textuelle des validations de jury de cet étudiant dans ce semestre""" """Description textuelle des validations de jury de cet étudiant dans ce semestre"""
from app.models.but_validations import ApcValidationRCUE, ApcValidationAnnee from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
vals_sem = ScolarFormSemestreValidation.query.filter_by( vals_sem = ScolarFormSemestreValidation.query.filter_by(
etudid=etudid, formsemestre_id=self.id, ue_id=None etudid=etudid, formsemestre_id=self.id, ue_id=None
@ -914,8 +1057,8 @@ class NotesSemSet(db.Model):
title = db.Column(db.Text) title = db.Column(db.Text)
annee_scolaire = db.Column(db.Integer, nullable=True, default=None) 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=False, default=0)
sem_id = db.Column(db.Integer, nullable=True, default=None) "période: 0 (année), 1 (Simpair), 2 (Spair)"
# Association: many to many # Association: many to many

View File

@ -1,7 +1,7 @@
# -*- coding: UTF-8 -* # -*- coding: UTF-8 -*
############################################################################## ##############################################################################
# ScoDoc # ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
@ -87,6 +87,7 @@ class Partition(db.Model):
def to_dict(self, with_groups=False) -> dict: def to_dict(self, with_groups=False) -> dict:
"""as a dict, with or without groups""" """as a dict, with or without groups"""
d = dict(self.__dict__) d = dict(self.__dict__)
d["partition_id"] = self.id
d.pop("_sa_instance_state", None) d.pop("_sa_instance_state", None)
d.pop("formsemestre", None) d.pop("formsemestre", None)

View File

@ -20,14 +20,12 @@ class ModuleImpl(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
moduleimpl_id = db.synonym("id") moduleimpl_id = db.synonym("id")
module_id = db.Column( module_id = db.Column(db.Integer, db.ForeignKey("notes_modules.id"), nullable=False)
db.Integer,
db.ForeignKey("notes_modules.id"),
)
formsemestre_id = db.Column( formsemestre_id = db.Column(
db.Integer, db.Integer,
db.ForeignKey("notes_formsemestre.id"), db.ForeignKey("notes_formsemestre.id"),
index=True, index=True,
nullable=False,
) )
responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id")) responsable_id = db.Column("responsable_id", db.Integer, db.ForeignKey("user.id"))
# formule de calcul moyenne: # formule de calcul moyenne:
@ -62,7 +60,7 @@ class ModuleImpl(db.Model):
"""Invalide poids cachés""" """Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id) 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 """true si les poids des évaluations du module permettent de satisfaire
les coefficients du PN. les coefficients du PN.
""" """
@ -76,7 +74,7 @@ class ModuleImpl(db.Model):
return moy_mod.moduleimpl_is_conforme( return moy_mod.moduleimpl_is_conforme(
self, self,
self.get_evaluations_poids(), 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): def to_dict(self, convert_objects=False, with_module=True):
@ -101,6 +99,22 @@ class ModuleImpl(db.Model):
d.pop("module", None) d.pop("module", None)
return d return d
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
# Enseignants (chargés de TD ou TP) d'un moduleimpl # Enseignants (chargés de TD ou TP) d'un moduleimpl
notes_modules_enseignants = db.Table( notes_modules_enseignants = db.Table(

View File

@ -3,7 +3,7 @@
from app import db from app import db
from app.models import APO_CODE_STR_LEN 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 import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -37,7 +37,9 @@ class Module(db.Model):
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum) # Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)
module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0") module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0")
# Relations: # 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) ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True)
tags = db.relationship( tags = db.relationship(
"NotesTag", "NotesTag",
@ -66,7 +68,39 @@ class Module(db.Model):
super(Module, self).__init__(**kwargs) super(Module, self).__init__(**kwargs)
def __repr__(self): 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: def to_dict(self, convert_objects=False, with_matiere=False, with_ue=False) -> dict:
"""If convert_objects, convert all attributes to native types """If convert_objects, convert all attributes to native types
@ -188,25 +222,31 @@ class Module(db.Model):
# à redéfinir les relationships... # à redéfinir les relationships...
return sorted(self.ue_coefs, key=lambda x: x.ue.numero) 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). """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. sauf UE bonus sport.
Result: List of tuples [ (ue, coef) ] Result: List of tuples [ (ue, coef) ]
""" """
if not self.is_apc(): if not self.is_apc():
return [] return []
if include_zeros: if include_zeros and ues is None:
# Toutes les UE du même semestre: # Toutes les UE du même semestre:
ues_semestre = ( ues = (
self.formation.ues.filter_by(semestre_idx=self.ue.semestre_idx) self.formation.ues.filter_by(semestre_idx=self.ue.semestre_idx)
.filter(UniteEns.type != UE_SPORT) .filter(UniteEns.type != UE_SPORT)
.order_by(UniteEns.numero) .order_by(UniteEns.numero)
.all() .all()
) )
if not ues:
return []
if ues:
coefs_dict = self.get_ue_coef_dict() coefs_dict = self.get_ue_coef_dict()
coefs_list = [] coefs_list = []
for ue in ues_semestre: for ue in ues:
coefs_list.append((ue, coefs_dict.get(ue.id, 0.0))) coefs_list.append((ue, coefs_dict.get(ue.id, 0.0)))
return coefs_list return coefs_list
# Liste seulement les coefs définis: # Liste seulement les coefs définis:
@ -218,6 +258,19 @@ class Module(db.Model):
return {x.strip() for x in self.code_apogee.split(",") if x} return {x.strip() for x in self.code_apogee.split(",") if x}
return set() 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): class ModuleUECoef(db.Model):
"""Coefficients des modules vers les UE (APC, BUT) """Coefficients des modules vers les UE (APC, BUT)

View File

@ -1,9 +1,14 @@
"""ScoDoc 9 models : Unités d'Enseignement (UE) """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 APO_CODE_STR_LEN
from app.models import SHORT_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 from app.scodoc import sco_utils as scu
@ -49,9 +54,19 @@ class UniteEns(db.Model):
niveau_competence_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id")) niveau_competence_id = db.Column(db.Integer, db.ForeignKey("apc_niveau.id"))
niveau_competence = db.relationship("ApcNiveau", back_populates="ues") 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"), index=True)
parcour = db.relationship("ApcParcours", back_populates="ues")
# relations # relations
matieres = db.relationship("Matiere", lazy="dynamic", backref="ue") matieres = db.relationship("Matiere", lazy="dynamic", backref="ue")
modules = db.relationship("Module", 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): def __repr__(self):
return f"""<{self.__class__.__name__}(id={self.id}, formation_id={ return f"""<{self.__class__.__name__}(id={self.id}, formation_id={
@ -59,6 +74,28 @@ class UniteEns(db.Model):
self.semestre_idx} { self.semestre_idx} {
'EXTERNE' if self.is_external else ''})>""" '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): def to_dict(self, convert_objects=False, with_module_ue_coefs=True):
"""as a dict, with the same conversions as in ScoDoc7 """as a dict, with the same conversions as in ScoDoc7
(except ECTS: keep None) (except ECTS: keep None)
@ -74,6 +111,7 @@ class UniteEns(db.Model):
e["ects"] = e["ects"] e["ects"] = e["ects"]
e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0
e["code_apogee"] = e["code_apogee"] or "" # pas de None 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 with_module_ue_coefs:
if convert_objects: if convert_objects:
e["module_ue_coefs"] = [ e["module_ue_coefs"] = [
@ -83,6 +121,12 @@ class UniteEns(db.Model):
e.pop("module_ue_coefs", None) e.pop("module_ue_coefs", None)
return e 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): def is_locked(self):
"""True if UE should not be modified """True if UE should not be modified
(contains modules used in a locked formsemestre) (contains modules used in a locked formsemestre)
@ -135,3 +179,137 @@ class UniteEns(db.Model):
if self.code_apogee: if self.code_apogee:
return {x.strip() for x in self.code_apogee.split(",") if x} return {x.strip() for x in self.code_apogee.split(",") if x}
return set() 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 db
from app import log
from app.models import SHORT_STR_LEN from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN from app.models import CODE_STR_LEN
from app.models.events import Scolog from app.models.events import Scolog
@ -58,7 +59,7 @@ class ScolarFormSemestreValidation(db.Model):
) )
def __repr__(self): 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): def __str__(self):
if self.ue_id: if self.ue_id:
@ -93,6 +94,10 @@ class ScolarAutorisationInscription(db.Model):
db.ForeignKey("notes_formsemestre.id"), 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: def to_dict(self) -> dict:
"as a dict" "as a dict"
d = dict(self.__dict__) d = dict(self.__dict__)
@ -116,7 +121,10 @@ class ScolarAutorisationInscription(db.Model):
semestre_id=semestre_id, semestre_id=semestre_id,
) )
db.session.add(autorisation) 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 @classmethod
def delete_autorisation_etud( def delete_autorisation_etud(
@ -130,10 +138,11 @@ class ScolarAutorisationInscription(db.Model):
) )
for autorisation in autorisations: for autorisation in autorisations:
db.session.delete(autorisation) db.session.delete(autorisation)
log(f"ScolarAutorisationInscription: deleting {autorisation}")
Scolog.logdb( Scolog.logdb(
"autorise_etud", "autorise_etud",
etudid=etudid, etudid=etudid,
msg=f"annule passage vers S{autorisation.semestre_id}", msg=f"Passage vers S{autorisation.semestre_id}: effacé",
) )
db.session.flush() db.session.flush()

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -459,8 +459,7 @@ class JuryPE(object):
etud = self.get_cache_etudInfo_d_un_etudiant(etudid) etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
(_, parcours) = sco_report.get_codeparcoursetud(etud) (_, parcours) = sco_report.get_codeparcoursetud(etud)
if ( if (
len(set(sco_codes_parcours.CODES_SEM_REO.keys()) & set(parcours.values())) len(sco_codes_parcours.CODES_SEM_REO & set(parcours.values())) > 0
> 0
): # Eliminé car NAR apparait dans le parcours ): # Eliminé car NAR apparait dans le parcours
reponse = True reponse = True
if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2: if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2:
@ -563,9 +562,8 @@ class JuryPE(object):
dec = nt.get_etud_decision_sem( dec = nt.get_etud_decision_sem(
etudid etudid
) # quelle est la décision du jury ? ) # quelle est la décision du jury ?
if dec and dec["code"] in list( if dec and (dec["code"] in sco_codes_parcours.CODES_SEM_VALIDES):
sco_codes_parcours.CODES_SEM_VALIDES.keys() # isinstance( sesMoyennes[i+1], float) and
): # isinstance( sesMoyennes[i+1], float) and
# mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide" # mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide"
leFid = sem["formsemestre_id"] leFid = sem["formsemestre_id"]
else: else:

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

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

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -274,7 +274,7 @@ def sco_header(
H.append("""<div id="gtrcontent">""") H.append("""<div id="gtrcontent">""")
# En attendant le replacement complet de cette fonction, # En attendant le replacement complet de cette fonction,
# inclusion ici des messages flask # inclusion ici des messages flask
H.append(render_template("flashed_messages.html")) H.append(render_template("flashed_messages.j2"))
# #
# Barre menu semestre: # Barre menu semestre:
H.append(formsemestre_page_title(formsemestre_id)) H.append(formsemestre_page_title(formsemestre_id))

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -101,7 +101,6 @@ def sidebar(etudid: int = None):
etudid = request.form.get("etudid", None) etudid = request.form.get("etudid", None)
if etudid is not None: if etudid is not None:
etudi = int(etudid)
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
params.update(etud) params.update(etud)
params["fiche_url"] = url_for( params["fiche_url"] = url_for(
@ -167,6 +166,6 @@ def sidebar(etudid: int = None):
def sidebar_dept(): def sidebar_dept():
"""Partie supérieure de la marge de gauche""" """Partie supérieure de la marge de gauche"""
return render_template( return render_template(
"sidebar_dept.html", "sidebar_dept.j2",
prefs=sco_preferences.SemPreferences(), prefs=sco_preferences.SemPreferences(),
) )

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # 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) 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. """HTML snippet to render a simple drop down menu.
items is a list of dicts: items is a list of dicts:
{ 'title' : { 'title' :

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -42,6 +42,8 @@ from app.scodoc import sco_cache
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.models import Assiduite
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
# --- Misc tools.... ------------------ # --- Misc tools.... ------------------
@ -1052,6 +1054,36 @@ def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso):
return r return r
def get_assiduites_count_in_interval(etudid, date_debut_iso, date_fin_iso):
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs, nb abs justifiées)
Utilise un cache.
"""
key = str(etudid) + "_" + date_debut_iso + "_" + date_fin_iso
r = sco_cache.AbsSemEtudCache.get(key)
if not r:
date_debut: datetime.datetime = scu.is_iso_formated(date_debut_iso, True)
date_fin: datetime.datetime = scu.is_iso_formated(date_debut_iso, True)
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
assiduites = scass.filter_assiduites_by_date(assiduites, date_debut, sup=True)
assiduites = scass.filter_assiduites_by_date(assiduites, date_fin, sup=False)
nb_abs = scass.get_count(assiduites)["demi"]
nb_abs_just = count_abs_just(
etudid=etudid,
debut=date_debut_iso,
fin=date_fin_iso,
)
r = (nb_abs, nb_abs_just)
ans = sco_cache.AbsSemEtudCache.set(key, r)
if not ans:
log("warning: get_abs_count failed to cache")
return r
def invalidate_abs_count(etudid, sem): def invalidate_abs_count(etudid, sem):
"""Invalidate (clear) cached counts""" """Invalidate (clear) cached counts"""
date_debut = sem["date_debut_iso"] date_debut = sem["date_debut_iso"]

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -685,7 +685,7 @@ def EtatAbsences():
</td></tr></table> </td></tr></table>
</form>""" </form>"""
% (scu.AnneeScolaire(), datetime.datetime.now().strftime("%d/%m/%Y")), % (scu.annee_scolaire(), datetime.datetime.now().strftime("%d/%m/%Y")),
html_sco_header.sco_footer(), html_sco_header.sco_footer(),
] ]
return "\n".join(H) return "\n".join(H)
@ -719,15 +719,27 @@ def formChoixSemestreGroupe(all=False):
return "\n".join(H) return "\n".join(H)
def _convert_sco_year(year) -> int:
try:
year = int(year)
if year > 1900 and year < 2999:
return year
except:
raise ScoValueError("année scolaire invalide")
def CalAbs(etudid, sco_year=None): def CalAbs(etudid, sco_year=None):
"""Calendrier des absences d'un etudiant""" """Calendrier des absences d'un etudiant"""
# crude portage from 1999 DTML # crude portage from 1999 DTML
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
etudid = etud["etudid"] etudid = etud["etudid"]
anneescolaire = int(scu.AnneeScolaire(sco_year)) if sco_year:
datedebut = str(anneescolaire) + "-08-01" annee_scolaire = _convert_sco_year(sco_year)
datefin = str(anneescolaire + 1) + "-07-31" else:
annee_courante = scu.AnneeScolaire() annee_scolaire = scu.annee_scolaire()
datedebut = str(annee_scolaire) + "-08-01"
datefin = str(annee_scolaire + 1) + "-07-31"
annee_courante = scu.annee_scolaire()
nbabs = sco_abs.count_abs(etudid=etudid, debut=datedebut, fin=datefin) nbabs = sco_abs.count_abs(etudid=etudid, debut=datedebut, fin=datefin)
nbabsjust = sco_abs.count_abs_just(etudid=etudid, debut=datedebut, fin=datefin) nbabsjust = sco_abs.count_abs_just(etudid=etudid, debut=datedebut, fin=datefin)
events = [] events = []
@ -746,7 +758,7 @@ def CalAbs(etudid, sco_year=None):
events.append( events.append(
(str(a["jour"]), "X", "#8EA2C6", "", a["matin"], a["description"]) (str(a["jour"]), "X", "#8EA2C6", "", a["matin"], a["description"])
) )
CalHTML = sco_abs.YearTable(anneescolaire, events=events, halfday=1) CalHTML = sco_abs.YearTable(annee_scolaire, events=events, halfday=1)
# #
H = [ H = [
@ -777,12 +789,12 @@ def CalAbs(etudid, sco_year=None):
CalHTML, CalHTML,
"""<form method="GET" action="CalAbs" name="f">""", """<form method="GET" action="CalAbs" name="f">""",
"""<input type="hidden" name="etudid" value="%s"/>""" % etudid, """<input type="hidden" name="etudid" value="%s"/>""" % etudid,
"""Année scolaire %s-%s""" % (anneescolaire, anneescolaire + 1), """Année scolaire %s-%s""" % (annee_scolaire, annee_scolaire + 1),
"""&nbsp;&nbsp;Changer année: <select name="sco_year" onchange="document.f.submit()">""", """&nbsp;&nbsp;Changer année: <select name="sco_year" onchange="document.f.submit()">""",
] ]
for y in range(annee_courante, min(annee_courante - 6, anneescolaire - 6), -1): for y in range(annee_courante, min(annee_courante - 6, annee_scolaire - 6), -1):
H.append("""<option value="%s" """ % y) H.append("""<option value="%s" """ % y)
if y == anneescolaire: if y == annee_scolaire:
H.append("selected") H.append("selected")
H.append(""">%s</option>""" % y) H.append(""">%s</option>""" % y)
H.append("""</select></form>""") H.append("""</select></form>""")
@ -811,7 +823,11 @@ def ListeAbsEtud(
""" """
# si absjust_only, table absjust seule (export xls ou pdf) # si absjust_only, table absjust seule (export xls ou pdf)
absjust_only = scu.to_bool(absjust_only) absjust_only = scu.to_bool(absjust_only)
datedebut = "%s-08-01" % scu.AnneeScolaire(sco_year=sco_year) if sco_year:
annee_scolaire = _convert_sco_year(sco_year)
else:
annee_scolaire = scu.annee_scolaire()
datedebut = f"{annee_scolaire}-{scu.MONTH_DEBUT_ANNEE_SCOLAIRE+1}-01"
etudid = etudid or False etudid = etudid or False
etuds = sco_etud.get_etud_info(etudid=etudid, code_nip=code_nip, filled=True) etuds = sco_etud.get_etud_info(etudid=etudid, code_nip=code_nip, filled=True)
if not etuds: if not etuds:

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -43,6 +43,7 @@ Pour chaque étudiant commun:
comparer les résultats comparer les résultats
""" """
from flask import g, url_for
from app import log from app import log
from app.scodoc import sco_apogee_csv from app.scodoc import sco_apogee_csv
@ -72,11 +73,11 @@ def apo_compare_csv_form():
""" """
<div class="apo_compare_csv_form_but"> <div class="apo_compare_csv_form_but">
Fichier Apogée A: Fichier Apogée A:
<input type="file" size="30" name="A_file"/> <input type="file" size="30" name="file_a"/>
</div> </div>
<div class="apo_compare_csv_form_but"> <div class="apo_compare_csv_form_but">
Fichier Apogée B: Fichier Apogée B:
<input type="file" size="30" name="B_file"/> <input type="file" size="30" name="file_b"/>
</div> </div>
<input type="checkbox" name="autodetect" checked/>autodétecter encodage</input> <input type="checkbox" name="autodetect" checked/>autodétecter encodage</input>
<div class="apo_compare_csv_form_submit"> <div class="apo_compare_csv_form_submit">
@ -88,17 +89,36 @@ def apo_compare_csv_form():
return "\n".join(H) return "\n".join(H)
def apo_compare_csv(A_file, B_file, autodetect=True): def apo_compare_csv(file_a, file_b, autodetect=True):
"""Page comparing 2 Apogee CSV files""" """Page comparing 2 Apogee CSV files"""
A = _load_apo_data(A_file, autodetect=autodetect) try:
B = _load_apo_data(B_file, autodetect=autodetect) apo_data_a = _load_apo_data(file_a, autodetect=autodetect)
apo_data_b = _load_apo_data(file_b, autodetect=autodetect)
except (UnicodeDecodeError, UnicodeEncodeError) as exc:
dest_url = url_for("notes.semset_page", scodoc_dept=g.scodoc_dept)
if autodetect:
raise ScoValueError(
"""
Erreur: l'encodage de l'un des fichiers est mal détecté.
Essayez sans auto-détection, ou vérifiez le codage et le contenu
des fichiers.
""",
dest_url=dest_url,
) from exc
else:
raise ScoValueError(
f"""
Erreur: l'encodage de l'un des fichiers est incorrect.
Vérifiez qu'il est bien en {sco_apogee_csv.APO_INPUT_ENCODING}
""",
dest_url=dest_url,
) from exc
H = [ H = [
html_sco_header.sco_header(page_title="Comparaison de fichiers Apogée"), html_sco_header.sco_header(page_title="Comparaison de fichiers Apogée"),
"<h2>Comparaison de fichiers Apogée</h2>", "<h2>Comparaison de fichiers Apogée</h2>",
_help_txt, _help_txt,
'<div class="apo_compare_csv">', '<div class="apo_compare_csv">',
_apo_compare_csv(A, B), _apo_compare_csv(apo_data_a, apo_data_b),
"</div>", "</div>",
"""<p><a href="apo_compare_csv_form" class="stdlink">Autre comparaison</a></p>""", """<p><a href="apo_compare_csv_form" class="stdlink">Autre comparaison</a></p>""",
html_sco_header.sco_footer(), html_sco_header.sco_footer(),
@ -112,9 +132,9 @@ def _load_apo_data(csvfile, autodetect=True):
if autodetect: if autodetect:
data_b, message = sco_apogee_csv.fix_data_encoding(data_b) data_b, message = sco_apogee_csv.fix_data_encoding(data_b)
if message: if message:
log("apo_compare_csv: %s" % message) log(f"apo_compare_csv: {message}")
if not data_b: if not data_b:
raise ScoValueError("apo_compare_csv: no data") raise ScoValueError("fichier vide ? (apo_compare_csv: no data)")
data = data_b.decode(sco_apogee_csv.APO_INPUT_ENCODING) data = data_b.decode(sco_apogee_csv.APO_INPUT_ENCODING)
apo_data = sco_apogee_csv.ApoData(data, orig_filename=csvfile.filename) apo_data = sco_apogee_csv.ApoData(data, orig_filename=csvfile.filename)
return apo_data return apo_data

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -155,28 +155,25 @@ def fix_data_encoding(
text: bytes, text: bytes,
default_source_encoding=APO_INPUT_ENCODING, default_source_encoding=APO_INPUT_ENCODING,
dest_encoding=APO_INPUT_ENCODING, dest_encoding=APO_INPUT_ENCODING,
) -> bytes: ) -> tuple[bytes, str]:
"""Try to ensure that text is using dest_encoding """Try to ensure that text is using dest_encoding
returns converted text, and a message describing the conversion. returns converted text, and a message describing the conversion.
Raises UnicodeEncodeError en cas de problème, en général liée à
une auto-détection errornée.
""" """
message = "" message = ""
detected_encoding = guess_data_encoding(text) detected_encoding = guess_data_encoding(text)
if not detected_encoding: if not detected_encoding:
if default_source_encoding != dest_encoding: if default_source_encoding != dest_encoding:
message = "converting from %s to %s" % ( message = f"converting from {default_source_encoding} to {dest_encoding}"
default_source_encoding, text = text.decode(default_source_encoding).encode(dest_encoding)
dest_encoding,
)
text = text.decode(default_source_encoding).encode(
dest_encoding
) # XXX #py3 #sco8 à tester
else: else:
if detected_encoding != dest_encoding: if detected_encoding != dest_encoding:
message = "converting from detected %s to %s" % ( message = (
detected_encoding, f"converting from detected {default_source_encoding} to {dest_encoding}"
dest_encoding,
) )
text = text.decode(detected_encoding).encode(dest_encoding) # XXX text = text.decode(detected_encoding).encode(dest_encoding)
return text, message return text, message
@ -511,7 +508,7 @@ class ApoEtud(dict):
# print 'comp_elt_annuel cur_sem=%s autre_sem=%s' % (cur_sem['formsemestre_id'], autre_sem['formsemestre_id']) # print 'comp_elt_annuel cur_sem=%s autre_sem=%s' % (cur_sem['formsemestre_id'], autre_sem['formsemestre_id'])
if not cur_sem: if not cur_sem:
# l'étudiant n'a pas de semestre courant ?! # l'étudiant n'a pas de semestre courant ?!
log("comp_elt_annuel: etudid %s has no cur_sem" % etudid) log(f"comp_elt_annuel: etudid {etudid} has no cur_sem")
return VOID_APO_RES return VOID_APO_RES
cur_formsemestre = FormSemestre.query.get_or_404(cur_sem["formsemestre_id"]) cur_formsemestre = FormSemestre.query.get_or_404(cur_sem["formsemestre_id"])
cur_nt: NotesTableCompat = res_sem.load_formsemestre_results(cur_formsemestre) cur_nt: NotesTableCompat = res_sem.load_formsemestre_results(cur_formsemestre)
@ -586,7 +583,11 @@ class ApoEtud(dict):
(sem["semestre_id"] == apo_data.cur_semestre_id) (sem["semestre_id"] == apo_data.cur_semestre_id)
and (apo_data.etape in sem["etapes"]) and (apo_data.etape in sem["etapes"])
and ( and (
sco_formsemestre.sem_in_annee_scolaire(sem, apo_data.annee_scolaire) sco_formsemestre.sem_in_semestre_scolaire(
sem,
apo_data.annee_scolaire,
0, # annee complete
)
) )
) )
] ]

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -68,7 +68,7 @@ from app import log
from app.but import jury_but_pv from app.but import jury_but_pv
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import Departement, FormSemestre from app.models import FormSemestre
from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_exceptions import ( from app.scodoc.sco_exceptions import (
AccessDenied, AccessDenied,
@ -89,6 +89,11 @@ class BaseArchiver(object):
self.archive_type = archive_type self.archive_type = archive_type
self.initialized = False self.initialized = False
self.root = None self.root = None
self.dept_id = None
def set_dept_id(self, dept_id: int):
"set dept"
self.dept_id = dept_id
def initialize(self): def initialize(self):
if self.initialized: if self.initialized:
@ -105,20 +110,21 @@ class BaseArchiver(object):
try: try:
scu.GSL.acquire() scu.GSL.acquire()
if not os.path.isdir(path): if not os.path.isdir(path):
log("creating directory %s" % path) log(f"creating directory {path}")
os.mkdir(path) os.mkdir(path)
finally: finally:
scu.GSL.release() scu.GSL.release()
self.initialized = True self.initialized = True
if self.dept_id is None:
self.dept_id = getattr(g, "scodoc_dept_id")
def get_obj_dir(self, oid): def get_obj_dir(self, oid: int):
""" """
:return: path to directory of archives for this object (eg formsemestre_id or etudid). :return: path to directory of archives for this object (eg formsemestre_id or etudid).
If directory does not yet exist, create it. If directory does not yet exist, create it.
""" """
self.initialize() self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first() dept_dir = os.path.join(self.root, str(self.dept_id))
dept_dir = os.path.join(self.root, str(dept.id))
try: try:
scu.GSL.acquire() scu.GSL.acquire()
if not os.path.isdir(dept_dir): if not os.path.isdir(dept_dir):
@ -137,12 +143,11 @@ class BaseArchiver(object):
:return: list of archive oids :return: list of archive oids
""" """
self.initialize() self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first() base = os.path.join(self.root, str(self.dept_id)) + os.path.sep
base = os.path.join(self.root, str(dept.id)) + os.path.sep
dirs = glob.glob(base + "*") dirs = glob.glob(base + "*")
return [os.path.split(x)[1] for x in dirs] return [os.path.split(x)[1] for x in dirs]
def list_obj_archives(self, oid): def list_obj_archives(self, oid: int):
"""Returns """Returns
:return: list of archive identifiers for this object (paths to non empty dirs) :return: list of archive identifiers for this object (paths to non empty dirs)
""" """
@ -157,7 +162,7 @@ class BaseArchiver(object):
dirs.sort() dirs.sort()
return dirs return dirs
def delete_archive(self, archive_id): def delete_archive(self, archive_id: str):
"""Delete (forever) this archive""" """Delete (forever) this archive"""
self.initialize() self.initialize()
try: try:
@ -166,7 +171,7 @@ class BaseArchiver(object):
finally: finally:
scu.GSL.release() scu.GSL.release()
def get_archive_date(self, archive_id): def get_archive_date(self, archive_id: str):
"""Returns date (as a DateTime object) of an archive""" """Returns date (as a DateTime object) of an archive"""
return datetime.datetime( return datetime.datetime(
*[int(x) for x in os.path.split(archive_id)[1].split("-")] *[int(x) for x in os.path.split(archive_id)[1].split("-")]
@ -183,17 +188,17 @@ class BaseArchiver(object):
files.sort() files.sort()
return [f for f in files if f and f[0] != "_"] return [f for f in files if f and f[0] != "_"]
def get_archive_name(self, archive_id): def get_archive_name(self, archive_id: str):
"""name identifying archive, to be used in web URLs""" """name identifying archive, to be used in web URLs"""
return os.path.split(archive_id)[1] return os.path.split(archive_id)[1]
def is_valid_archive_name(self, archive_name): def is_valid_archive_name(self, archive_name: str):
"""check if name is valid.""" """check if name is valid."""
return re.match( return re.match(
"^[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}$", archive_name "^[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}-[0-9]{2}$", archive_name
) )
def get_id_from_name(self, oid, archive_name): def get_id_from_name(self, oid, archive_name: str):
"""returns archive id (check that name is valid)""" """returns archive id (check that name is valid)"""
self.initialize() self.initialize()
if not self.is_valid_archive_name(archive_name): if not self.is_valid_archive_name(archive_name):
@ -206,7 +211,7 @@ class BaseArchiver(object):
raise ScoValueError(f"Archive {archive_name} introuvable") raise ScoValueError(f"Archive {archive_name} introuvable")
return archive_id return archive_id
def get_archive_description(self, archive_id): def get_archive_description(self, archive_id: str) -> str:
"""Return description of archive""" """Return description of archive"""
self.initialize() self.initialize()
filename = os.path.join(archive_id, "_description.txt") filename = os.path.join(archive_id, "_description.txt")
@ -247,7 +252,7 @@ class BaseArchiver(object):
data = data.encode(scu.SCO_ENCODING) data = data.encode(scu.SCO_ENCODING)
self.initialize() self.initialize()
filename = scu.sanitize_filename(filename) filename = scu.sanitize_filename(filename)
log("storing %s (%d bytes) in %s" % (filename, len(data), archive_id)) log(f"storing {filename} ({len(data)} bytes) in {archive_id}")
try: try:
scu.GSL.acquire() scu.GSL.acquire()
fname = os.path.join(archive_id, filename) fname = os.path.join(archive_id, filename)
@ -261,16 +266,18 @@ class BaseArchiver(object):
"""Retreive data""" """Retreive data"""
self.initialize() self.initialize()
if not scu.is_valid_filename(filename): if not scu.is_valid_filename(filename):
log('Archiver.get: invalid filename "%s"' % filename) log(f"""Archiver.get: invalid filename '{filename}'""")
raise ScoValueError("archive introuvable (déjà supprimée ?)") raise ScoValueError("archive introuvable (déjà supprimée ?)")
fname = os.path.join(archive_id, filename) fname = os.path.join(archive_id, filename)
log("reading archive file %s" % fname) log(f"reading archive file {fname}")
with open(fname, "rb") as f: with open(fname, "rb") as f:
data = f.read() data = f.read()
return data return data
def get_archived_file(self, oid, archive_name, filename): def get_archived_file(self, oid, archive_name, filename):
"""Recupere donnees du fichier indiqué et envoie au client""" """Recupère les donnees du fichier indiqué et envoie au client.
Returns: Response
"""
archive_id = self.get_id_from_name(oid, archive_name) archive_id = self.get_id_from_name(oid, archive_name)
data = self.get(archive_id, filename) data = self.get(archive_id, filename)
mime = mimetypes.guess_type(filename)[0] mime = mimetypes.guess_type(filename)[0]

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -373,7 +373,7 @@ def etudarchive_import_files(
filename_title="fichier_a_charger", filename_title="fichier_a_charger",
) )
return render_template( return render_template(
"scolar/photos_import_files.html", "scolar/photos_import_files.j2",
page_title="Téléchargement de fichiers associés aux étudiants", page_title="Téléchargement de fichiers associés aux étudiants",
ignored_zipfiles=ignored_zipfiles, ignored_zipfiles=ignored_zipfiles,
unmatched_files=unmatched_files, unmatched_files=unmatched_files,

View File

@ -0,0 +1,108 @@
from app.scodoc.sco_archives import BaseArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.models import Identite, Departement
from flask import g
import os
class JustificatifArchiver(BaseArchiver):
"""
TOTALK:
- oid -> etudid
- archive_id -> date de création de l'archive (une archive par dépot de document)
justificatif
<dept_id>
<etudid/oid>
<archive_id>
[_description.txt]
[<filename.ext>]
"""
def __init__(self):
BaseArchiver.__init__(self, archive_type="justificatifs")
def save_justificatif(
self,
etudid: int,
filename: str,
data: bytes or str,
archive_name: str = None,
description: str = "",
) -> str:
"""
Ajoute un fichier dans une archive "justificatif" pour l'etudid donné
Retourne l'archive_name utilisé
"""
self._set_dept(etudid)
if archive_name is None:
archive_id: str = self.create_obj_archive(
oid=etudid, description=description
)
else:
archive_id: str = self.get_id_from_name(etudid, archive_name)
fname: str = self.store(archive_id, filename, data)
return self.get_archive_name(archive_id), fname
def delete_justificatif(self, etudid: int, archive_name: str, filename: str = None):
"""
Supprime une archive ou un fichier particulier de l'archivage de l'étudiant donné
"""
self._set_dept(etudid)
if str(etudid) not in self.list_oids():
raise ValueError(f"Aucune archive pour etudid[{etudid}]")
archive_id = self.get_id_from_name(etudid, archive_name)
if filename is not None:
if filename not in self.list_archive(archive_id):
raise ValueError(
f"filename {filename} inconnu dans l'archive archive_id[{archive_id}] -> etudid[{etudid}]"
)
path: str = os.path.join(self.get_obj_dir(etudid), archive_id, filename)
if os.path.isfile(path):
os.remove(path)
else:
self.delete_archive(
os.path.join(
self.get_obj_dir(etudid),
archive_id,
)
)
def list_justificatifs(self, archive_name: str, etudid: int) -> list[str]:
"""
Retourne la liste des noms de fichiers dans l'archive donnée
"""
self._set_dept(etudid)
filenames: list[str] = []
archive_id = self.get_id_from_name(etudid, archive_name)
filenames = self.list_archive(archive_id)
return filenames
def get_justificatif_file(self, archive_name: str, etudid: int, filename: str):
"""
Retourne une réponse de téléchargement de fichier si le fichier existe
"""
self._set_dept(etudid)
archive_id: str = self.get_id_from_name(etudid, archive_name)
if filename in self.list_archive(archive_id):
return self.get_archived_file(etudid, archive_name, filename)
raise ScoValueError(
f"Fichier {filename} introuvable dans l'archive {archive_name}"
)
def _set_dept(self, etudid: int):
"""
Mets à jour le dept_id de l'archiver en fonction du département de l'étudiant
"""
etud: Identite = Identite.query.filter_by(id=etudid).first()
self.set_dept_id(etud.dept_id)

View File

@ -0,0 +1,401 @@
from datetime import date, datetime, time, timedelta
import app.scodoc.sco_utils as scu
from app.models.assiduites import Assiduite, Justificatif
from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre, FormSemestreInscription
class CountCalculator:
def __init__(
self,
morning: time = time(8, 0),
noon: time = time(12, 0),
after_noon: time = time(14, 00),
evening: time = time(18, 0),
skip_saturday: bool = True,
) -> None:
self.morning: time = morning
self.noon: time = noon
self.after_noon: time = after_noon
self.evening: time = evening
self.skip_saturday: bool = skip_saturday
delta_total: timedelta = datetime.combine(date.min, evening) - datetime.combine(
date.min, morning
)
delta_lunch: timedelta = datetime.combine(
date.min, after_noon
) - datetime.combine(date.min, noon)
self.hour_per_day: float = (delta_total - delta_lunch).total_seconds() / 3600
self.days: list[date] = []
self.half_days: list[tuple[date, bool]] = [] # tuple -> (date, morning:bool)
self.hours: float = 0.0
self.count: int = 0
def add_half_day(self, day: date, is_morning: bool = True):
key: tuple[date, bool] = (day, is_morning)
if key not in self.half_days:
self.half_days.append(key)
def add_day(self, day: date):
if day not in self.days:
self.days.append(day)
def check_in_morning(self, period: tuple[datetime, datetime]) -> bool:
interval_morning: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(period[0].date(), self.morning)),
scu.localize_datetime(datetime.combine(period[0].date(), self.noon)),
)
in_morning: bool = scu.is_period_overlapping(period, interval_morning)
return in_morning
def check_in_evening(self, period: tuple[datetime, datetime]) -> bool:
interval_evening: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(period[0].date(), self.after_noon)),
scu.localize_datetime(datetime.combine(period[0].date(), self.evening)),
)
in_evening: bool = scu.is_period_overlapping(period, interval_evening)
return in_evening
def compute_long_assiduite(self, assi: Assiduite):
pointer_date: date = assi.date_debut.date() + timedelta(days=1)
start_hours: timedelta = assi.date_debut - scu.localize_datetime(
datetime.combine(assi.date_debut, self.morning)
)
finish_hours: timedelta = assi.date_fin - scu.localize_datetime(
datetime.combine(assi.date_fin, self.morning)
)
self.add_day(assi.date_debut.date())
self.add_day(assi.date_fin.date())
start_period: tuple[datetime, datetime] = (
assi.date_debut,
scu.localize_datetime(
datetime.combine(assi.date_debut.date(), self.evening)
),
)
finish_period: tuple[datetime, datetime] = (
scu.localize_datetime(datetime.combine(assi.date_fin.date(), self.morning)),
assi.date_fin,
)
hours = 0.0
for period in (start_period, finish_period):
if self.check_in_evening(period):
self.add_half_day(period[0].date(), False)
if self.check_in_morning(period):
self.add_half_day(period[0].date())
while pointer_date < assi.date_fin.date():
if pointer_date.weekday() < (6 - self.skip_saturday):
self.add_day(pointer_date)
self.add_half_day(pointer_date)
self.add_half_day(pointer_date, False)
self.hours += self.hour_per_day
hours += self.hour_per_day
pointer_date += timedelta(days=1)
self.hours += finish_hours.total_seconds() / 3600
self.hours += self.hour_per_day - (start_hours.total_seconds() / 3600)
def compute_assiduites(self, assiduites: Assiduite):
assi: Assiduite
for assi in assiduites.all():
self.count += 1
delta: timedelta = assi.date_fin - assi.date_debut
if delta.days > 0:
# raise Exception(self.hours)
self.compute_long_assiduite(assi)
continue
period: tuple[datetime, datetime] = (assi.date_debut, assi.date_fin)
deb_date: date = assi.date_debut.date()
if self.check_in_morning(period):
self.add_half_day(deb_date)
if self.check_in_evening(period):
self.add_half_day(deb_date, False)
self.add_day(deb_date)
self.hours += delta.total_seconds() / 3600
def to_dict(self) -> dict[str, object]:
return {
"compte": self.count,
"journee": len(self.days),
"demi": len(self.half_days),
"heure": round(self.hours, 2),
}
def get_assiduites_stats(
assiduites: Assiduite, metric: str = "all", filtered: dict[str, object] = None
) -> Assiduite:
if filtered is not None:
deb, fin = None, None
for key in filtered:
if key == "etat":
assiduites = filter_assiduites_by_etat(assiduites, filtered[key])
elif key == "date_fin":
fin = filtered[key]
elif key == "date_debut":
deb = filtered[key]
elif key == "moduleimpl_id":
assiduites = filter_by_module_impl(assiduites, filtered[key])
elif key == "formsemestre":
assiduites = filter_by_formsemestre(assiduites, filtered[key])
if (deb, fin) != (None, None):
assiduites = filter_by_date(assiduites, Assiduite, deb, fin)
calculator: CountCalculator = CountCalculator()
calculator.compute_assiduites(assiduites)
count: dict = calculator.to_dict()
metrics: list[str] = metric.split(",")
output: dict = {}
for key, val in count.items():
if key in metrics:
output[key] = val
return output if output else count
# def big_counter(
# interval: tuple[datetime],
# pref_time: time = time(12, 0),
# ):
# curr_date: datetime
# if interval[0].time() >= pref_time:
# curr_date = scu.localize_datetime(
# datetime.combine(interval[0].date(), pref_time)
# )
# else:
# curr_date = scu.localize_datetime(
# datetime.combine(interval[0].date(), time(0, 0))
# )
# def next_(curr: datetime, journee):
# if curr.time() != pref_time:
# next_time = scu.localize_datetime(datetime.combine(curr.date(), pref_time))
# else:
# next_time = scu.localize_datetime(
# datetime.combine(curr.date() + timedelta(days=1), time(0, 0))
# )
# journee += 1
# return next_time, journee
# demi: int = 0
# j: int = 0
# while curr_date <= interval[1]:
# next_time: datetime
# next_time, j = next_(curr_date, j)
# if scu.is_period_overlapping((curr_date, next_time), interval, True):
# demi += 1
# curr_date = next_time
# delta: timedelta = interval[1] - interval[0]
# heures: float = delta.total_seconds() / 3600
# if delta.days >= 1:
# heures -= delta.days * 16
# return (demi, j, heures)
# def get_count(
# assiduites: Assiduite, noon: time = time(hour=12)
# ) -> dict[str, int or float]:
# """Fonction permettant de compter les assiduites
# -> seul "compte" est correcte lorsque les assiduites viennent de plusieurs étudiants
# """
# # TODO: Comptage demi journée / journée d'assiduité longue
# output: dict[str, int or float] = {}
# compte: int = assiduites.count()
# heure: float = 0.0
# journee: int = 0
# demi: int = 0
# all_assiduites: list[Assiduite] = assiduites.order_by(Assiduite.date_debut).all()
# current_day: date = None
# current_time: str = None
# midnight: time = time(hour=0)
# def time_check(dtime):
# return midnight <= dtime.time() <= noon
# for ass in all_assiduites:
# delta: timedelta = ass.date_fin - ass.date_debut
# if delta.days > 0:
# computed_values: tuple[int, int, float] = big_counter(
# (ass.date_debut, ass.date_fin), noon
# )
# demi += computed_values[0] - 1
# journee += computed_values[1] - 1
# heure += computed_values[2]
# current_day = ass.date_fin.date()
# continue
# heure += delta.total_seconds() / 3600
# ass_time: str = time_check(ass.date_debut)
# if current_day != ass.date_debut.date():
# current_day = ass.date_debut.date()
# current_time = ass_time
# demi += 1
# journee += 1
# if current_time != ass_time:
# current_time = ass_time
# demi += 1
# heure = round(heure, 2)
# return {"compte": compte, "journee": journee, "heure": heure, "demi": demi}
def filter_assiduites_by_etat(assiduites: Assiduite, etat: str) -> Assiduite:
"""
Filtrage d'une collection d'assiduites en fonction de leur état
"""
etats: list[str] = list(etat.split(","))
etats = [scu.EtatAssiduite.get(e, -1) for e in etats]
return assiduites.filter(Assiduite.etat.in_(etats))
def filter_by_date(
collection: Assiduite or Justificatif,
collection_cls: Assiduite or Justificatif,
date_deb: datetime = None,
date_fin: datetime = None,
strict: bool = False,
):
"""
Filtrage d'une collection d'assiduites en fonction d'une date
"""
if date_deb is None:
date_deb = datetime.min
if date_fin is None:
date_fin = datetime.max
date_deb = scu.localize_datetime(date_deb)
date_fin = scu.localize_datetime(date_fin)
if not strict:
return collection.filter(
collection_cls.date_debut <= date_fin, collection_cls.date_fin >= date_deb
)
return collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_deb
)
def filter_justificatifs_by_etat(
justificatifs: Justificatif, etat: str
) -> Justificatif:
"""
Filtrage d'une collection de justificatifs en fonction de leur état
"""
etats: list[str] = list(etat.split(","))
etats = [scu.EtatJustificatif.get(e, -1) for e in etats]
return justificatifs.filter(Justificatif.etat.in_(etats))
def filter_justificatifs_by_date(
justificatifs: Justificatif, date_: datetime, sup: bool = True
) -> Assiduite:
"""
Filtrage d'une collection d'assiduites en fonction d'une date
Sup == True -> les assiduites doivent débuter après 'date'\n
Sup == False -> les assiduites doivent finir avant 'date'
"""
if date_.tzinfo is None:
first_justificatif: Justificatif = justificatifs.first()
if first_justificatif is not None:
date_: datetime = date_.replace(tzinfo=first_justificatif.date_debut.tzinfo)
if sup:
return justificatifs.filter(Justificatif.date_debut >= date_)
return justificatifs.filter(Justificatif.date_fin <= date_)
def filter_by_module_impl(
assiduites: Assiduite, module_impl_id: int or None
) -> Assiduite:
"""
Filtrage d'une collection d'assiduites en fonction de l'ID du module_impl
"""
return assiduites.filter(Assiduite.moduleimpl_id == module_impl_id)
def filter_by_formsemestre(assiduites_query: Assiduite, formsemestre: FormSemestre):
"""
Filtrage d'une collection d'assiduites en fonction d'un formsemestre
"""
if formsemestre is None:
return assiduites_query.filter(False)
assiduites_query = (
assiduites_query.join(Identite, Assiduite.etudid == Identite.id)
.join(
FormSemestreInscription,
Identite.id == FormSemestreInscription.etudid,
)
.filter(FormSemestreInscription.formsemestre_id == formsemestre.id)
)
assiduites_query = assiduites_query.filter(
Assiduite.date_debut >= formsemestre.date_debut
)
return assiduites_query.filter(Assiduite.date_fin <= formsemestre.date_fin)
def justifies(justi: Justificatif) -> list[int]:
"""
Retourne la liste des assiduite_id qui sont justifié par la justification
Une assiduité est justifiée si elle est STRICTEMENT comprise dans la plage du justificatif
et que l'état du justificatif est "validé"
"""
justified: list[int] = []
if justi.etat != scu.EtatJustificatif.VALIDE:
return justified
assiduites_query: Assiduite = Assiduite.query.join(
Justificatif, Assiduite.etudid == Justificatif.etudid
).filter(Assiduite.etat != scu.EtatAssiduite.PRESENT)
assiduites_query = filter_by_date(
assiduites_query, Assiduite, justi.date_debut, justi.date_fin
)
justified = [assi.id for assi in assiduites_query.all()]
return justified

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by

View File

@ -5,7 +5,7 @@
# #
# Gestion scolarite IUT # 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 # 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 # it under the terms of the GNU General Public License as published by
@ -926,7 +926,7 @@ def formsemestre_bulletinetud(
_formsemestre_bulletinetud_header_html(etud, formsemestre, format, version), _formsemestre_bulletinetud_header_html(etud, formsemestre, format, version),
bulletin, bulletin,
render_template( render_template(
"bul_foot.html", "bul_foot.j2",
appreciations=None, # déjà affichées appreciations=None, # déjà affichées
css_class="bul_classic_foot", css_class="bul_classic_foot",
etud=etud, etud=etud,
@ -990,6 +990,8 @@ def do_formsemestre_bulletinetud(
version=version, version=version,
) )
return bul, "" return bul, ""
if version.endswith("_mat"):
version = version[:-4] # enlève le "_mat"
if formsemestre.formation.is_apc(): if formsemestre.formation.is_apc():
etudiant = Identite.query.get(etudid) etudiant = Identite.query.get(etudid)
@ -1082,7 +1084,7 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
recipients = [recipient_addr] recipients = [recipient_addr]
sender = sco_preferences.get_preference("email_from_addr", formsemestre_id) sender = sco_preferences.get_preference("email_from_addr", formsemestre_id)
if copy_addr: if copy_addr:
bcc = copy_addr.strip() bcc = copy_addr.strip().split(",")
else: else:
bcc = "" bcc = ""
@ -1092,7 +1094,7 @@ def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr):
subject, subject,
sender, sender,
recipients, recipients,
bcc=[bcc], bcc=bcc,
text_body=hea, text_body=hea,
attachments=[ attachments=[
{"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata} {"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata}
@ -1215,7 +1217,8 @@ def make_menu_autres_operations(
"formsemestre_id": formsemestre.id, "formsemestre_id": formsemestre.id,
"etudid": etud.id, "etudid": etud.id,
}, },
"enabled": sco_permissions_check.can_validate_sem(formsemestre.id), "enabled": sco_permissions_check.can_validate_sem(formsemestre.id)
and not formsemestre.formation.is_apc(),
}, },
{ {
"title": "Entrer décisions jury", "title": "Entrer décisions jury",
@ -1256,7 +1259,7 @@ def _formsemestre_bulletinetud_header_html(
cssstyles=["css/radar_bulletin.css"], cssstyles=["css/radar_bulletin.css"],
), ),
render_template( render_template(
"bul_head.html", "bul_head.j2",
etud=etud, etud=etud,
format=format, format=format,
formsemestre=formsemestre, formsemestre=formsemestre,

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