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...
disable=bad-continuation

View File

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

View File

@ -26,11 +26,13 @@ from flask_mail import Mail
from flask_bootstrap import Bootstrap
from flask_moment import Moment
from flask_caching import Cache
from jinja2 import select_autoescape
import sqlalchemy
from app.scodoc.sco_exceptions import (
AccessDenied,
ScoBugCatcher,
ScoException,
ScoGenError,
ScoValueError,
APIInvalidParams,
@ -60,11 +62,11 @@ cache = Cache(
def handle_sco_value_error(exc):
return render_template("sco_value_error.html", exc=exc), 404
return render_template("sco_value_error.j2", exc=exc), 404
def handle_access_denied(exc):
return render_template("error_access_denied.html", exc=exc), 403
return render_template("error_access_denied.j2", exc=exc), 403
def internal_server_error(exc):
@ -74,7 +76,7 @@ def internal_server_error(exc):
return (
render_template(
"error_500.html",
"error_500.j2",
SCOVERSION=sco_version.SCOVERSION,
date=datetime.datetime.now().isoformat(),
exc=exc,
@ -92,9 +94,12 @@ def handle_sco_bug(exc):
"""Un bug, en général rare, sur lequel les dev cherchent des
informations pour le corriger.
"""
Thread(
target=_async_dump, args=(current_app._get_current_object(), request.url)
).start()
if current_app.config["TESTING"] or current_app.config["DEBUG"]:
raise ScoException # for development servers only
else:
Thread(
target=_async_dump, args=(current_app._get_current_object(), request.url)
).start()
return internal_server_error(exc)
@ -142,7 +147,7 @@ def render_raw_html(template_filename: str, **args) -> str:
def postgresql_server_error(e):
"""Erreur de connection au serveur postgresql (voir notesdb.open_db_connection)"""
return render_raw_html("error_503.html", SCOVERSION=sco_version.SCOVERSION), 503
return render_raw_html("error_503.j2", SCOVERSION=sco_version.SCOVERSION), 503
class LogRequestFormatter(logging.Formatter):
@ -271,6 +276,9 @@ def create_app(config_class=DevConfig):
from app.api import api_bp
from app.api import api_web_bp
# Enable autoescaping of all templates, including .j2
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
# https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp)
# https://scodoc.fr/ScoDoc/RT/Scolarite/...
@ -435,8 +443,6 @@ def initialize_scodoc_database(erase=False, create_all=False):
SQL tables and functions.
If erase is True, _erase_ all database content.
"""
from app import models
# - ERASE (the truncation sql function has been defined above)
if erase:
truncate_database()
@ -463,6 +469,26 @@ def truncate_database():
except:
db.session.rollback()
raise
# Remet les compteurs (séquences sql) à zéro
db.session.execute(
"""
CREATE OR REPLACE FUNCTION reset_sequences(username IN VARCHAR) RETURNS void AS $$
DECLARE
statements CURSOR FOR
SELECT sequence_name
FROM information_schema.sequences
ORDER BY sequence_name ;
BEGIN
FOR stmt IN statements LOOP
EXECUTE 'ALTER SEQUENCE ' || quote_ident(stmt.sequence_name) || ' RESTART;';
END LOOP;
END;
$$ LANGUAGE plpgsql;
SELECT reset_sequences('scodoc');
"""
)
db.session.commit()
def clear_scodoc_cache():
@ -480,12 +506,10 @@ def clear_scodoc_cache():
# --------- Logging
def log(msg: str, silent_test=True):
def log(msg: str):
"""log a message.
If Flask app, use configured logger, else stderr.
"""
if silent_test and current_app and current_app.config["TESTING"]:
return
try:
dept = getattr(g, "scodoc_dept", "")
msg = f" ({dept}) {msg}"
@ -530,3 +554,22 @@ def scodoc_flash_status_messages():
f"Mode test: mails redirigés vers {email_test_mode_address}",
category="warning",
)
def critical_error(msg):
"""Handle a critical error: flush all caches, display message to the user"""
import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}")
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg)
clear_scodoc_cache()
raise ScoValueError(
f"""
Une erreur est survenue.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
{msg}
"""
)

View File

@ -2,7 +2,8 @@
"""
from flask import Blueprint
from flask import request
from flask import request, g, jsonify
from app import db
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException
@ -31,9 +32,26 @@ def requested_format(default_format="json", allowed_formats=None):
return None
def get_model_api_object(model_cls: db.Model, model_id: int, join_cls: db.Model = None):
"""
Retourne une réponse contenant la représentation api de l'objet "Model[model_id]"
Filtrage du département en fonction d'une classe de jointure (eg: Identite, Formsemstre) -> join_cls
exemple d'utilisation : fonction "justificatif()" -> app/api/justificatifs.py
"""
query = model_cls.query.filter_by(id=model_id)
if g.scodoc_dept and join_cls is not None:
query = query.join(join_cls).filter_by(dept_id=g.scodoc_dept_id)
unique: model_cls = query.first_or_404()
return jsonify(unique.to_dict(format_api=True))
from app.api import tokens
from app.api import (
absences,
assiduites,
billets_absences,
departements,
etudiants,
@ -41,6 +59,7 @@ from app.api import (
formations,
formsemestres,
jury,
justificatifs,
logos,
partitions,
users,

View File

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

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
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -43,13 +43,13 @@ from app.but import bulletin_but
from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc import sco_abs
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud
from app.scodoc import sco_photos
from app.scodoc import sco_preferences
from app.scodoc import sco_xml
from app.scodoc.sco_xml import quote_xml_attr
def bulletin_but_xml_compat(
@ -108,13 +108,13 @@ def bulletin_but_xml_compat(
etudid=str(etudid),
code_nip=etud.code_nip or "",
code_ine=etud.code_ine or "",
nom=scu.quote_xml_attr(etud.nom),
prenom=scu.quote_xml_attr(etud.prenom),
civilite=scu.quote_xml_attr(etud.civilite_str),
sexe=scu.quote_xml_attr(etud.civilite_str), # compat
photo_url=scu.quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)),
email=scu.quote_xml_attr(etud.get_first_email() or ""),
emailperso=scu.quote_xml_attr(etud.get_first_email("emailperso") or ""),
nom=quote_xml_attr(etud.nom),
prenom=quote_xml_attr(etud.prenom),
civilite=quote_xml_attr(etud.civilite_str),
sexe=quote_xml_attr(etud.civilite_str), # compat
photo_url=quote_xml_attr(sco_photos.get_etud_photo_url(etud.id)),
email=quote_xml_attr(etud.get_first_email() or ""),
emailperso=quote_xml_attr(etud.get_first_email("emailperso") or ""),
)
)
# Disponible pour publication ?
@ -153,10 +153,10 @@ def bulletin_but_xml_compat(
x_ue = Element(
"ue",
id=str(ue.id),
numero=scu.quote_xml_attr(ue.numero),
acronyme=scu.quote_xml_attr(ue.acronyme or ""),
titre=scu.quote_xml_attr(ue.titre or ""),
code_apogee=scu.quote_xml_attr(ue.code_apogee or ""),
numero=quote_xml_attr(ue.numero),
acronyme=quote_xml_attr(ue.acronyme or ""),
titre=quote_xml_attr(ue.titre or ""),
code_apogee=quote_xml_attr(ue.code_apogee or ""),
)
doc.append(x_ue)
if ue.type != sco_codes_parcours.UE_SPORT:
@ -192,11 +192,9 @@ def bulletin_but_xml_compat(
code=str(modimpl.module.code or ""),
coefficient=str(coef),
numero=str(modimpl.module.numero or 0),
titre=scu.quote_xml_attr(modimpl.module.titre or ""),
abbrev=scu.quote_xml_attr(modimpl.module.abbrev or ""),
code_apogee=scu.quote_xml_attr(
modimpl.module.code_apogee or ""
),
titre=quote_xml_attr(modimpl.module.titre or ""),
abbrev=quote_xml_attr(modimpl.module.abbrev or ""),
code_apogee=quote_xml_attr(modimpl.module.code_apogee or ""),
)
# XXX TODO rangs et effectifs
# --- notes de chaque eval:
@ -215,7 +213,7 @@ def bulletin_but_xml_compat(
coefficient=str(e.coefficient),
# pas les poids en XML compat
evaluation_type=str(e.evaluation_type),
description=scu.quote_xml_attr(e.description),
description=quote_xml_attr(e.description),
# notes envoyées sur 20, ceci juste pour garder trace:
note_max_origin=str(e.note_max),
)
@ -262,7 +260,7 @@ def bulletin_but_xml_compat(
),
)
x_situation = Element("situation")
x_situation.text = scu.quote_xml_attr(infos["situation"])
x_situation.text = quote_xml_attr(infos["situation"])
doc.append(x_situation)
if dpv:
decision = dpv["decisions"][0]
@ -297,9 +295,9 @@ def bulletin_but_xml_compat(
Element(
"decision_ue",
ue_id=str(ue["ue_id"]),
numero=scu.quote_xml_attr(ue["numero"]),
acronyme=scu.quote_xml_attr(ue["acronyme"]),
titre=scu.quote_xml_attr(ue["titre"]),
numero=quote_xml_attr(ue["numero"]),
acronyme=quote_xml_attr(ue["acronyme"]),
titre=quote_xml_attr(ue["titre"]),
code=decision["decisions_ue"][ue_id]["code"],
)
)
@ -322,7 +320,7 @@ def bulletin_but_xml_compat(
"appreciation",
date=ndb.DateDMYtoISO(appr["date"]),
)
x_appr.text = scu.quote_xml_attr(appr["comment"])
x_appr.text = quote_xml_attr(appr["comment"])
doc.append(x_appr)
if is_appending:

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,12 +1,13 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Jury BUT: table recap annuelle et liens saisie
"""
import collections
import time
import numpy as np
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_pvjury
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(
@ -58,20 +59,13 @@ def formsemestre_saisie_jury_but(
# DecisionsProposeesAnnee(etud, formsemestre2)
# Pour le 1er etud, faire un check_ues_ready_jury(self) -> page d'erreur
# -> rcue .ue_1, .ue_2 -> stroe moy ues, rcue.moy_rcue, etc
if formsemestre2.semestre_id % 2 != 0:
raise ScoValueError("Cette page ne fonctionne que sur les semestres pairs")
# XXX if formsemestre2.semestre_id % 2 != 0:
# raise ScoValueError("Cette page ne fonctionne que sur les semestres pairs")
if formsemestre2.formation.referentiel_competence is None:
raise ScoValueError(
"""
<p>Pas de référentiel de compétences associé à la formation !</p>
<p>Pour associer un référentiel, passer par le menu <b>Semestre /
Voir la formation... </b> et suivre le lien <em>"associer à un référentiel
de compétences"</em>
"""
)
raise ScoNoReferentielCompetences(formation=formsemestre2.formation)
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
)
if not rows:
@ -153,6 +147,28 @@ def formsemestre_saisie_jury_but(
f"""
</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()}
"""
)
@ -262,12 +278,16 @@ class RowCollector:
# --- Codes (seront cachés, mais exportés en excel)
self.add_cell("etudid", "etudid", etud.id, "codes")
self.add_cell("code_nip", "code_nip", etud.code_nip or "", "codes")
# --- Identité étudiant (adapté de res_comon/get_table_recap, à factoriser XXX TODO)
# --- 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("nom_disp", "Nom", etud.nom_disp(), "identite_detail")
self["_nom_disp_order"] = etud.sort_key
self.add_cell("prenom", "Prénom", etud.prenom, "identite_detail")
self.add_cell("nom_short", "Nom", etud.nom_short, "identite_court")
self["_nom_short_data"] = {
"etudid": etud.id,
"nomprenom": etud.nomprenom,
}
if with_links:
self["_nom_short_order"] = etud.sort_key
self["_nom_short_target"] = url_for(
@ -352,10 +372,6 @@ class RowCollector:
+ ((" " + scu.EMO_WARNING) if deca.nb_rcues_under_8 > 0 else ""),
"col_rcue col_rcues_validables" + klass,
)
self["_rcues_validables_data"] = {
"etudid": deca.etud.id,
"nomprenom": deca.etud.nomprenom,
}
if len(deca.rcues_annee) > 0:
# permet un tri par nb de niveaux validables + moyenne gen indicative S_pair
if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen:
@ -377,10 +393,17 @@ class RowCollector:
def get_jury_but_table(
formsemestre2: FormSemestre, read_only: bool = False, mode="jury", with_links=True
) -> tuple[list[dict], list[str], list[str]]:
"""Construit la table des résultats annuels pour le jury BUT"""
) -> tuple[list[dict], list[str], list[str], dict]:
"""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)
titles = {} # column_id : title
jury_stats = {
"nb_etuds": len(formsemestre2.etuds_inscriptions),
"codes_annuels": collections.Counter(),
}
column_classes = {}
rows = []
for etudid in formsemestre2.etuds_inscriptions:
@ -417,6 +440,8 @@ def get_jury_but_table(
f"""{deca.code_valide or ''}""",
"col_code_annee",
)
if deca.code_valide:
jury_stats["codes_annuels"][deca.code_valide] += 1
# --- Le lien de saisie
if mode != "recap" and with_links:
row.add_cell(
@ -439,11 +464,14 @@ def get_jury_but_table(
rows.append(row)
rows_dict = [row.get_row_dict() for row in rows]
if len(rows_dict) > 0:
res2.recap_add_partitions(rows_dict, titles, col_idx=row.last_etud_cell_idx + 1)
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.sort(key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000))
rows_dict.sort(key=lambda row: row["_nom_disp_order"])
return rows_dict, titles, column_ids
return rows_dict, titles, column_ids, jury_stats
def get_jury_but_results(formsemestre: FormSemestre) -> list[dict]:

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -18,21 +18,11 @@ import pandas as pd
from flask import g
from app.models.formsemestre import FormSemestre
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono
from app.scodoc.sco_utils import ModuleType
def get_bonus_sport_class_from_name(dept_id):
"""La classe de bonus sport pour le département indiqué.
Note: en ScoDoc 9, le bonus sport est défini gloabelement et
ne dépend donc pas du département.
Résultat: une sous-classe de BonusSport
"""
raise NotImplementedError()
class BonusSport:
"""Calcul du bonus sport.
@ -65,7 +55,7 @@ class BonusSport:
def __init__(
self,
formsemestre: FormSemestre,
formsemestre: "FormSemestre",
sem_modimpl_moys: np.array,
ues: list,
modimpl_inscr_df: pd.DataFrame,
@ -362,18 +352,37 @@ class BonusAisneStQuentin(BonusSportAdditif):
class BonusAmiens(BonusSportAdditif):
"""Bonus IUT Amiens pour les modules optionnels (sport, culture, ...).
"""Bonus IUT Amiens pour les modules optionnels (sport, culture, ...)
Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,1 point
<p><b>À partir d'août 2022:</b></p>
<p>
Deux activités optionnelles sont possibles chaque semestre, et peuvent donner lieu à une bonification de 0,1 chacune sur la moyenne de chaque UE.
</p><p>
La note saisie peut valoir 0 (pas de bonus), 1 (bonus de 0,1 points) ou 2 (bonus de 0,2 points).
</p>
<p><b>Avant juillet 2022:</b></p>
<p>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,1 point
sur toutes les moyennes d'UE.
</p>
"""
name = "bonus_amiens"
displayed_name = "IUT d'Amiens"
seuil_moy_gen = 0.0 # tous les points sont comptés
proportion_point = 1e10
bonus_max = 0.1
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points sont comptés
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""calcul du bonus, avec réglage différent suivant la date"""
if self.formsemestre.date_debut > datetime.date(2022, 8, 1):
self.proportion_point = 0.1
self.bonus_max = 0.2
else: # anciens semestres
self.proportion_point = 1e10
self.bonus_max = 0.1
super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan)
# Finalement ils n'en veulent pas.
@ -421,6 +430,22 @@ class BonusAmiens(BonusSportAdditif):
# )
class BonusBesanconVesoul(BonusSportAdditif):
"""Bonus IUT Besançon - Vesoul pour les UE libres
<p>Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,2 point
sur toutes les moyennes d'UE.
</p>
"""
name = "bonus_besancon_vesoul"
displayed_name = "IUT de Besançon - Vesoul"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points sont comptés
proportion_point = 1e10 # infini
bonus_max = 0.2
class BonusBethune(BonusSportMultiplicatif):
"""
Calcul bonus modules optionnels (sport, culture), règle IUT de Béthune.
@ -638,7 +663,10 @@ class BonusCalais(BonusSportAdditif):
dans la limite de 10 points. 6% de ces points cumulés s'ajoutent :
<ul>
<li><b>en DUT</b> à la moyenne générale du semestre déjà obtenue par l'étudiant.
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b> (ex : UE2.1BS, UE32BS)
</li>
<li><b>en BUT et LP</b> à la moyenne des UE dont l'acronyme fini par <b>BS</b>
(ex : UE2.1BS, UE32BS)
</li>
</ul>
"""
@ -1199,7 +1227,7 @@ class BonusStDenis(BonusSportAdditif):
bonus_max = 0.5
class BonusStNazaire(BonusSportMultiplicatif):
class BonusStNazaire(BonusSport):
"""IUT de Saint-Nazaire
Trois bonifications sont possibles : sport, culture et engagement citoyen
@ -1221,9 +1249,37 @@ class BonusStNazaire(BonusSportMultiplicatif):
name = "bonus_iutSN"
displayed_name = "IUT de Saint-Nazaire"
classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP
seuil_moy_gen = 0.0 # tous les points comptent
amplitude = 0.01 / 4 # 4pt => 1%
factor_max = 0.1 # 10% max
# Modifié 2022-11-29: calculer chaque bonus
# (de 1 à 3 modules) séparément.
def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan):
"""Calcul du bonus St Nazaire 2022
sem_modimpl_moys_inscrits: les notes de sport
En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus)
En classic: ndarray (nb_etuds, nb_mod_sport)
"""
if 0 in sem_modimpl_moys_inscrits.shape:
# pas d'étudiants ou pas d'UE ou pas de module...
return
# Prend les 3 premiers bonus trouvés
# ignore les coefficients
bonus_mod_moys = sem_modimpl_moys_inscrits[:, :3]
bonus_mod_moys = np.nan_to_num(bonus_mod_moys, copy=False)
factor = bonus_mod_moys * self.amplitude
# somme les bonus:
factor = factor.sum(axis=1)
# et limite à 10%:
factor.clip(0.0, self.factor_max, out=factor)
# Applique aux moyennes d'UE
if len(factor.shape) == 1: # classic
factor = factor[:, np.newaxis]
bonus = self.etud_moy_ue * factor
self.bonus_ues = bonus # DataFrame
# Les bonus multiplicatifs ne s'appliquent pas à la moyenne générale
self.bonus_moy_gen = None
class BonusTarbes(BonusIUTRennes1):
@ -1302,7 +1358,45 @@ class BonusIUTvannes(BonusSportAdditif):
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.
Les étudiants de l'IUT peuvent suivre des enseignements optionnels
@ -1351,7 +1445,7 @@ class BonusIUTV(BonusSportAdditif):
name = "bonus_iutv"
displayed_name = "IUT de Villetaneuse"
pass # oui, c'est le bonus par défaut
# c'est le bonus par défaut: aucune méthode à surcharger
def get_bonus_class_dict(start=BonusSport, d=None):

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -122,6 +122,10 @@ def formsemestre_get_ue_capitalisees(formsemestre: FormSemestre) -> pd.DataFrame
event_date :
} ]
"""
# Note: pour récupérer aussi les UE validées en CMp ou ADJ, changer une ligne
# and ( SFV.code = 'ADM' or SFV.code = 'ADJ' or SFV.code = 'CMP' )
query = """
SELECT DISTINCT SFV.*, ue.ue_code
FROM

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -35,14 +35,16 @@ moyenne générale d'une UE.
"""
import dataclasses
from dataclasses import dataclass
import numpy as np
import pandas as pd
import app
from app import db
from app.models import ModuleImpl, Evaluation, EvaluationUEPoids
from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
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_utils import ModuleType
@ -217,12 +219,19 @@ class ModuleImplResults:
]
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
"""Coefficients des évaluations, met à zéro ceux des évals incomplètes.
"""Coefficients des évaluations.
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
sont zéro.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[e.coefficient for e in moduleimpl.evaluations],
[
e.coefficient
if e.evaluation_type == scu.EVALUATION_NORMALE
else 0.0
for e in moduleimpl.evaluations
],
dtype=float,
)
* self.evaluations_completes
@ -236,8 +245,8 @@ class ModuleImplResults:
]
def get_eval_notes_sur_20(self, moduleimpl: ModuleImpl) -> np.array:
"""Les notes des évaluations,
remplace les ATT, EXC, ABS, NaN par zéro et mets les notes sur 20.
"""Les notes de toutes les évaluations du module, complètes ou non.
Remplace les ATT, EXC, ABS, NaN par zéro et mets les notes sur 20.
Résultat: 2d array of floats, shape nb_etuds x nb_evaluations
"""
return np.where(
@ -368,7 +377,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
)
# Serie indiquant que l'étudiant utilise une note de rattarage sur l'une des UE:
# Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
)
@ -429,7 +438,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modules_coefficients: pd.DataFrame
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool:
"""Vérifie que les évaluations de ce moduleimpl sont bien conformes
au PN.
@ -438,7 +447,7 @@ def moduleimpl_is_conforme(
Arguments:
evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs
modules_coefficients: DataFrame, cols module_id, lignes UEs
modimpl_coefs_df: DataFrame, cols: modimpl_id, lignes: UEs du formsemestre
NB: les UEs dans evals_poids sont sans le bonus sport
"""
nb_evals, nb_ues = evals_poids.shape
@ -446,18 +455,18 @@ def moduleimpl_is_conforme(
return True # modules vides conformes
if nb_ues == 0:
return False # situation absurde (pas d'UE)
if len(modules_coefficients) != nb_ues:
if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
if moduleimpl.module_id not in modules_coefficients:
if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modules_coefficients[moduleimpl.module_id] != 0).eq(module_evals_poids))
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
class ModuleImplResultsClassic(ModuleImplResults):
@ -476,7 +485,8 @@ class ModuleImplResultsClassic(ModuleImplResults):
if nb_etuds == 0:
return pd.Series()
evals_coefs = self.get_evaluations_coefs(modimpl).reshape(-1)
assert evals_coefs.shape == (nb_evals,)
if evals_coefs.shape != (nb_evals,):
app.critical_error("compute_module_moy: vals_coefs.shape != nb_evals")
evals_notes_20 = self.get_eval_notes_sur_20(modimpl)
# Les coefs des évals pour chaque étudiant: là où il a des notes
# non neutralisées

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
@ -48,12 +48,13 @@ class ResultatsSemestre(ResultatsCache):
_cached_attrs = (
"bonus",
"bonus_ues",
"dispense_ues",
"etud_coef_ue_df",
"etud_moy_gen_ranks",
"etud_moy_gen",
"etud_moy_ue",
"modimpl_inscr_df",
"modimpls_results",
"etud_coef_ue_df",
"moyennes_matieres",
)
@ -66,6 +67,8 @@ class ResultatsSemestre(ResultatsCache):
"Bonus sur moy. gen. Series de float, index etudid"
self.bonus_ues: pd.DataFrame = None # virtuel
"DataFrame de float, index etudid, columns: ue.id"
self.dispense_ues: set[tuple[int, int]] = set()
"""set des dispenses d'UE: (etudid, ue_id), en APC seulement."""
# ResultatsSemestreBUT ou ResultatsSemestreClassic
self.etud_moy_ue = {}
"etud_moy_ue: DataFrame columns UE, rows etudid"
@ -316,7 +319,7 @@ class ResultatsSemestre(ResultatsCache):
"""L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre.
"""
ue = UniteEns.query.get(ue_id) # TODO cacher nos UEs ?
ue = UniteEns.query.get(ue_id)
if ue.type == UE_SPORT:
return {
"is_capitalized": False,
@ -439,7 +442,7 @@ class ResultatsSemestre(ResultatsCache):
allow_html=True,
):
"""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
- rows: liste de dicts { column_id : value }
@ -491,7 +494,7 @@ class ResultatsSemestre(ResultatsCache):
classes: str = "",
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
if classes:
row[f"_{col_id}_class"] = classes + f" c{idx}"
@ -516,10 +519,11 @@ class ResultatsSemestre(ResultatsCache):
row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx
)
# --- Rang
idx = add_cell(
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
)
row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}"
if not self.formsemestre.block_moyenne_generale:
idx = add_cell(
row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx
)
row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}"
# --- Identité étudiant
idx = add_cell(
row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx
@ -539,32 +543,38 @@ class ResultatsSemestre(ResultatsCache):
formsemestre_id=self.formsemestre.id,
etudid=etudid,
)
row["_nom_short_data"] = {
"etudid": etud.id,
"nomprenom": etud.nomprenom,
}
row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"'
row["_nom_disp_target"] = row["_nom_short_target"]
row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"]
idx = 30 # début des colonnes de notes
# --- Moyenne générale
moy_gen = self.etud_moy_gen.get(etudid, False)
note_class = ""
if moy_gen is False:
moy_gen = NO_NOTE
elif isinstance(moy_gen, float) and moy_gen < barre_moy:
note_class = " moy_ue_warning" # en rouge
idx = add_cell(
row,
"moy_gen",
"Moy",
fmt_note(moy_gen),
"col_moy_gen" + note_class,
idx,
)
titles_bot["_moy_gen_target_attrs"] = (
'title="moyenne indicative"' if self.is_apc else ""
)
if not self.formsemestre.block_moyenne_generale:
moy_gen = self.etud_moy_gen.get(etudid, False)
note_class = ""
if moy_gen is False:
moy_gen = NO_NOTE
elif isinstance(moy_gen, float) and moy_gen < barre_moy:
note_class = " moy_ue_warning" # en rouge
idx = add_cell(
row,
"moy_gen",
"Moy",
fmt_note(moy_gen),
"col_moy_gen" + note_class,
idx,
)
titles_bot["_moy_gen_target_attrs"] = (
'title="moyenne indicative"' if self.is_apc else ""
)
# --- Moyenne d'UE
nb_ues_validables, nb_ues_warning = 0, 0
for ue in ues_sans_bonus:
idx_ue_start = idx
for idx_ue, ue in enumerate(ues_sans_bonus):
ue_status = self.get_etud_ue_status(etudid, ue.id)
if ue_status is not None:
col_id = f"moy_ue_{ue.id}"
@ -585,7 +595,7 @@ class ResultatsSemestre(ResultatsCache):
ue.acronyme,
fmt_note(val),
"col_ue" + note_class,
idx,
idx_ue * 10000 + idx_ue_start,
)
titles_bot[
f"_{col_id}_target_attrs"
@ -606,7 +616,7 @@ class ResultatsSemestre(ResultatsCache):
f"Bonus {ue.acronyme}",
val_fmt_html if allow_html else val_fmt,
"col_ue_bonus",
idx,
idx_ue * 10000 + idx_ue_start + 1,
)
row[f"_bonus_ue_{ue.id}_xls"] = val_fmt
# Les moyennes des modules (ou ressources et SAÉs) dans cette UE
@ -651,7 +661,11 @@ class ResultatsSemestre(ResultatsCache):
val_fmt_html,
# class col_res mod_ue_123
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
if modimpl.module.module_type == scu.ModuleType.MALUS:
@ -701,7 +715,7 @@ class ResultatsSemestre(ResultatsCache):
else:
jury_code_sem = ""
else:
# formations classiqes: code semestre
# formations classiques: code semestre
dec_sem = self.validations.decisions_jury.get(etudid)
jury_code_sem = dec_sem["code"] if dec_sem else ""
idx = add_cell(
@ -719,17 +733,22 @@ class ResultatsSemestre(ResultatsCache):
f"""<a href="{url_for('notes.formsemestre_validation_etud_form',
scodoc_dept=g.scodoc_dept, formsemestre_id=self.formsemestre.id, etudid=etudid
)
}">{"saisir" if not jury_code_sem else "modifier"} décision</a>""",
}">{("saisir" if not jury_code_sem else "modifier")
if self.formsemestre.etat else "voir"} décisions</a>""",
"col_jury_link",
idx,
)
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)
# tri par rang croissant
rows.sort(key=lambda e: e["_rang_order"])
if not self.formsemestre.block_moyenne_generale:
rows.sort(key=lambda e: e["_rang_order"])
else:
rows.sort(key=lambda e: e["_ues_validables_order"], reverse=True)
# INFOS POUR FOOTER
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():
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...
footer_rows = []
for (bottom_line, row) in bottom_infos.items():
@ -769,7 +802,7 @@ class ResultatsSemestre(ResultatsCache):
return (rows, footer_rows, titles, column_ids)
def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict:
"""Les informations à mettre en bas de la table: min, max, moy, ECTS"""
"""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 = (
{"_tr_class": "bottom_info", "_title": "Min."},
{"_tr_class": "bottom_info"},
@ -829,7 +862,7 @@ class ResultatsSemestre(ResultatsCache):
row_moy[f"_{colid}_class"] = "col_empty"
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,
"max": row_max,
"moy": row_moy,
@ -877,7 +910,7 @@ class ResultatsSemestre(ResultatsCache):
}
first = True
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:
titles[f"_{cid}_class"] = "admission admission_first"
first = False
@ -896,10 +929,29 @@ class ResultatsSemestre(ResultatsCache):
else:
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
rows est une liste de dict avec une clé "etudid"
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(
self.formsemestre.id
@ -948,6 +1000,7 @@ class ResultatsSemestre(ResultatsCache):
row[rg_cid] = rang.get(row["etudid"], "")
first_partition = False
return col_order
def _recap_add_evaluations(
self, rows: list[dict], titles: dict, bottom_infos: dict

View File

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

View File

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

View File

@ -16,6 +16,7 @@ import flask_login
import app
from app.auth.models import User
import app.scodoc.sco_utils as scu
from app.scodoc.sco_exceptions import ScoValueError
class ZUser(object):
@ -180,19 +181,24 @@ def scodoc7func(func):
else:
arg_names = argspec.args
for arg_name in arg_names: # pour chaque arg de la fonction vue
if arg_name == "REQUEST": # ne devrait plus arriver !
# debug check, TODO remove after tests
raise ValueError("invalid REQUEST parameter !")
else:
# peut produire une KeyError s'il manque un argument attendu:
v = req_args[arg_name]
# try to convert all arguments to INTEGERS
# necessary for db ids and boolean values
try:
v = int(v)
except (ValueError, TypeError):
pass
pos_arg_values.append(v)
# peut produire une KeyError s'il manque un argument attendu:
v = req_args[arg_name]
# try to convert all arguments to INTEGERS
# necessary for db ids and boolean values
try:
v = int(v) if v else v
except (ValueError, TypeError) as exc:
if arg_name in {
"etudid",
"formation_id",
"formsemestre_id",
"module_id",
"moduleimpl_id",
"partition_id",
"ue_id",
}:
raise ScoValueError("page introuvable (id invalide)") from exc
pos_arg_values.append(v)
# current_app.logger.info("pos_arg_values=%s" % pos_arg_values)
# current_app.logger.info("req_args=%s" % req_args)
# Add keyword arguments

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -63,6 +63,7 @@ class CodesDecisionsForm(FlaskForm):
ABL = _build_code_field("ABL")
ADC = _build_code_field("ADC")
ADJ = _build_code_field("ADJ")
ADJR = _build_code_field("ADJR")
ADM = _build_code_field("ADM")
AJ = _build_code_field("AJ")
ATB = _build_code_field("ATB")

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -171,7 +171,7 @@ class AddLogoForm(FlaskForm):
class LogoForm(FlaskForm):
"""Embed both presentation of a logo (cf. template file configuration.html)
"""Embed both presentation of a logo (cf. template file configuration.j2)
and all its data and UI action (change, delete)"""
dept_key = HiddenField()
@ -434,7 +434,7 @@ def config_logos():
scu.flash_errors(form)
return render_template(
"config_logos.html",
"config_logos.j2",
scodoc_dept=None,
title="Configuration ScoDoc",
form=form,

View File

@ -5,7 +5,7 @@
#
# ScoDoc
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -54,6 +54,22 @@ class BonusConfigurationForm(FlaskForm):
class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée"
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
month_debut_annee_scolaire = SelectField(
label="Mois de début des années scolaires",
description="""Date pivot. En France métropolitaine, août.
S'applique à tous les départements.""",
choices=[
(i, name.capitalize()) for (i, name) in enumerate(scu.MONTH_NAMES, start=1)
],
)
month_debut_periode2 = SelectField(
label="Mois de début deuxième période de l'année",
description="""Date pivot. En France métropolitaine, décembre.
S'applique à tous les départements.""",
choices=[
(i, name.capitalize()) for (i, name) in enumerate(scu.MONTH_NAMES, start=1)
],
)
submit_scodoc = SubmitField("Valider")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -67,7 +83,11 @@ def configuration():
}
)
form_scodoc = ScoDocConfigurationForm(
data={"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled()}
data={
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
}
)
if request.method == "POST" and (
form_bonus.cancel_bonus.data or form_scodoc.cancel_scodoc.data
@ -94,10 +114,26 @@ def configuration():
"Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
)
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
int(form_scodoc.data["month_debut_annee_scolaire"])
):
flash(
f"""Début des années scolaires fixé au mois de {
scu.MONTH_NAMES[ScoDocSiteConfig.get_month_debut_annee_scolaire()-1]
}"""
)
if ScoDocSiteConfig.set_month_debut_periode2(
int(form_scodoc.data["month_debut_periode2"])
):
flash(
f"""Début des années scolaires fixé au mois de {
scu.MONTH_NAMES[ScoDocSiteConfig.get_month_debut_periode2()-1]
}"""
)
return redirect(url_for("scodoc.index"))
return render_template(
"configuration.html",
"configuration.j2",
form_bonus=form_bonus,
form_scodoc=form_scodoc,
scu=scu,

View File

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

View File

@ -36,7 +36,7 @@ from app.models.etudiants import (
from app.models.events import Scolog, ScolarNews
from app.models.formations import Formation, Matiere
from app.models.modules import Module, ModuleUECoef, NotesTag, notes_modules_tags
from app.models.ues import UniteEns
from app.models.ues import DispenseUE, UniteEns
from app.models.formsemestre import (
FormSemestre,
FormSemestreEtape,
@ -72,12 +72,15 @@ from app.models.validations import (
from app.models.preferences import ScoPreference
from app.models.but_refcomp import (
ApcReferentielCompetences,
ApcCompetence,
ApcSituationPro,
ApcAppCritique,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcReferentielCompetences,
ApcSituationPro,
)
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.config import ScoDocSiteConfig
from app.models.assiduites import Assiduite, Justificatif

View File

@ -15,8 +15,10 @@ class Absence(db.Model):
db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True
)
jour = db.Column(db.Date)
# absent / justifié / absent+ justifié
estabs = db.Column(db.Boolean())
estjust = db.Column(db.Boolean())
matin = db.Column(db.Boolean())
# motif de l'absence:
description = db.Column(db.Text())

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
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 models : Référentiel Compétence BUT 2021
@ -14,7 +14,7 @@ import sqlalchemy
from app import db
from app.scodoc.sco_utils import ModuleType
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences
# from https://stackoverflow.com/questions/2537471/method-of-iterating-over-sqlalchemy-models-defined-columns
@ -54,13 +54,15 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"Référentiel de compétence d'une spécialité"
id = db.Column(db.Integer, primary_key=True)
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
annexe = db.Column(db.Text())
specialite = db.Column(db.Text())
specialite_long = db.Column(db.Text())
type_titre = db.Column(db.Text())
type_structure = db.Column(db.Text())
annexe = db.Column(db.Text()) # '1', '22', ...
specialite = db.Column(db.Text()) # 'CJ', 'RT', 'INFO', ...
specialite_long = db.Column(
db.Text()
) # 'Carrière Juridique', 'Réseaux et télécommunications', ...
type_titre = db.Column(db.Text()) # 'B.U.T.'
type_structure = db.Column(db.Text()) # 'type1', 'type2', ...
type_departement = db.Column(db.Text()) # "secondaire", "tertiaire"
version_orebut = db.Column(db.Text())
version_orebut = db.Column(db.Text()) # '2021-12-11 00:00:00'
_xml_attribs = { # Orébut xml attrib : attribute
"type": "type_titre",
"version": "version_orebut",
@ -86,9 +88,16 @@ class ApcReferentielCompetences(db.Model, XMLModel):
def __repr__(self):
return f"<ApcReferentielCompetences {self.id} {self.specialite!r} {self.departement!r}>"
def to_dict(self):
def get_version(self) -> str:
"La version, normalement sous forme de date iso yyy-mm-dd"
if not self.version_orebut:
return ""
return self.version_orebut.split()[0]
def to_dict(self, parcours: list["ApcParcours"] = None, with_app_critiques=True):
"""Représentation complète du ref. de comp.
comme un dict.
Si parcours est une liste de parcours, restreint l'export aux parcours listés.
"""
return {
"dept_id": self.dept_id,
@ -103,29 +112,45 @@ class ApcReferentielCompetences(db.Model, XMLModel):
if self.scodoc_date_loaded
else "",
"scodoc_orig_filename": self.scodoc_orig_filename,
"competences": {x.titre: x.to_dict() for x in self.competences},
"parcours": {x.code: x.to_dict() for x in self.parcours},
"competences": {
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
for x in self.competences
},
"parcours": {
x.code: x.to_dict()
for x in (self.parcours if parcours is None else parcours)
},
}
def get_niveaux_by_parcours(self, annee) -> dict:
def get_niveaux_by_parcours(
self, annee: int, parcour: "ApcParcours" = None
) -> tuple[list["ApcParcours"], dict]:
"""
Construit la liste des niveaux de compétences pour chaque parcours
de ce référentiel.
de ce référentiel, ou seulement pour le parcours donné.
Les niveaux sont groupés par parcours, en isolant les niveaux de tronc commun.
Le tronc commun n'est pas identifié comme tel dans les référentiels Orébut:
on cherche les niveaux qui sont présents dans tous les parcours et les range sous
la clé "TC" (toujours présente mais éventuellement liste vide si pas de tronc commun).
résultat:
{
"TC" : [ ApcNiveau ],
parcour.id : [ ApcNiveau ]
}
Résultat: couple
( [ ApcParcours ],
{
"TC" : [ ApcNiveau ],
parcour.id : [ ApcNiveau ]
}
)
"""
parcours = self.parcours.order_by(ApcParcours.numero).all()
parcours_ref = self.parcours.order_by(ApcParcours.numero).all()
if parcour is None:
parcours = parcours_ref
else:
parcours = [parcour]
niveaux_by_parcours = {
parcour.id: ApcNiveau.niveaux_annee_de_parcours(parcour, annee, self)
for parcour in parcours
for parcour in parcours_ref
}
# Cherche tronc commun
if niveaux_by_parcours:
@ -154,7 +179,28 @@ class ApcReferentielCompetences(db.Model, XMLModel):
niveau for niveau in niveaux_parcours_1 if niveau.id in niveaux_ids_tc
]
niveaux_by_parcours_no_tc["TC"] = niveaux_tc
return niveaux_by_parcours_no_tc
return parcours, niveaux_by_parcours_no_tc
def get_competences_tronc_commun(self) -> list["ApcCompetence"]:
"""Liste des compétences communes à tous les parcours du référentiel."""
parcours = self.parcours.all()
if not parcours:
return []
ids = set.intersection(
*[
{competence.id for competence in parcour.query_competences()}
for parcour in parcours
]
)
return sorted(
[
competence
for competence in parcours[0].query_competences()
if competence.id in ids
],
key=lambda c: c.numero or 0,
)
class ApcCompetence(db.Model, XMLModel):
@ -197,7 +243,7 @@ class ApcCompetence(db.Model, XMLModel):
def __repr__(self):
return f"<ApcCompetence {self.id} {self.titre!r}>"
def to_dict(self):
def to_dict(self, with_app_critiques=True):
"repr dict recursive sur situations, composantes, niveaux"
return {
"id_orebut": self.id_orebut,
@ -209,7 +255,10 @@ class ApcCompetence(db.Model, XMLModel):
"composantes_essentielles": [
x.to_dict() for x in self.composantes_essentielles
],
"niveaux": {x.annee: x.to_dict() for x in self.niveaux},
"niveaux": {
x.annee: x.to_dict(with_app_critiques=with_app_critiques)
for x in self.niveaux
},
}
def to_dict_bul(self) -> dict:
@ -275,13 +324,15 @@ class ApcNiveau(db.Model, XMLModel):
return f"""<{self.__class__.__name__} {self.id} ordre={self.ordre!r} annee={
self.annee!r} {self.competence!r}>"""
def to_dict(self):
"as a dict, recursif sur les AC"
def to_dict(self, with_app_critiques=True):
"as a dict, recursif (ou non) sur les AC"
return {
"libelle": self.libelle,
"annee": self.annee,
"ordre": self.ordre,
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques},
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {},
}
def to_dict_bul(self):
@ -306,9 +357,8 @@ class ApcNiveau(db.Model, XMLModel):
if annee not in {1, 2, 3}:
raise ValueError("annee invalide pour un parcours BUT")
if referentiel_competence is None:
raise ScoValueError(
"Pas de référentiel de compétences associé à la formation !"
)
raise ScoNoReferentielCompetences()
annee_formation = f"BUT{annee}"
if parcour is None:
return ApcNiveau.query.filter(
@ -436,6 +486,7 @@ class ApcParcours(db.Model, XMLModel):
lazy="dynamic",
cascade="all, delete-orphan",
)
ues = db.relationship("UniteEns", back_populates="parcour")
def __repr__(self) -> str:
return f"<{self.__class__.__name__} {self.id} {self.code!r} ref={self.referentiel}>"
@ -453,6 +504,14 @@ class ApcParcours(db.Model, XMLModel):
d["annees"] = {x.ordre: x.to_dict() for x in self.annees}
return d
def query_competences(self) -> flask_sqlalchemy.BaseQuery:
"Les compétences associées à ce parcours"
return (
ApcCompetence.query.join(ApcParcoursNiveauCompetence, ApcAnneeParcours)
.filter_by(parcours_id=self.id)
.order_by(ApcCompetence.numero)
)
class ApcAnneeParcours(db.Model, XMLModel):
id = db.Column(db.Integer, primary_key=True)

View File

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

View File

@ -6,13 +6,14 @@
from flask import flash
from app import db, log
from app.comp import bonus_spo
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import (
ABAN,
ABL,
ADC,
ADJ,
ADJR,
ADM,
AJ,
ATB,
@ -34,6 +35,7 @@ CODES_SCODOC_TO_APO = {
ABL: "ABL",
ADC: "ADMC",
ADJ: "ADM",
ADJR: "ADM",
ADM: "ADM",
AJ: "AJ",
ATB: "AJAC",
@ -83,6 +85,8 @@ class ScoDocSiteConfig(db.Model):
"INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
"enable_entreprises": bool,
"month_debut_annee_scolaire": int,
"month_debut_periode2": int,
}
def __init__(self, name, value):
@ -223,3 +227,73 @@ class ScoDocSiteConfig(db.Model):
db.session.commit()
return True
return False
@classmethod
def _get_int_field(cls, name: str, default=None) -> int:
"""Valeur d'un champs integer"""
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if (cfg is None) or cfg.value is None:
return default
return int(cfg.value)
@classmethod
def _set_int_field(
cls,
name: str,
value: int,
default=None,
range_values: tuple = (),
) -> bool:
"""Set champs integer. True si changement."""
if value != cls._get_int_field(name, default=default):
if not isinstance(value, int) or (
range_values and (value < range_values[0]) or (value > range_values[1])
):
raise ValueError("invalid value")
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None:
cfg = ScoDocSiteConfig(name=name, value=str(value))
else:
cfg.value = str(value)
db.session.add(cfg)
db.session.commit()
return True
return False
@classmethod
def get_month_debut_annee_scolaire(cls) -> int:
"""Mois de début de l'année scolaire."""
return cls._get_int_field(
"month_debut_annee_scolaire", scu.MONTH_DEBUT_ANNEE_SCOLAIRE
)
@classmethod
def get_month_debut_periode2(cls) -> int:
"""Mois de début de l'année scolaire."""
return cls._get_int_field("month_debut_periode2", scu.MONTH_DEBUT_PERIODE2)
@classmethod
def set_month_debut_annee_scolaire(
cls, month: int = scu.MONTH_DEBUT_ANNEE_SCOLAIRE
) -> bool:
"""Fixe le mois de début des années scolaires.
True si changement.
"""
if cls._set_int_field(
"month_debut_annee_scolaire", month, scu.MONTH_DEBUT_ANNEE_SCOLAIRE, (1, 12)
):
log(f"set_month_debut_annee_scolaire({month})")
return True
return False
@classmethod
def set_month_debut_periode2(cls, month: int = scu.MONTH_DEBUT_PERIODE2) -> bool:
"""Fixe le mois de début des années scolaires.
True si changement.
"""
if cls._set_int_field(
"month_debut_periode2", month, scu.MONTH_DEBUT_PERIODE2, (1, 12)
):
log(f"set_month_debut_periode2({month})")
return True
return False

View File

@ -2,10 +2,10 @@
"""ScoDoc models : departements
"""
from typing import Any
from app import db
from app.models import SHORT_STR_LEN
from app.models.preferences import ScoPreference
from app.scodoc.sco_exceptions import ScoValueError
@ -39,7 +39,7 @@ class Departement(db.Model):
def __repr__(self):
return f"<{self.__class__.__name__}(id={self.id}, acronym='{self.acronym}')>"
def to_dict(self):
def to_dict(self, with_dept_name=True, with_dept_preferences=False):
data = {
"id": self.id,
"acronym": self.acronym,
@ -47,6 +47,17 @@ class Departement(db.Model):
"visible": self.visible,
"date_creation": self.date_creation,
}
if with_dept_name:
pref = ScoPreference.query.filter_by(
dept_id=self.id, name="DeptName"
).first()
data["dept_name"] = pref.value if pref else None
# Ceci n'est pas encore utilisé, mais pourrait être publié
# par l'API après nettoyage des préférences.
if with_dept_preferences:
data["preferences"] = {
p.name: p.value for p in ScoPreference.query.filter_by(dept_id=self.id)
}
return data
@classmethod

View File

@ -58,6 +58,16 @@ class Identite(db.Model):
billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
#
admission = db.relationship("Admission", backref="identite", lazy="dynamic")
dispense_ues = db.relationship(
"DispenseUE",
back_populates="etud",
cascade="all, delete",
passive_deletes=True,
)
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship("Assiduite", backref="etudiant", lazy="dynamic")
justificatifs = db.relationship("Justificatif", backref="etudiant", lazy="dynamic")
def __repr__(self):
return (
@ -73,6 +83,14 @@ class Identite(db.Model):
args = make_etud_args(etudid=etudid, code_nip=code_nip)
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
def civilite_str(self):
"""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
import app.scodoc.notesdb as ndb
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
class Evaluation(db.Model):
"""Evaluation (contrôle, examen, ...)"""
@ -51,7 +53,7 @@ class Evaluation(db.Model):
self.description[:16] if self.description else ''}">"""
def to_dict(self) -> dict:
"Représentation dict, pour json"
"Représentation dict (riche, compat ScoDoc 7)"
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
# ScoDoc7 output_formators
@ -71,6 +73,34 @@ class Evaluation(db.Model):
e["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
return evaluation_enrich_dict(e)
def to_dict_api(self) -> dict:
"Représentation dict pour API JSON"
if self.jour is None:
date_debut = None
date_fin = None
else:
date_debut = datetime.datetime.combine(
self.jour, self.heure_debut or datetime.time(0, 0)
).isoformat()
date_fin = datetime.datetime.combine(
self.jour, self.heure_fin or datetime.time(0, 0)
).isoformat()
return {
"coefficient": self.coefficient,
"date_debut": date_debut,
"date_fin": date_fin,
"description": self.description,
"evaluation_type": self.evaluation_type,
"id": self.id,
"moduleimpl_id": self.moduleimpl_id,
"note_max": self.note_max,
"numero": self.numero,
"poids": self.get_ue_poids_dict(),
"publish_incomplete": self.publish_incomplete,
"visi_bulletin": self.visibulletin,
}
def from_dict(self, data):
"""Set evaluation attributes from given dict values."""
check_evaluation_args(data)
@ -83,12 +113,24 @@ class Evaluation(db.Model):
if self.heure_debut and (
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:
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:
return ""
def descr_duree(self) -> str:
"Description de la durée pour affichages"
if self.heure_debut is None and self.heure_fin is None:
return ""
debut = self.heure_debut or DEFAULT_EVALUATION_TIME
fin = self.heure_fin or DEFAULT_EVALUATION_TIME
d = (fin.hour * 60 + fin.minute) - (debut.hour * 60 + debut.minute)
duree = f"{d//60}h"
if d % 60:
duree += f"{d%60:02d}"
return duree
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain commit
@ -227,7 +269,7 @@ def evaluation_enrich_dict(e: dict):
heure_fin_dt = e["heure_fin"] or datetime.time(8, 00)
e["heure_debut"] = ndb.TimefromISO8601(e["heure_debut"])
e["heure_fin"] = ndb.TimefromISO8601(e["heure_fin"])
e["jouriso"] = ndb.DateDMYtoISO(e["jour"])
e["jour_iso"] = ndb.DateDMYtoISO(e["jour"])
heure_debut, heure_fin = e["heure_debut"], e["heure_fin"]
d = ndb.TimeDuration(heure_debut, heure_fin)
if d is not None:

View File

@ -36,6 +36,7 @@ class Formation(db.Model):
titre = db.Column(db.Text(), nullable=False)
titre_officiel = db.Column(db.Text(), nullable=False)
version = db.Column(db.Integer, default=1, server_default="1")
commentaire = db.Column(db.Text())
formation_code = db.Column(
db.String(SHORT_STR_LEN),
server_default=db.text("notes_newid_fcod()"),
@ -55,18 +56,21 @@ class Formation(db.Model):
modules = db.relationship("Module", lazy="dynamic", backref="formation")
def __repr__(self):
return f"<{self.__class__.__name__}(id={self.id}, dept_id={self.dept_id}, acronyme='{self.acronyme!r}')>"
return f"""<{self.__class__.__name__}(id={self.id}, dept_id={
self.dept_id}, acronyme={self.acronyme!r}, version={self.version})>"""
def to_html(self) -> str:
"titre complet pour affichage"
return f"""Formation {self.titre} ({self.acronyme}) [version {self.version}] code {self.formation_code}"""
def to_dict(self, with_refcomp_attrs=False):
""" "as a dict.
"""As a dict.
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.
"""
e = dict(self.__dict__)
e.pop("_sa_instance_state", None)
if "referentiel_competence" in e:
e.pop("referentiel_competence")
e["departement"] = self.departement.to_dict()
e["formation_id"] = self.id # ScoDoc7 backward compat
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:
"""Les UEs d'un parcours de la formation.
Si parcour est None, les UE sans parcours.
Exemple: pour avoir les UE du semestre 3, faire
`formation.query_ues_parcour(parcour).filter_by(semestre_idx=3)`
"""
return UniteEns.query.filter_by(formation=self).filter(
if parcour is None:
return UniteEns.query.filter_by(
formation=self, type=UE_STANDARD, parcour_id=None
)
return UniteEns.query.filter_by(formation=self, type=UE_STANDARD).filter(
UniteEns.niveau_competence_id == ApcNiveau.id,
UniteEns.type == UE_STANDARD,
(UniteEns.parcour_id == parcour.id) | (UniteEns.parcour_id == None),
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcAnneeParcours.parcours_id == parcour.id,
@ -233,6 +242,21 @@ class Formation(db.Model):
.filter(ApcAnneeParcours.parcours_id == parcour.id)
)
def refcomp_desassoc(self):
"""Désassocie la formation de son ref. de compétence"""
self.referentiel_competence = None
db.session.add(self)
# Niveaux des UE
for ue in self.ues:
ue.niveau_competence = None
db.session.add(ue)
# Parcours et AC des modules
for mod in self.modules:
mod.parcours = []
mod.app_critiques = []
db.session.add(mod)
db.session.commit()
class Matiere(db.Model):
"""Matières: regroupe les modules d'une UE

View File

@ -1,48 +1,47 @@
# -*- coding: UTF-8 -*
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
# pylint génère trop de faux positifs avec les colonnes date:
# pylint: disable=no-member,not-an-iterable
"""ScoDoc models: formsemestre
"""
import datetime
from functools import cached_property
from flask import flash, g
import flask_sqlalchemy
from flask import flash, g
from sqlalchemy import and_, or_
from sqlalchemy.sql import text
from app import db
from app import log
from app.models import APO_CODE_STR_LEN
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
import app.scodoc.sco_utils as scu
from app import db, log
from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
parcours_formsemestre,
)
from app.models.groups import GroupDescr, Partition
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.config import ScoDocSiteConfig
from app.models.etudiants import Identite
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.modules import Module
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import sco_codes_parcours
from app.scodoc import sco_preferences
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc import sco_codes_parcours, sco_preferences
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_vdi import ApoEtapeVDI
class FormSemestre(db.Model):
@ -57,51 +56,58 @@ class FormSemestre(db.Model):
dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
titre = db.Column(db.Text())
date_debut = db.Column(db.Date())
date_fin = db.Column(db.Date())
etat = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true"
) # False si verrouillé
titre = db.Column(db.Text(), nullable=False)
date_debut = db.Column(db.Date(), nullable=False)
date_fin = db.Column(db.Date(), nullable=False)
etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
"False si verrouillé"
modalite = db.Column(
db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
) # "FI", "FAP", "FC", ...
# gestion compensation sem DUT:
)
"Modalité de formation: 'FI', 'FAP', 'FC', ..."
gestion_compensation = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# ne publie pas le bulletin XML ou JSON:
"gestion compensation sem DUT (inutilisé en APC)"
bul_hide_xml = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# Bloque le calcul des moyennes (générale et d'UE)
"ne publie pas le bulletin XML ou JSON"
block_moyennes = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# semestres decales (pour gestion jurys):
"Bloque le calcul des moyennes (générale et d'UE)"
block_moyenne_generale = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# couleur fond bulletins HTML:
"Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)"
bul_bgcolor = db.Column(
db.String(SHORT_STR_LEN), default="white", server_default="white"
db.String(SHORT_STR_LEN),
default="white",
server_default="white",
nullable=False,
)
# autorise resp. a modifier semestre:
"couleur fond bulletins HTML"
resp_can_edit = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
# autorise resp. a modifier slt les enseignants:
"autorise resp. à modifier le formsemestre"
resp_can_change_ens = db.Column(
db.Boolean(), nullable=False, default=True, server_default="true"
)
# autorise les ens a creer des evals:
"autorise resp. a modifier slt les enseignants"
ens_can_edit_eval = db.Column(
db.Boolean(), nullable=False, default=False, server_default="False"
)
# code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'
"autorise les enseignants à créer des évals dans leurs modimpls"
elt_sem_apo = db.Column(db.Text()) # peut être fort long !
# code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'
"code element semestre Apogee, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
elt_annee_apo = db.Column(db.Text())
"code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
# Relations:
etapes = db.relationship(
@ -111,6 +117,7 @@ class FormSemestre(db.Model):
"ModuleImpl",
backref="formsemestre",
lazy="dynamic",
cascade="all, delete-orphan",
)
etuds = db.relationship(
"Identite",
@ -148,7 +155,12 @@ class FormSemestre(db.Model):
self.modalite = FormationModalite.DEFAULT_MODALITE
def __repr__(self):
return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>"
return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
def sort_key(self) -> tuple:
"""clé pour tris par ordre alphabétique
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
return (self.date_debut, self.semestre_id)
def to_dict(self, convert_objects=False) -> dict:
"""dict (compatible ScoDoc7).
@ -173,7 +185,7 @@ class FormSemestre(db.Model):
d["responsables"] = [u.id for u in self.responsables]
d["titre_formation"] = self.titre_formation()
if convert_objects:
d["parcours"] = [p.to_dict() for p in self.parcours]
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
d["departement"] = self.departement.to_dict()
d["formation"] = self.formation.to_dict()
d["etape_apo"] = self.etapes_apo_str()
@ -200,9 +212,10 @@ class FormSemestre(db.Model):
d["etape_apo"] = self.etapes_apo_str()
d["formsemestre_id"] = self.id
d["formation"] = self.formation.to_dict()
d["parcours"] = [p.to_dict() for p in self.parcours]
d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
d["responsables"] = [u.id for u in self.responsables]
d["titre_court"] = self.formation.acronyme
d["titre_formation"] = self.titre_formation()
d["titre_num"] = self.titre_num()
d["session_id"] = self.session_id()
return d
@ -222,7 +235,8 @@ class FormSemestre(db.Model):
d["mois_debut_ord"] = self.date_debut.month
d["mois_fin_ord"] = self.date_fin.month
# La période: considère comme "S1" (ou S3) les débuts en aout-sept-octobre
# devrait sans doute pouvoir etre changé...
# devrait sans doute pouvoir etre changé... XXX PIVOT
d["periode"] = self.periode()
if self.date_debut.month >= 8 and self.date_debut.month <= 10:
d["periode"] = 1 # typiquement, début en septembre: S1, S3...
else:
@ -241,17 +255,36 @@ class FormSemestre(db.Model):
d["etapes_apo_str"] = self.etapes_apo_str()
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:
"""UE des modules de ce semestre, triées par numéro.
- Formations classiques: les UEs auxquelles appartiennent
les modules mis en place dans ce semestre.
- Formations APC / BUT: les UEs de la formation qui ont
le même numéro de semestre que ce formsemestre.
- Formations APC / BUT: les UEs de la formation qui
- ont le même numéro de semestre que ce formsemestre
- sont associées à l'un des parcours de ce formsemestre (ou à aucun)
"""
if self.formation.get_parcours().APC_SAE:
sem_ues = UniteEns.query.filter_by(
formation=self.formation, semestre_idx=self.semestre_id
)
if self.parcours:
# Prend toutes les UE de l'un des parcours du sem., ou déclarées sans parcours
sem_ues = sem_ues.filter(
(UniteEns.parcour == None)
| (UniteEns.parcour_id.in_([p.id for p in self.parcours]))
)
# si le sem. ne coche aucun parcours, prend toutes les UE
else:
sem_ues = db.session.query(UniteEns).filter(
ModuleImpl.formsemestre_id == self.id,
@ -263,8 +296,11 @@ class FormSemestre(db.Model):
return sem_ues.order_by(UniteEns.numero)
def query_ues_parcours_etud(self, etudid: int) -> flask_sqlalchemy.BaseQuery:
"""UE que suit l'étudiant dans ce semestre BUT
"""XXX inutilisé à part pour un test unitaire => supprimer ?
UEs que suit l'étudiant dans ce semestre BUT
en fonction du parcours dans lequel il est inscrit.
Si l'étudiant n'est inscrit à aucun parcours,
renvoie uniquement les UEs de tronc commun (sans parcours).
Si voulez les UE d'un parcours, il est plus efficace de passer par
`formation.query_ues_parcour(parcour)`.
@ -275,7 +311,13 @@ class FormSemestre(db.Model):
UniteEns.niveau_competence_id == ApcNiveau.id,
ApcParcoursNiveauCompetence.competence_id == ApcNiveau.competence_id,
ApcParcoursNiveauCompetence.annee_parcours_id == ApcAnneeParcours.id,
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
or_(
ApcAnneeParcours.parcours_id == FormSemestreInscription.parcour_id,
and_(
FormSemestreInscription.parcour_id.is_(None),
UniteEns.parcour_id.is_(None),
),
),
)
@cached_property
@ -288,7 +330,7 @@ class FormSemestre(db.Model):
if self.formation.is_apc():
modimpls.sort(
key=lambda m: (
m.module.module_type or 0,
m.module.module_type or 0, # ressources (2) avant SAEs (3)
m.module.numero or 0,
m.module.code or 0,
)
@ -327,7 +369,7 @@ class FormSemestre(db.Model):
return [ModuleImpl.query.get(modimpl_id) for modimpl_id in cursor]
def can_be_edited_by(self, user):
"""Vrai si user peut modifier ce semestre"""
"""Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
if not user.has_permission(Permission.ScoImplement): # pas chef
if not self.resp_can_edit or user.id not in [
resp.id for resp in self.responsables
@ -341,7 +383,7 @@ class FormSemestre(db.Model):
(les dates de début et fin sont incluses)
"""
today = datetime.date.today()
return (self.date_debut <= today) and (today <= self.date_fin)
return self.date_debut <= today <= self.date_fin
def contient_periode(self, date_debut, date_fin) -> bool:
"""Vrai si l'intervalle [date_debut, date_fin] est
@ -354,29 +396,99 @@ class FormSemestre(db.Model):
"""Test si sem est entièrement sur la même année scolaire.
(ce n'est pas obligatoire mais si ce n'est pas le
cas les exports Apogée risquent de mal fonctionner)
Pivot au 1er août.
Pivot au 1er août par défaut.
"""
if self.date_debut > self.date_fin:
flash(f"Dates début/fin inversées pour le semestre {self.titre_annee()}")
log(f"Warning: semestre {self.id} begins after ending !")
annee_debut = self.date_debut.year
if self.date_debut.month < 8: # août
# considere que debut sur l'anne scolaire precedente
month_debut_annee = ScoDocSiteConfig.get_month_debut_annee_scolaire()
if self.date_debut.month < month_debut_annee:
# début sur l'année scolaire précédente (juillet inclus par défaut)
annee_debut -= 1
annee_fin = self.date_fin.year
if self.date_fin.month < 9:
# 9 (sept) pour autoriser un début en sept et une fin en aout
if self.date_fin.month < (month_debut_annee + 1):
# 9 (sept) pour autoriser un début en sept et une fin en août
annee_fin -= 1
return annee_debut == annee_fin
def est_decale(self):
"""Vrai si semestre "décalé"
c'est à dire semestres impairs commençant entre janvier et juin
et les pairs entre juillet et decembre
c'est à dire semestres impairs commençant (par défaut)
entre janvier et juin et les pairs entre juillet et décembre.
"""
if self.semestre_id <= 0:
return False # formations sans semestres
return (self.semestre_id % 2 and self.date_debut.month <= 6) or (
not self.semestre_id % 2 and self.date_debut.month > 6
return (
# impair
(
self.semestre_id % 2
and self.date_debut.month < scu.MONTH_DEBUT_ANNEE_SCOLAIRE
)
or
# pair
(
(not self.semestre_id % 2)
and self.date_debut.month >= scu.MONTH_DEBUT_ANNEE_SCOLAIRE
)
)
@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]:
@ -429,7 +541,7 @@ class FormSemestre(db.Model):
def annee_scolaire(self) -> int:
"""L'année de début de l'année scolaire.
Par exemple, 2022 si le semestre va de septebre 2022 à février 2023."""
Par exemple, 2022 si le semestre va de septembre 2022 à février 2023."""
return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
def annee_scolaire_str(self):
@ -479,7 +591,9 @@ class FormSemestre(db.Model):
)
def titre_annee(self) -> str:
""" """
"""Le titre avec l'année
'DUT Réseaux et Télécommunications semestre 3 FAP 2020-2021'
"""
titre_annee = (
f"{self.titre_num()} {self.modalite or ''} {self.date_debut.year}"
)
@ -585,14 +699,43 @@ class FormSemestre(db.Model):
db.session.add(partition)
db.session.flush() # pour avoir un id
flash("Partition Parcours créée.")
elif partition.groups_editable:
# Il ne faut jamais laisser éditer cette partition de parcours
partition.groups_editable = False
db.session.add(partition)
for parcour in self.parcours:
for parcour in self.get_parcours_apc():
if parcour.code:
group = GroupDescr.query.filter_by(
partition_id=partition.id, group_name=parcour.code
).first()
if not group:
partition.groups.append(GroupDescr(group_name=parcour.code))
db.session.flush()
# S'il reste des groupes de parcours qui ne sont plus dans le semestre
# - s'ils n'ont pas d'inscrits, supprime-les.
# - s'ils ont des inscrits: avertissement
for group in GroupDescr.query.filter_by(partition_id=partition.id):
if group.group_name not in (p.code for p in self.get_parcours_apc()):
if (
len(
[
inscr
for inscr in self.inscriptions
if (inscr.parcour is not None)
and inscr.parcour.code == group.group_name
]
)
== 0
):
flash(f"Suppression du groupe de parcours vide {group.group_name}")
db.session.delete(group)
else:
flash(
f"""Attention: groupe de parcours {group.group_name} non vide:
réaffectez ses étudiants dans des parcours du semestre"""
)
db.session.commit()
def update_inscriptions_parcours_from_groups(self) -> None:
@ -653,7 +796,7 @@ class FormSemestre(db.Model):
def etud_validations_description_html(self, etudid: int) -> str:
"""Description textuelle des validations de jury de cet étudiant dans ce semestre"""
from app.models.but_validations import ApcValidationRCUE, ApcValidationAnnee
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
vals_sem = ScolarFormSemestreValidation.query.filter_by(
etudid=etudid, formsemestre_id=self.id, ue_id=None
@ -914,8 +1057,8 @@ class NotesSemSet(db.Model):
title = db.Column(db.Text)
annee_scolaire = db.Column(db.Integer, nullable=True, default=None)
# periode: 0 (année), 1 (Simpair), 2 (Spair)
sem_id = db.Column(db.Integer, nullable=True, default=None)
sem_id = db.Column(db.Integer, nullable=False, default=0)
"période: 0 (année), 1 (Simpair), 2 (Spair)"
# Association: many to many

View File

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

View File

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

View File

@ -3,7 +3,7 @@
from app import db
from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import app_critiques_modules, parcours_modules
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
from app.scodoc import sco_utils as scu
from app.scodoc.sco_codes_parcours import UE_SPORT
from app.scodoc.sco_utils import ModuleType
@ -37,7 +37,9 @@ class Module(db.Model):
# Type: ModuleType.STANDARD, MALUS, RESSOURCE, SAE (enum)
module_type = db.Column(db.Integer, nullable=False, default=0, server_default="0")
# Relations:
modimpls = db.relationship("ModuleImpl", backref="module", lazy="dynamic")
modimpls = db.relationship(
"ModuleImpl", backref="module", lazy="dynamic", cascade="all, delete-orphan"
)
ues_apc = db.relationship("UniteEns", secondary="module_ue_coef", viewonly=True)
tags = db.relationship(
"NotesTag",
@ -66,7 +68,39 @@ class Module(db.Model):
super(Module, self).__init__(**kwargs)
def __repr__(self):
return f"<Module{ModuleType(self.module_type or ModuleType.STANDARD).name} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
def clone(self):
"""Create a new copy of this module."""
mod = Module(
titre=self.titre,
abbrev=self.abbrev,
code=self.code + "-copie",
heures_cours=self.heures_cours,
heures_td=self.heures_td,
heures_tp=self.heures_tp,
coefficient=self.coefficient,
ects=self.ects,
ue_id=self.ue_id,
matiere_id=self.matiere_id,
formation_id=self.formation_id,
semestre_id=self.semestre_id,
numero=self.numero, # il est conseillé de renuméroter
code_apogee="", # volontairement vide pour éviter les erreurs
module_type=self.module_type,
)
# Les tags:
for tag in self.tags:
mod.tags.append(tag)
# Les parcours
for parcour in self.parcours:
mod.parcours.append(parcour)
# Les AC
for app_critique in self.app_critiques:
mod.app_critiques.append(app_critique)
return mod
def to_dict(self, convert_objects=False, with_matiere=False, with_ue=False) -> dict:
"""If convert_objects, convert all attributes to native types
@ -188,25 +222,31 @@ class Module(db.Model):
# à redéfinir les relationships...
return sorted(self.ue_coefs, key=lambda x: x.ue.numero)
def ue_coefs_list(self, include_zeros=True):
def ue_coefs_list(
self, include_zeros=True, ues: list["UniteEns"] = None
) -> list[tuple["UniteEns", float]]:
"""Liste des coefs vers les UE (pour les modules APC).
Si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre,
Si ues est spécifié, restreint aux UE indiquées.
Sinon si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre,
sauf UE bonus sport.
Result: List of tuples [ (ue, coef) ]
"""
if not self.is_apc():
return []
if include_zeros:
if include_zeros and ues is None:
# Toutes les UE du même semestre:
ues_semestre = (
ues = (
self.formation.ues.filter_by(semestre_idx=self.ue.semestre_idx)
.filter(UniteEns.type != UE_SPORT)
.order_by(UniteEns.numero)
.all()
)
if not ues:
return []
if ues:
coefs_dict = self.get_ue_coef_dict()
coefs_list = []
for ue in ues_semestre:
for ue in ues:
coefs_list.append((ue, coefs_dict.get(ue.id, 0.0)))
return coefs_list
# Liste seulement les coefs définis:
@ -218,6 +258,19 @@ class Module(db.Model):
return {x.strip() for x in self.code_apogee.split(",") if x}
return set()
def get_parcours(self) -> list[ApcParcours]:
"""Les parcours utilisant ce module.
Si tous les parcours, liste vide (!).
"""
ref_comp = self.formation.referentiel_competence
if not ref_comp:
return []
tous_parcours_ids = {p.id for p in ref_comp.parcours}
parcours_ids = {p.id for p in self.parcours}
if tous_parcours_ids == parcours_ids:
return []
return self.parcours
class ModuleUECoef(db.Model):
"""Coefficients des modules vers les UE (APC, BUT)

View File

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

View File

@ -4,6 +4,7 @@
"""
from app import db
from app import log
from app.models import SHORT_STR_LEN
from app.models import CODE_STR_LEN
from app.models.events import Scolog
@ -58,7 +59,7 @@ class ScolarFormSemestreValidation(db.Model):
)
def __repr__(self):
return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
return f"{self.__class__.__name__}(sem={self.formsemestre_id}, etuid={self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})"
def __str__(self):
if self.ue_id:
@ -93,6 +94,10 @@ class ScolarAutorisationInscription(db.Model):
db.ForeignKey("notes_formsemestre.id"),
)
def __repr__(self) -> str:
return f"""{self.__class__.__name__}(id={self.id}, etudid={
self.etudid}, semestre_id={self.semestre_id})"""
def to_dict(self) -> dict:
"as a dict"
d = dict(self.__dict__)
@ -116,7 +121,10 @@ class ScolarAutorisationInscription(db.Model):
semestre_id=semestre_id,
)
db.session.add(autorisation)
Scolog.logdb("autorise_etud", etudid=etudid, msg=f"passage vers S{semestre_id}")
Scolog.logdb(
"autorise_etud", etudid=etudid, msg=f"Passage vers S{semestre_id}: autorisé"
)
log(f"ScolarAutorisationInscription: recording {autorisation}")
@classmethod
def delete_autorisation_etud(
@ -130,10 +138,11 @@ class ScolarAutorisationInscription(db.Model):
)
for autorisation in autorisations:
db.session.delete(autorisation)
log(f"ScolarAutorisationInscription: deleting {autorisation}")
Scolog.logdb(
"autorise_etud",
etudid=etudid,
msg=f"annule passage vers S{autorisation.semestre_id}",
msg=f"Passage vers S{autorisation.semestre_id}: effacé",
)
db.session.flush()

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -459,8 +459,7 @@ class JuryPE(object):
etud = self.get_cache_etudInfo_d_un_etudiant(etudid)
(_, parcours) = sco_report.get_codeparcoursetud(etud)
if (
len(set(sco_codes_parcours.CODES_SEM_REO.keys()) & set(parcours.values()))
> 0
len(sco_codes_parcours.CODES_SEM_REO & set(parcours.values())) > 0
): # Eliminé car NAR apparait dans le parcours
reponse = True
if pe_tools.PE_DEBUG and pe_tools.PE_DEBUG >= 2:
@ -563,9 +562,8 @@ class JuryPE(object):
dec = nt.get_etud_decision_sem(
etudid
) # quelle est la décision du jury ?
if dec and dec["code"] in list(
sco_codes_parcours.CODES_SEM_VALIDES.keys()
): # isinstance( sesMoyennes[i+1], float) and
if dec and (dec["code"] in sco_codes_parcours.CODES_SEM_VALIDES):
# isinstance( sesMoyennes[i+1], float) and
# mT = sesMoyennes[i+1] # substitue la moyenne si le semestre suivant est "valide"
leFid = sem["formsemestre_id"]
else:

View File

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

View File

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

View File

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

View File

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

View File

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

43
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
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -274,7 +274,7 @@ def sco_header(
H.append("""<div id="gtrcontent">""")
# En attendant le replacement complet de cette fonction,
# inclusion ici des messages flask
H.append(render_template("flashed_messages.html"))
H.append(render_template("flashed_messages.j2"))
#
# Barre menu semestre:
H.append(formsemestre_page_title(formsemestre_id))

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -101,7 +101,6 @@ def sidebar(etudid: int = None):
etudid = request.form.get("etudid", None)
if etudid is not None:
etudi = int(etudid)
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
params.update(etud)
params["fiche_url"] = url_for(
@ -167,6 +166,6 @@ def sidebar(etudid: int = None):
def sidebar_dept():
"""Partie supérieure de la marge de gauche"""
return render_template(
"sidebar_dept.html",
"sidebar_dept.j2",
prefs=sco_preferences.SemPreferences(),
)

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -42,6 +42,8 @@ from app.scodoc import sco_cache
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre_inscriptions
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
# --- Misc tools.... ------------------
@ -1052,6 +1054,36 @@ def get_abs_count_in_interval(etudid, date_debut_iso, date_fin_iso):
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):
"""Invalidate (clear) cached counts"""
date_debut = sem["date_debut_iso"]

View File

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

View File

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

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -685,7 +685,7 @@ def EtatAbsences():
</td></tr></table>
</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(),
]
return "\n".join(H)
@ -719,15 +719,27 @@ def formChoixSemestreGroupe(all=False):
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):
"""Calendrier des absences d'un etudiant"""
# crude portage from 1999 DTML
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
etudid = etud["etudid"]
anneescolaire = int(scu.AnneeScolaire(sco_year))
datedebut = str(anneescolaire) + "-08-01"
datefin = str(anneescolaire + 1) + "-07-31"
annee_courante = scu.AnneeScolaire()
if sco_year:
annee_scolaire = _convert_sco_year(sco_year)
else:
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)
nbabsjust = sco_abs.count_abs_just(etudid=etudid, debut=datedebut, fin=datefin)
events = []
@ -746,7 +758,7 @@ def CalAbs(etudid, sco_year=None):
events.append(
(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 = [
@ -777,12 +789,12 @@ def CalAbs(etudid, sco_year=None):
CalHTML,
"""<form method="GET" action="CalAbs" name="f">""",
"""<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()">""",
]
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)
if y == anneescolaire:
if y == annee_scolaire:
H.append("selected")
H.append(""">%s</option>""" % y)
H.append("""</select></form>""")
@ -811,7 +823,11 @@ def ListeAbsEtud(
"""
# si absjust_only, table absjust seule (export xls ou pdf)
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
etuds = sco_etud.get_etud_info(etudid=etudid, code_nip=code_nip, filled=True)
if not etuds:

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -43,6 +43,7 @@ Pour chaque étudiant commun:
comparer les résultats
"""
from flask import g, url_for
from app import log
from app.scodoc import sco_apogee_csv
@ -72,11 +73,11 @@ def apo_compare_csv_form():
"""
<div class="apo_compare_csv_form_but">
Fichier Apogée A:
<input type="file" size="30" name="A_file"/>
<input type="file" size="30" name="file_a"/>
</div>
<div class="apo_compare_csv_form_but">
Fichier Apogée B:
<input type="file" size="30" name="B_file"/>
<input type="file" size="30" name="file_b"/>
</div>
<input type="checkbox" name="autodetect" checked/>autodétecter encodage</input>
<div class="apo_compare_csv_form_submit">
@ -88,17 +89,36 @@ def apo_compare_csv_form():
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"""
A = _load_apo_data(A_file, autodetect=autodetect)
B = _load_apo_data(B_file, autodetect=autodetect)
try:
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 = [
html_sco_header.sco_header(page_title="Comparaison de fichiers Apogée"),
"<h2>Comparaison de fichiers Apogée</h2>",
_help_txt,
'<div class="apo_compare_csv">',
_apo_compare_csv(A, B),
_apo_compare_csv(apo_data_a, apo_data_b),
"</div>",
"""<p><a href="apo_compare_csv_form" class="stdlink">Autre comparaison</a></p>""",
html_sco_header.sco_footer(),
@ -112,9 +132,9 @@ def _load_apo_data(csvfile, autodetect=True):
if autodetect:
data_b, message = sco_apogee_csv.fix_data_encoding(data_b)
if message:
log("apo_compare_csv: %s" % message)
log(f"apo_compare_csv: {message}")
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)
apo_data = sco_apogee_csv.ApoData(data, orig_filename=csvfile.filename)
return apo_data

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -155,28 +155,25 @@ def fix_data_encoding(
text: bytes,
default_source_encoding=APO_INPUT_ENCODING,
dest_encoding=APO_INPUT_ENCODING,
) -> bytes:
) -> tuple[bytes, str]:
"""Try to ensure that text is using dest_encoding
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 = ""
detected_encoding = guess_data_encoding(text)
if not detected_encoding:
if default_source_encoding != dest_encoding:
message = "converting from %s to %s" % (
default_source_encoding,
dest_encoding,
)
text = text.decode(default_source_encoding).encode(
dest_encoding
) # XXX #py3 #sco8 à tester
message = f"converting from {default_source_encoding} to {dest_encoding}"
text = text.decode(default_source_encoding).encode(dest_encoding)
else:
if detected_encoding != dest_encoding:
message = "converting from detected %s to %s" % (
detected_encoding,
dest_encoding,
message = (
f"converting from detected {default_source_encoding} to {dest_encoding}"
)
text = text.decode(detected_encoding).encode(dest_encoding) # XXX
text = text.decode(detected_encoding).encode(dest_encoding)
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'])
if not cur_sem:
# 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
cur_formsemestre = FormSemestre.query.get_or_404(cur_sem["formsemestre_id"])
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)
and (apo_data.etape in sem["etapes"])
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
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -28,7 +28,7 @@
"""ScoDoc : gestion des archives des PV et bulletins, et des dossiers etudiants (admission)
Archives are plain files, stored in
Archives are plain files, stored in
<SCODOC_VAR_DIR>/archives/<dept_id>
(where <SCODOC_VAR_DIR> is usually /opt/scodoc-data, and <dept_id> a departement id (int))
@ -42,7 +42,7 @@
Les maquettes Apogée pour l'export des notes sont dans
<archivedir>/apo_csv/<dept_id>/<annee_scolaire>-<sem_id>/<YYYY-MM-DD-HH-MM-SS>/<code_etape>.csv
Un répertoire d'archive contient des fichiers quelconques, et un fichier texte nommé _description.txt
qui est une description (humaine, format libre) de l'archive.
@ -68,7 +68,7 @@ from app import log
from app.but import jury_but_pv
from app.comp import res_sem
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.sco_exceptions import (
AccessDenied,
@ -89,6 +89,11 @@ class BaseArchiver(object):
self.archive_type = archive_type
self.initialized = False
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):
if self.initialized:
@ -105,20 +110,21 @@ class BaseArchiver(object):
try:
scu.GSL.acquire()
if not os.path.isdir(path):
log("creating directory %s" % path)
log(f"creating directory {path}")
os.mkdir(path)
finally:
scu.GSL.release()
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).
If directory does not yet exist, create it.
"""
self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first()
dept_dir = os.path.join(self.root, str(dept.id))
dept_dir = os.path.join(self.root, str(self.dept_id))
try:
scu.GSL.acquire()
if not os.path.isdir(dept_dir):
@ -137,12 +143,11 @@ class BaseArchiver(object):
:return: list of archive oids
"""
self.initialize()
dept = Departement.query.filter_by(acronym=g.scodoc_dept).first()
base = os.path.join(self.root, str(dept.id)) + os.path.sep
base = os.path.join(self.root, str(self.dept_id)) + os.path.sep
dirs = glob.glob(base + "*")
return [os.path.split(x)[1] for x in dirs]
def list_obj_archives(self, oid):
def list_obj_archives(self, oid: int):
"""Returns
:return: list of archive identifiers for this object (paths to non empty dirs)
"""
@ -157,7 +162,7 @@ class BaseArchiver(object):
dirs.sort()
return dirs
def delete_archive(self, archive_id):
def delete_archive(self, archive_id: str):
"""Delete (forever) this archive"""
self.initialize()
try:
@ -166,7 +171,7 @@ class BaseArchiver(object):
finally:
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"""
return datetime.datetime(
*[int(x) for x in os.path.split(archive_id)[1].split("-")]
@ -183,17 +188,17 @@ class BaseArchiver(object):
files.sort()
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"""
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."""
return re.match(
"^[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)"""
self.initialize()
if not self.is_valid_archive_name(archive_name):
@ -206,7 +211,7 @@ class BaseArchiver(object):
raise ScoValueError(f"Archive {archive_name} introuvable")
return archive_id
def get_archive_description(self, archive_id):
def get_archive_description(self, archive_id: str) -> str:
"""Return description of archive"""
self.initialize()
filename = os.path.join(archive_id, "_description.txt")
@ -247,7 +252,7 @@ class BaseArchiver(object):
data = data.encode(scu.SCO_ENCODING)
self.initialize()
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:
scu.GSL.acquire()
fname = os.path.join(archive_id, filename)
@ -261,16 +266,18 @@ class BaseArchiver(object):
"""Retreive data"""
self.initialize()
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 ?)")
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:
data = f.read()
return data
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)
data = self.get(archive_id, filename)
mime = mimetypes.guess_type(filename)[0]

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@ -373,7 +373,7 @@ def etudarchive_import_files(
filename_title="fichier_a_charger",
)
return render_template(
"scolar/photos_import_files.html",
"scolar/photos_import_files.j2",
page_title="Téléchargement de fichiers associés aux étudiants",
ignored_zipfiles=ignored_zipfiles,
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
#
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by

View File

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

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