1
0
Fork 0

Compare commits

...

210 Commits

Author SHA1 Message Date
Emmanuel Viennet 2bdc937425 Fix small bug 2023-08-25 11:25:05 +02:00
Emmanuel Viennet cc33846093 flake8 config. Code cosmetic. 2023-08-25 11:25:05 +02:00
Emmanuel Viennet c5edd72293 build_release: option to skip tests 2023-08-25 11:25:05 +02:00
Emmanuel Viennet c4889d4a83 Version bump 2023-08-25 11:25:05 +02:00
Emmanuel Viennet 7cf253e614 Fix: calcul moyenne générale BUT si aucune UE 2023-08-25 11:25:05 +02:00
iziram 8f11a8f8cb Assiduites : migration heure aprem 2023-08-25 11:25:05 +02:00
iziram c087a9f771 Assiduites : fixes select annee cal + saisie par null 2023-08-25 11:25:05 +02:00
iziram 73a4abf0e9 Assiduites : préférences internes 2023-08-25 11:25:05 +02:00
Sébastien Lehmann 4ac9db35ed Correction superposition 2023-08-25 11:22:43 +02:00
Emmanuel Viennet 905bc934e3 Revert "Assiduites : metrique interne externe"
Changement non compatible avec les préférences en production.

This reverts commit afe2caac2d.
2023-08-25 11:22:43 +02:00
iziram b1386e9529 Assiduites : metrique interne externe 2023-08-25 11:22:43 +02:00
iziram ddb148a4ef Assiduites: Assiduites_metric_label + correction typing 2023-08-25 11:22:43 +02:00
Emmanuel Viennet 4c7f65f0b4 WIP: assiduités 2023-08-25 11:22:43 +02:00
Emmanuel Viennet b24280b30a version 9.6.8 2023-08-25 11:22:43 +02:00
iziram d3379298e2 Assiduites : bugfix justif import fichier 2023-08-25 11:22:43 +02:00
iziram 22d4be7b14 Assiduites : bugfix differee massAction 2023-08-25 11:22:43 +02:00
iziram 466dbe4859 Assiduites : bugfix external_data + differee 2023-08-25 11:22:43 +02:00
Emmanuel Viennet 4a877212c7 - API: added POST etudiant/etudid/int:etudid/photo
- API: added unit tests for photos
- Photos: code cleaning.
2023-08-25 11:22:43 +02:00
Emmanuel Viennet 6c3c0cec53 API: /formsemestres/query et /formsemestres_courants : ajout tri résultat. Ajout paramètre etat au query. 2023-08-25 11:22:43 +02:00
Sébastien Lehmann 59b5717346 Correctif Image.ANTIALIAS 2023-08-11 15:14:16 +02:00
Emmanuel Viennet baee82103c Fix: tests unitaires test_but_jury.py. + check in package building script. 2023-08-10 21:15:42 +02:00
Emmanuel Viennet b8c4254af6 Fix CLI flask delete-dept -f 2023-08-10 19:51:42 +02:00
Emmanuel Viennet 82c8dc8709 Merge branch 'dev96' of https://scodoc.org/git/iziram/ScoDoc into sco96 2023-08-10 19:16:47 +02:00
Emmanuel Viennet 975b4247e0 Fix: calcul de la liste des niveaux associés à une année de parcours 2023-08-10 19:08:01 +02:00
iziram cae3511f3c Assiduites : bug fix multiple + modifs routes api 2023-08-09 10:06:51 +02:00
Emmanuel Viennet 243268a0c3 API: exporte les date et datetime en ISO et non pas en RFC 822. 2023-08-09 08:19:06 +02:00
Emmanuel Viennet 720ca7222c Fix #666: affichage malus sur tableau bord modimpl. 2023-08-09 08:19:06 +02:00
Sébastien Lehmann 2cad6560c4 Progress fin message 2023-08-09 08:19:06 +02:00
Sébastien Lehmann 7d3811113b Partition editor : progress bar 2023-08-09 08:19:06 +02:00
Emmanuel Viennet 46dada71a6 API: exporte les date et datetime en ISO et non pas en RFC 822. 2023-08-01 22:07:23 +02:00
Emmanuel Viennet 14aa70fdc5 Fix #666: affichage malus sur tableau bord modimpl. 2023-07-31 20:37:58 +02:00
Emmanuel Viennet aea4f6853b Merge pull request 'Progress fin message' (#679) from lehmann/ScoDoc-Front:master into sco96
Reviewed-on: ScoDoc/ScoDoc#679
2023-07-31 20:17:59 +02:00
Sébastien Lehmann a5369cf9b7 Progress fin message 2023-07-31 20:10:42 +02:00
Emmanuel Viennet e7e35bd4b6 Merge pull request 'Partition editor : progress bar' (#678) from lehmann/ScoDoc-Front:master into sco96
Reviewed-on: ScoDoc/ScoDoc#678
2023-07-31 20:00:16 +02:00
Sébastien Lehmann 7cd4271073 Partition editor : progress bar 2023-07-31 19:57:47 +02:00
Emmanuel Viennet deff37b9b7 Fix #672: mise à jour parcours 2023-07-31 18:46:41 +02:00
Emmanuel Viennet 4dd6530ff8 ajout aide 2023-07-31 18:46:11 +02:00
iziram f4c0ee38ba Assiduites : bugfix justificatif create 2 2023-07-31 16:52:31 +02:00
iziram f683e6a041 Assiduites : bugfix justificatif create 2023-07-31 16:16:18 +02:00
iziram f540f78cd5 Assiduites : external_data + raison null 2023-07-31 16:16:18 +02:00
Emmanuel Viennet 2f8fa8061c version 2023-07-31 16:15:48 +02:00
Emmanuel Viennet bb1d4f559d Liens personnalisables (implements #386): au niveau global, avec paramètres. 2023-07-31 16:14:16 +02:00
Emmanuel Viennet b8767c7536 Test unitaire ScoDocSiteConfig. Correction d'un mini bug. 2023-07-30 22:07:41 +02:00
Emmanuel Viennet 5ccce380da Upgrade Python packages 2023-07-30 22:03:17 +02:00
Emmanuel Viennet bdfa603539 Merge branch 'dev96' of https://scodoc.org/git/iziram/ScoDoc into sco96 2023-07-30 21:50:46 +02:00
iziram b6d405f2a0 Assiduites : Bugfix justificatifs justifies 2023-07-30 21:47:47 +02:00
Emmanuel Viennet 234e9a2192 Closes #663 2023-07-30 20:00:05 +02:00
iziram 0174d68a98 Assiduites : bugfix justificatif delete 2023-07-30 18:59:06 +02:00
Emmanuel Viennet 868533e30e Merge pull request 'ajustement bulletin web affichage malus negatif' (#677) from jmplace/ScoDoc-Lille:PR96_bulletin_web_malus into sco96
Reviewed-on: ScoDoc/ScoDoc#677
2023-07-30 18:36:35 +02:00
iziram 2a971eb365 Assiduites : query with_justifs 2023-07-30 17:09:21 +02:00
iziram cab5a71925 Assiduites : optimisation justification 2023-07-30 16:34:05 +02:00
Jean-Marie Place 499d42a912 ajustement bulletin web affichage malus negatif 2023-07-30 16:08:47 +02:00
Emmanuel Viennet ead32c8a06 API assiduités: /justificatif/nip/<nip>/create, /justificatif/nip/<nip>/create 2023-07-30 15:27:31 +02:00
Emmanuel Viennet dc375f1a89 API assiduite: nip -> code_nip (oups) 2023-07-29 19:04:35 +02:00
Emmanuel Viennet cab72caebe API assiduite: ajoute NIP aux assiduites. + modifie relations Assuiduite/Identite. 2023-07-29 18:32:29 +02:00
iziram f3ceaff307 Assiduites : API - changement retour batch 2023-07-27 18:00:40 +02:00
iziram 3aa5629d1b Assiduites: api routes nip ine 2023-07-27 14:58:57 +02:00
iziram e5b1082e1d Assiduites : lecture seule + bug fix 2023-07-26 16:43:49 +02:00
Emmanuel Viennet 2873253cb4 Log modifs justificatifs 2023-07-26 16:00:23 +02:00
Emmanuel Viennet 7a4cff2623 Fix: API group/<int:group_id>/etudiants restreint aux inscrits au semestre 2023-07-26 15:59:31 +02:00
Emmanuel Viennet b04930870e cosmetic 2023-07-26 14:07:42 +02:00
Emmanuel Viennet 740749e37e Assiduités: ajout logs, style sur etuds dem. 2023-07-26 13:27:57 +02:00
iziram 70cda5a553 Assiduites : lisibilité conflit + UX 2023-07-26 07:32:32 +02:00
iziram 4e5e15092e Assiduites : bugfix aucun dem def 2023-07-25 20:15:28 +02:00
iziram 1e9796528f Assiduites : legendes, permissions, demissions 2023-07-25 19:59:47 +02:00
iziram fed626c043 Assiduites : calendrier juillet aout 2023-07-25 14:06:49 +02:00
iziram 72ef3373eb Assiduites : permission ScoAbsChange 2023-07-25 14:03:09 +02:00
Emmanuel Viennet bdb5f96a3a version 2023-07-25 09:08:41 +02:00
Emmanuel Viennet f731a53c54 Merge branch 'dev96' of https://scodoc.org/git/iziram/ScoDoc into sco96 2023-07-25 09:08:19 +02:00
iziram 904c5fe34c bugfix: page config assiduites 2023-07-25 09:02:45 +02:00
Emmanuel Viennet 52594ac09c enhance initial configuration script (deb12) 2023-07-25 09:02:45 +02:00
Emmanuel Viennet ce2b9df6c0 Correction affecation parcours 2023-07-25 09:02:45 +02:00
Sébastien Lehmann c888eab6d7 Correctif affectations groupes 2023-07-25 09:02:45 +02:00
Emmanuel Viennet 07f5f6c332 Optimisation: ajout cache par requete a FormSemestre.get_ues() 2023-07-25 09:02:45 +02:00
Sébastien Lehmann 4e300c3dbb Groups auto assignment 2023-07-25 09:02:45 +02:00
Emmanuel Viennet e10807c3ac enhance initial configuration script (deb12) 2023-07-24 21:35:36 +02:00
Emmanuel Viennet 0e14ff3c35 Correction affecation parcours 2023-07-24 21:34:35 +02:00
Emmanuel Viennet f8325f4612 Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into sco96 2023-07-24 21:27:01 +02:00
Emmanuel Viennet ea76dd702e Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into sco96 2023-07-24 16:49:16 +02:00
iziram 76ccb8ea66 Assiduités : Amélioration Migration et Tests Unitaires 2023-07-21 19:21:45 +02:00
iziram 5ea9944be2 Assiduités : changement champ desc Table assiduites 2023-07-21 19:21:45 +02:00
Emmanuel Viennet a2d9e19122 Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into sco96 2023-07-21 09:28:45 +02:00
Emmanuel Viennet 0d0c9da6b3 Merge pull request 'affine affichege des bonus/malus' (#674) from jmplace/ScoDoc-Lille:bonus_malus into sco96
Reviewed-on: ScoDoc/ScoDoc#674
2023-07-21 09:26:50 +02:00
Jean-Marie Place 2f05e081ee affine affichege des bonus/malus 2023-07-20 18:06:12 +02:00
Emmanuel Viennet fb5cdc2624 VisualisationAssiduitesGroupe: améliore table + export excel 2023-07-20 15:53:59 +02:00
Emmanuel Viennet 9bdeda7559 Fix: requirements for Python 3.11 2023-07-19 15:37:43 +02:00
Emmanuel Viennet cb0b890f1f Optimisation migration absences->assiduites 2023-07-19 10:37:56 +02:00
Emmanuel Viennet cde5960899 Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into sco96 2023-07-18 17:02:12 +02:00
Emmanuel Viennet f2574c2d49 modif script install pour Debian 12 2023-07-18 15:20:55 +02:00
Emmanuel Viennet 753deac3b7 Fix assiduite migration (2) 2023-07-18 09:33:40 +02:00
Emmanuel Viennet d86681b268 Fix assiduite migration 2023-07-18 08:35:18 +02:00
Emmanuel Viennet aee58edab1 removed unnecessary dev software 2023-07-18 08:26:20 +02:00
Emmanuel Viennet 7491176532 Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into sco96 2023-07-17 22:31:47 +02:00
Emmanuel Viennet 0963ebcc9a Fix from 9.5.3 2023-07-13 18:57:29 +02:00
Emmanuel Viennet a53f911bdd Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into sco96 2023-07-13 18:56:33 +02:00
Emmanuel Viennet 467d56c0fa Merge bugfixes from 9.5.2 2023-07-13 12:57:16 +02:00
Emmanuel Viennet 72b60ad76e Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into sco96 2023-07-12 15:15:58 +02:00
Emmanuel Viennet f4547c37c5 Merge bugfixes from 9.5.1 2023-07-12 14:15:15 +02:00
Emmanuel Viennet 4882526897 Fix migrations assiduités 2023-07-11 16:04:39 +02:00
Emmanuel Viennet 5da59a6cbe Adapte module assiduité pour SQLAlchemy 2. Tests unitaires sans erreurs. 2023-07-11 14:45:48 +02:00
Emmanuel Viennet f1d085ad50 Module assiduités 2023-07-11 14:45:00 +02:00
iziram f9fa4753a2 Assiduités : Bug Fix justifs/list + Tests OK 2023-07-11 11:35:54 +02:00
Emmanuel Viennet c9be6f21a8 Modifs pour SA 2.0 (à reporter en 9.5)
(cherry picked from commit 38f93cae99)
2023-07-11 11:35:54 +02:00
Emmanuel Viennet 5eb8770e93 Fix: creation matieres sans numeros 2023-07-11 11:35:54 +02:00
Emmanuel Viennet 720af0a061 Fix for SQLAlchemy 2 2023-07-11 11:35:54 +02:00
Emmanuel Viennet 10cc43918f Force JSON datetime format to ISO 8601 (API). 2023-07-11 10:24:00 +02:00
Emmanuel Viennet 31b280ec82 Change postgresql version in API unit tests 2023-07-11 09:56:25 +02:00
Emmanuel Viennet 38f93cae99 Modifs pour SA 2.0 (à reporter en 9.5) 2023-07-11 09:33:53 +02:00
iziram 1dbec9231b Assiduités : Migration -> date dans les noms de fichiers log 2023-07-10 18:30:25 +02:00
iziram 3326001289 Assiduites : Visualisation des assiduités d'un groupe 2023-07-10 17:38:50 +02:00
Emmanuel Viennet 3335655a4e Merge branch 'master' of https://scodoc.org/git/viennet/ScoDoc into sco96 2023-07-10 14:38:53 +02:00
Emmanuel Viennet 8da2ccc5ee Fix for SQLAlchemy 2 2023-07-10 13:58:16 +02:00
iziram 531ac1cb0c merge Scodoc/master -> iziram/assiduites_corrigee 2023-07-10 10:41:59 +02:00
Emmanuel Viennet 2e194757a5 Adaptation pour Python3.11 2023-07-10 09:29:48 +02:00
iziram 056433e1e8 Assiduites : Tests + Fixes + Améliorations 2023-07-04 15:08:56 +02:00
Emmanuel Viennet 6c591a62fd Fix: édition validations antérieures lorsqu'il y a des validations sans semestres 2023-07-04 15:08:55 +02:00
iziram e39260ab81 Merge branch 'offSco' into assiduites_corrigee 2023-07-03 19:34:42 +02:00
iziram 467c29d947 Assiduites : Permissions 2023-06-30 17:24:16 +02:00
iziram ff2b27e1d9 Assiduités : Invalidation du cache 2023-06-30 15:34:50 +02:00
iziram cb7ec89484 Assiduités : Légendes des pages 2023-06-30 14:38:56 +02:00
Emmanuel Viennet 07acadd595 Modifie contrainte sur ApcValidationAnnee (une modif plus sérieuse est nécessaire) 2023-06-30 10:33:43 +02:00
Emmanuel Viennet d519760c86 Fix: ue_sharing_code 2023-06-30 10:33:11 +02:00
Emmanuel Viennet 066a24b302 WIP: jury BUT: adaptations des tests unitaires, traite semestre par semestre 2023-06-30 10:33:11 +02:00
Emmanuel Viennet eeaf449fb4 WIP: jury BUT: reprise UEs antérieures 2023-06-30 10:33:11 +02:00
Emmanuel Viennet d38f233c21 WIP: nouvelles gestion jury BUT. 2023-06-30 10:33:11 +02:00
IDK b4c10146a9 Adapte les tests unitaires jury BUT 2023-06-30 10:33:11 +02:00
IDK b78f8e19a1 WIP: nouveaux RCUEs 2023-06-30 10:33:11 +02:00
IDK 015ec66aec Nouvelle gestion RCUE 2023-06-30 10:33:11 +02:00
iziram 7a42c24fc4 Assiduites : Pages Bilans 2023-06-28 17:15:24 +02:00
Emmanuel Viennet 9b8e09cdba Fix temporaire: jury BUT: propose toujours le code année RED en mode manuel 2023-06-27 21:55:29 +02:00
Emmanuel Viennet 4231171668 Fix: typo validation manuelle 2023-06-27 21:55:29 +02:00
Emmanuel Viennet 02168b8032 Ajout ADSUP au transcodage Apogée. Cosmetic flash. 2023-06-27 21:55:28 +02:00
Emmanuel Viennet e15c468703 Fix: typo calcul auto jury BUT 2023-06-27 21:55:28 +02:00
Emmanuel Viennet df289ba556 un detail 2023-06-27 21:55:28 +02:00
Emmanuel Viennet fc0a1c285a Améliore UI gestion des UE antérieures 2023-06-27 21:55:28 +02:00
Emmanuel Viennet 0402eac989 Fix #582: moy UE fiche etud si dispense. 2023-06-27 21:55:28 +02:00
iziram 4dc2b41402 Assiduites : Finalisation Page Liste 2023-06-23 16:12:36 +02:00
Emmanuel Viennet 46e03c0f61 Ajout recap. parcours BUT sur page saisie jury 2023-06-23 16:11:16 +02:00
Emmanuel Viennet b70e2758c9 news pour opérations jury. Implements #668 2023-06-23 16:11:16 +02:00
Emmanuel Viennet 916edb72ac N'affiche pas les niveaux inexistants sur le résumé parcours 2023-06-22 21:19:44 +02:00
Emmanuel Viennet 449c1f0cb0 Jury BUT:
- Modification gestion de l'enregistrement des codes.
- Signale quand un RCUE change de code.
- Calcul auto du jury: peut modifier les décisions RCUE.
2023-06-22 21:19:44 +02:00
Emmanuel Viennet b696f772bf Modification priorité codes jury: PASD > PAS1NCI 2023-06-22 21:19:44 +02:00
Emmanuel Viennet b44563666a Jury BUT: modification menu choix décision RCUE redoublants 2023-06-22 21:19:44 +02:00
iziram 280ceaa255 Assiduites : Calendrier des assiduités 2023-06-22 16:25:13 +02:00
Emmanuel Viennet c0b750dcfb Improve critical error handling in moy_ue 2023-06-22 16:22:00 +02:00
Emmanuel Viennet 44d56f2493 Fix: affichage dans édition ECTS UEs par parcours 2023-06-22 16:22:00 +02:00
Emmanuel Viennet 2af2ca6c43 Jury BUT: corrige enregistrement décisions d'annee BUT manuelles 2023-06-22 16:22:00 +02:00
Emmanuel Viennet 41e065f6ab Jury BUT: présente toujours NAR sur année. Tri les codes dans les menus. 2023-06-22 16:22:00 +02:00
Emmanuel Viennet 60c157222b Enhance exception handling 2023-06-22 16:22:00 +02:00
Emmanuel Viennet 735100de60 Modify caching of ApcNiveaux 2023-06-22 16:22:00 +02:00
Emmanuel Viennet f7a42646bc Optimisation: table recap jury (x3) 2023-06-22 16:22:00 +02:00
Emmanuel Viennet d666483530 Améliore tri jury_delete_manual et table recap (rang) 2023-06-22 16:22:00 +02:00
Emmanuel Viennet 7712de19a2 Modifie effacement décisions annuelles BUT et RCUE. Améliore affichage décisions 2023-06-22 16:22:00 +02:00
Emmanuel Viennet c928ccdcfe Jury BUT: effacement décision année + 2 petits bugs mineurs 2023-06-22 16:22:00 +02:00
Emmanuel Viennet 44cb716154 Fix typo 2023-06-20 17:39:22 +02:00
Emmanuel Viennet 88d3ef020d Table jury: affichage stats codes annuels octroyés sous la table 2023-06-20 17:39:22 +02:00
iziram 2a5f602549 Assiduites : Page Justifier 2023-06-20 15:50:56 +02:00
iziram 93136ee679 Assiduites : réorganisation templates 2023-06-20 08:33:49 +02:00
Emmanuel Viennet 54ab56e9bf Database creation: add unaccent postgresql extension. Tests unitaires OK. 2023-06-20 08:09:50 +02:00
Emmanuel Viennet fae4c32db2 version 2023-06-20 08:09:50 +02:00
Emmanuel Viennet 319be43ba3 fix html typos 2023-06-20 08:09:50 +02:00
Emmanuel Viennet 07318b5d77 Affichage et suppression possible de toutes les décisions de jury 2023-06-20 08:09:50 +02:00
Emmanuel Viennet edd97e15f5 Table jury BUT: ajout explication sur col RCUEs 2023-06-20 08:09:50 +02:00
Emmanuel Viennet 068951ef1d Jury BUT: ajout colonne décision année sur table récap. 2023-06-20 08:09:50 +02:00
Emmanuel Viennet fbca147d7e Suppressions de décisions de jury 2023-06-20 08:09:50 +02:00
Emmanuel Viennet 9ee36f5eba Fix: ordre des RCUE sur les bulletins 2023-06-20 08:09:50 +02:00
Emmanuel Viennet 5dd5991995 Fix: mise à jour base postgres 2023-06-20 08:09:50 +02:00
Emmanuel Viennet 62bc0499e0 Jury BUT: condition de passage de S5: toutes UEs de BUT1 validées. 2023-06-20 08:09:50 +02:00
Emmanuel Viennet 135c449657 Fix enregistrement jury année BUT et passage en mode auto 2023-06-20 08:09:50 +02:00
Emmanuel Viennet 6891d9f1c1 BUT: jury: validation des niveaux inférieurs. WIP 2023-06-20 08:09:50 +02:00
iziram 6d2c3f8dcc Assiduites : Page liste - filtrage des tableaux 2023-06-15 17:50:38 +02:00
Emmanuel Viennet 9e9797c705 Fix: tri des coefs. de modules apc 2023-06-15 17:47:31 +02:00
iziram 1c0d0baf15 Assiduites : Fixes + fin page différée 2023-06-14 17:53:19 +02:00
Emmanuel Viennet 452233dd1a Fix: clonage formation avec UE BUT externes 2023-06-13 21:40:13 +02:00
iziram da8b416785 Assiduites : Modif Live + Toasts + filtre Liste 2023-06-13 16:25:45 +02:00
iziram f10fd311e1 Assiduites : Modif Comportement Page Différée 2023-06-12 17:54:30 +02:00
Emmanuel Viennet 56352d6ce6 Tableau bord module: n'affiche pas saisie abs pour groupes vides 2023-06-12 10:48:31 +02:00
Emmanuel Viennet c8c231a368 Fix: affichage moyenne évalution / 20 2023-06-12 10:48:31 +02:00
Emmanuel Viennet aaaa5e0f4d Editeur partitions: Cache boite non affectés quand elle est vide 2023-06-08 09:52:52 +02:00
Emmanuel Viennet e9234d958a code cleaning 2023-06-08 09:52:52 +02:00
Emmanuel Viennet 9b23ba4c96 Fix: update_inscriptions_parcours_from_groups (restreint au ref. comp. courant) 2023-06-08 09:52:52 +02:00
Emmanuel Viennet 8c909062e7 Retire bonus masters IG, inadapté aux besoins 2023-06-08 09:52:52 +02:00
Emmanuel Viennet 3c30bf357a Bonus pour masters Institut Galilée (USPN) 2023-06-08 09:52:52 +02:00
iziram 99223b760b Assiduites : Mise à jour diverses (Page différée + live groupe) 2023-06-05 16:18:06 +02:00
Emmanuel Viennet 72b0ed17b5 Tri parcours par numero et code; améliore table description semestre. 2023-06-05 08:20:10 +02:00
Emmanuel Viennet 9d18ed4671 - Amélioration enregistrement note.
- Nouveau point API: /evaluation/<int:evaluation_id>/notes/set
- Corrige API /evaluation/<int:evaluation_id>/notes
- Modernisation de code.
- Améliore tests unitaires APi evaluation.
2023-06-05 08:20:10 +02:00
iziram 4b4e52bf2d Assiduites : Correction bug granularité timeline 2023-06-02 17:40:46 +02:00
iziram d2a17ffdfb Assiduites : Correction bug timeline 2023-06-02 17:19:55 +02:00
iziram 5be9d711a7 Assiduites : mise à jour migration 2023-06-02 11:42:47 +02:00
iziram d5f01e0628 Assiduites : Signalement différé 2023-06-02 11:41:36 +02:00
iziram 36bc67fffc Assiduites : Lien Evaluation WIP 2023-06-02 11:41:36 +02:00
iziram 825dc6ecb1 Assiduites : préférences - métrique + lien assiduité avec reste scodoc 2023-06-02 11:41:36 +02:00
iziram 238b6b10d4 Assiduites : préférences - jours travaillés 2023-06-02 11:41:36 +02:00
iziram cfa209a24b Assiduites : préférences - granularité 2023-06-02 11:41:36 +02:00
iziram 54db0d70d5 Assiduites : api/assiduites/group bug fix flask_json 2023-06-02 11:41:36 +02:00
iziram fe80051573 Assiduites : Mise à jour suivi master (flask_json) 2023-06-02 11:41:36 +02:00
iziram 4d5c1a84c3 Assiduites : ajout préférence : durée créneau 2023-06-02 11:41:36 +02:00
iziram 30781ba9aa Assiduites : bug fix page "live" etud 2023-06-02 11:41:36 +02:00
iziram 6dc39c25ee Assiduites : ajout préférences 2023-06-02 11:41:36 +02:00
iziram 15baf57136 Assiduités : Page Liste Assiduites / Justifs (WIP) 2023-06-02 11:41:36 +02:00
iziram d796c7db93 Assiduites : Gestion des justificatifs (rapide) WIP
Assiduites : ajout style justifié (minitimeline)
2023-06-02 11:41:35 +02:00
iziram 35646a934b Assiduites : modification automatique du moduleimpl_id 2023-06-02 11:41:35 +02:00
iziram 5c6f0b3d6b Assiduites : modification styles (proposition Sébastien Lehmann) 2023-06-02 11:41:35 +02:00
iziram 19328cbe70 bugfix : placement modaux + affichage conflit 2023-06-02 11:41:35 +02:00
iziram 96b1512e24 Assiduites : Front End 2023-06-02 11:41:35 +02:00
iziram 94347657f6 Assiduites : script de migration et de suppression 2023-06-02 11:41:35 +02:00
iziram e748973ae1 Assiduités : Ajout des tests (Unit/API) 2023-06-02 11:41:35 +02:00
iziram 9a0852917f Assiduites : Fonctionnement BackEnd + API 2023-06-02 11:41:26 +02:00
iziram c5fb15fbe8 Assiduités : Ajout des migrations 2023-06-02 11:41:10 +02:00
iziram c48c07bd21 Assiduites :Création des models 2023-06-02 11:41:10 +02:00
143 changed files with 22492 additions and 779 deletions

3
.flake8 Normal file
View File

@ -0,0 +1,3 @@
[flake8]
max-line-length = 88
ignore = E203,W503

View File

@ -176,7 +176,7 @@ puis
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
# Paquet Debian 11
## Paquet Debian 12
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
important est `postinst`qui se charge de configurer le système (install ou

14
app/__init__.py Normal file → Executable file
View File

@ -148,7 +148,7 @@ def handle_invalid_usage(error):
# JSON ENCODING
# used by some internal finctions
# the API is now using flask_son, NOT THIS ENCODER
# the API is now using flask_json, NOT THIS ENCODER
class ScoDocJSONEncoder(json.JSONEncoder):
def default(self, o): # pylint: disable=E0202
if isinstance(o, (datetime.date, datetime.datetime)):
@ -260,7 +260,13 @@ def create_app(config_class=DevConfig):
CAS(app, url_prefix="/cas", configuration_function=cas.set_cas_configuration)
app.wsgi_app = ReverseProxied(app.wsgi_app)
FlaskJSON(app)
app_json = FlaskJSON(app)
@app_json.encoder
def scodoc_json_encoder(o):
"Overide default date encoding (RFC 822) and use ISO"
if isinstance(o, (datetime.date, datetime.datetime)):
return o.isoformat()
# Pour conserver l'ordre des objets dans les JSON:
# e.g. l'ordre des UE dans les bulletins
@ -322,6 +328,7 @@ def create_app(config_class=DevConfig):
from app.views import notes_bp
from app.views import users_bp
from app.views import absences_bp
from app.views import assiduites_bp
from app.api import api_bp
from app.api import api_web_bp
@ -340,6 +347,9 @@ def create_app(config_class=DevConfig):
app.register_blueprint(
absences_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Absences"
)
app.register_blueprint(
assiduites_bp, url_prefix="/ScoDoc/<scodoc_dept>/Scolarite/Assiduites"
)
app.register_blueprint(api_bp, url_prefix="/ScoDoc/api")
app.register_blueprint(api_web_bp, url_prefix="/ScoDoc/<scodoc_dept>/api")

View File

@ -1,8 +1,9 @@
"""api.__init__
"""
from flask_json import as_json
from flask import Blueprint
from flask import request
from flask import request, g
from app import db
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException
@ -34,9 +35,27 @@ def requested_format(default_format="json", allowed_formats=None):
return None
@as_json
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 unique.to_dict(format_api=True)
from app.api import tokens
from app.api import (
absences,
assiduites,
billets_absences,
departements,
etudiants,
@ -44,6 +63,7 @@ from app.api import (
formations,
formsemestres,
jury,
justificatifs,
logos,
partitions,
semset,

1043
app/api/assiduites.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -281,7 +281,15 @@ def dept_formsemestres_courants(acronym: str):
FormSemestre.date_debut <= test_date,
FormSemestre.date_fin >= test_date,
)
return [d.to_dict_api() for d in formsemestres]
return [
d.to_dict_api()
for d in formsemestres.order_by(
FormSemestre.date_debut.desc(),
FormSemestre.modalite,
FormSemestre.semestre_id,
FormSemestre.titre,
)
]
@bp.route("/departement/id/<int:dept_id>/formsemestres_courants")

76
app/api/etudiants.py Normal file → Executable file
View File

@ -34,6 +34,7 @@ from app.scodoc.sco_bulletins import do_formsemestre_bulletinetud
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error, suppress_accents
import app.scodoc.sco_photos as sco_photos
# Un exemple:
# @bp.route("/api_function/<int:arg>")
@ -136,6 +137,81 @@ def etudiant(etudid: int = None, nip: str = None, ine: str = None):
return etud.to_dict_api()
@bp.route("/etudiant/etudid/<int:etudid>/photo")
@bp.route("/etudiant/nip/<string:nip>/photo")
@bp.route("/etudiant/ine/<string:ine>/photo")
@api_web_bp.route("/etudiant/etudid/<int:etudid>/photo")
@api_web_bp.route("/etudiant/nip/<string:nip>/photo")
@api_web_bp.route("/etudiant/ine/<string:ine>/photo")
@login_required
@scodoc
@permission_required(Permission.ScoView)
def get_photo_image(etudid: int = None, nip: str = None, ine: str = None):
"""
Retourne la photo de l'étudiant
correspondant ou un placeholder si non existant.
etudid : l'etudid de l'étudiant
nip : le code nip de l'étudiant
ine : le code ine de l'étudiant
"""
etud = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
if not etudid:
filename = sco_photos.UNKNOWN_IMAGE_PATH
size = request.args.get("size", "orig")
filename = sco_photos.photo_pathname(etud.photo_filename, size=size)
if not filename:
filename = sco_photos.UNKNOWN_IMAGE_PATH
res = sco_photos.build_image_response(filename)
return res
@bp.route("/etudiant/etudid/<int:etudid>/photo", methods=["POST"])
@api_web_bp.route("/etudiant/etudid/<int:etudid>/photo", methods=["POST"])
@login_required
@scodoc
@permission_required(Permission.ScoEtudChangeAdr)
@as_json
def set_photo_image(etudid: int = None):
"""Enregistre la photo de l'étudiant."""
allowed_depts = current_user.get_depts_with_permission(Permission.ScoEtudChangeAdr)
query = Identite.query.filter_by(id=etudid)
if not None in allowed_depts:
# restreint aux départements autorisés:
query = query.join(Departement).filter(
or_(Departement.acronym == acronym for acronym in allowed_depts)
)
if g.scodoc_dept is not None:
query = query.filter_by(dept_id=g.scodoc_dept_id)
etud: Identite = query.first()
if etud is None:
return json_error(404, message="etudiant inexistant")
# Récupère l'image
if len(request.files) == 0:
return json_error(404, "Il n'y a pas de fichier joint")
file = list(request.files.values())[0]
if not file.filename:
return json_error(404, "Il n'y a pas de fichier joint")
data = file.stream.read()
status, err_msg = sco_photos.store_photo(etud, data, file.filename)
if status:
return {"etudid": etud.id, "message": "recorded photo"}
return json_error(
404,
message=f"Erreur: {err_msg}",
)
@bp.route("/etudiants/etudid/<int:etudid>", methods=["GET"])
@bp.route("/etudiants/nip/<string:nip>", methods=["GET"])
@bp.route("/etudiants/ine/<string:ine>", methods=["GET"])

View File

@ -99,18 +99,20 @@ def formsemestre_infos(formsemestre_id: int):
def formsemestres_query():
"""
Retourne les formsemestres filtrés par
étape Apogée ou année scolaire ou département (acronyme ou id)
étape Apogée ou année scolaire ou département (acronyme ou id) ou état ou code étudiant
etape_apo : un code étape apogée
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.
etat: 0 si verrouillé, 1 sinon
"""
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")
etat = request.args.get("etat")
nip = request.args.get("nip")
ine = request.args.get("ine")
formsemestres = FormSemestre.query
@ -126,6 +128,12 @@ def formsemestres_query():
formsemestres = formsemestres.filter(
FormSemestre.date_fin >= debut_annee, FormSemestre.date_debut <= fin_annee
)
if etat is not None:
try:
etat = bool(int(etat))
except ValueError:
return json_error(404, "invalid etat: integer expected")
formsemestres = formsemestres.filter_by(etat=etat)
if dept_acronym is not None:
formsemestres = formsemestres.join(Departement).filter_by(acronym=dept_acronym)
if dept_id is not None:
@ -151,7 +159,15 @@ def formsemestres_query():
formsemestres = formsemestres.join(FormSemestreInscription).join(Identite)
formsemestres = formsemestres.filter_by(code_ine=ine)
return [formsemestre.to_dict_api() for formsemestre in formsemestres]
return [
formsemestre.to_dict_api()
for formsemestre in formsemestres.order_by(
FormSemestre.date_debut.desc(),
FormSemestre.modalite,
FormSemestre.semestre_id,
FormSemestre.titre,
)
]
@bp.route("/formsemestre/<int:formsemestre_id>/bulletins")

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

@ -0,0 +1,699 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""ScoDoc 9 API : Assiduités
"""
from datetime import datetime
from flask_json import as_json
from flask import g, jsonify, request
from flask_login import login_required, current_user
import app.scodoc.sco_assiduites as scass
import app.scodoc.sco_utils as scu
from app import db
from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object, tools
from app.decorators import permission_required, scodoc
from app.models import Identite, Justificatif, Departement, FormSemestre
from app.models.assiduites import (
compute_assiduites_justified,
)
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import json_error
from flask_sqlalchemy.query import Query
# Partie Modèle
@bp.route("/justificatif/<int:justif_id>")
@api_web_bp.route("/justificatif/<int:justif_id>")
@scodoc
@permission_required(Permission.ScoView)
def justificatif(justif_id: int = None):
"""Retourne un objet justificatif à partir de son id
Exemple de résultat:
{
"justif_id": 1,
"etudid": 2,
"date_debut": "2022-10-31T08:00+01:00",
"date_fin": "2022-10-31T10:00+01:00",
"etat": "valide",
"fichier": "archive_id",
"raison": "une raison",
"entry_date": "2022-10-31T08:00+01:00",
"user_id": 1 or null,
}
"""
return get_model_api_object(Justificatif, justif_id, Identite)
# etudid
@bp.route("/justificatifs/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/<etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/<etudid>/query", defaults={"with_query": True})
@bp.route("/justificatifs/etudid/<etudid>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/etudid/<etudid>", defaults={"with_query": False})
@bp.route("/justificatifs/etudid/<etudid>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/etudid/<etudid>/query", defaults={"with_query": True})
# nip
@bp.route("/justificatifs/nip/<nip>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/nip/<nip>", defaults={"with_query": False})
@bp.route("/justificatifs/nip/<nip>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/nip/<nip>/query", defaults={"with_query": True})
# ine
@bp.route("/justificatifs/ine/<ine>", defaults={"with_query": False})
@api_web_bp.route("/justificatifs/ine/<ine>", defaults={"with_query": False})
@bp.route("/justificatifs/ine/<ine>/query", defaults={"with_query": True})
@api_web_bp.route("/justificatifs/ine/<ine>/query", defaults={"with_query": True})
#
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def justificatifs(etudid: int = None, nip=None, ine=None, with_query: bool = False):
"""
Retourne toutes les assiduités d'un étudiant
chemin : /justificatifs/<int:etudid>
Un filtrage peut être donné avec une query
chemin : /justificatifs/<int:etudid>/query?
Les différents filtres :
Etat (etat du justificatif -> validé, non validé, modifé, en attente):
query?etat=[- liste des états séparé par une virgule -]
ex: .../query?etat=validé,modifié
Date debut
(date de début du justificatif, sont affichés les justificatifs
dont la date de début est supérieur ou égale à la valeur donnée):
query?date_debut=[- date au format iso -]
ex: query?date_debut=2022-11-03T08:00+01:00
Date fin
(date de fin du justificatif, sont affichés les justificatifs
dont la date de fin est inférieure ou égale à la valeur donnée):
query?date_fin=[- date au format iso -]
ex: query?date_fin=2022-11-03T10:00+01:00
user_id (l'id de l'auteur du justificatif)
query?user_id=[int]
ex query?user_id=3
"""
etud: Identite = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
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 data_set
@api_web_bp.route("/justificatifs/dept/<int:dept_id>", defaults={"with_query": False})
@api_web_bp.route(
"/justificatifs/dept/<int:dept_id>/query", defaults={"with_query": True}
)
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoView)
def justificatifs_dept(dept_id: int = None, with_query: bool = False):
""" """
dept = Departement.query.get_or_404(dept_id)
etuds = [etud.id for etud in dept.etudiants]
justificatifs_query = Justificatif.query.filter(Justificatif.etudid.in_(etuds))
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 data_set
@bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/<int:etudid>/create", methods=["POST"])
@bp.route("/justificatif/etudid/<etudid>/create", methods=["POST"])
@api_web_bp.route("/justificatif/etudid/<etudid>/create", methods=["POST"])
# nip
@bp.route("/justificatif/nip/<nip>/create", methods=["POST"])
@api_web_bp.route("/justificatif/nip/<nip>/create", methods=["POST"])
# ine
@bp.route("/justificatif/ine/<ine>/create", methods=["POST"])
@api_web_bp.route("/justificatif/ine/<ine>/create", methods=["POST"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoAbsChange)
def justif_create(etudid: int = None, nip=None, ine=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 = tools.get_etud(etudid, nip, ine)
if etud is None:
return json_error(
404,
message="étudiant inconnu",
)
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: list = []
success: list = []
justifs: list = []
for i, data in enumerate(create_list):
code, obj, justi = _create_singular(data, etud)
if code == 404:
errors.append({"indice": i, "message": obj})
else:
success.append({"indice": i, "message": obj})
justifs.append(justi)
scass.simple_invalidate_cache(data, etud.id)
compute_assiduites_justified(etud.etudid, justifs)
return {"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)
external_data = data.get("external_data")
if external_data is not None:
if not isinstance(external_data, dict):
errors.append("param 'external_data' : n'est pas un objet JSON")
if errors:
err: str = ", ".join(errors)
return (404, err, None)
# TOUT EST OK
try:
nouv_justificatif: Query = Justificatif.create_justificatif(
date_debut=deb,
date_fin=fin,
etat=etat,
etud=etud,
raison=raison,
user_id=current_user.id,
external_data=external_data,
)
db.session.add(nouv_justificatif)
db.session.commit()
return (
200,
{
"justif_id": nouv_justificatif.id,
"couverture": scass.justifies(nouv_justificatif),
},
nouv_justificatif,
)
except ScoValueError as excp:
return (
404,
excp.args[0],
)
@bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/edit", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoAbsChange)
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: Query = Justificatif.query.filter_by(
id=justif_id
).first_or_404()
errors: list[str] = []
data = request.get_json(force=True)
avant_ids: list[int] = scass.justifies(justificatif_unique)
# Vérifications de data
# Cas 1 : Etat
if data.get("etat") is not None:
etat = scu.EtatJustificatif.get(data.get("etat"))
if etat is None:
errors.append("param 'etat': invalide")
else:
justificatif_unique.etat = etat
# Cas 2 : raison
raison = data.get("raison", False)
if raison is not False:
justificatif_unique.raison = raison
deb, fin = None, None
# cas 3 : date_debut
date_debut = data.get("date_debut", False)
if date_debut is not False:
if date_debut is None:
errors.append("param 'date_debut': manquant")
deb = scu.is_iso_formated(date_debut.replace(" ", "+"), convert=True)
if deb is None:
errors.append("param 'date_debut': format invalide")
# cas 4 : date_fin
date_fin = data.get("date_fin", False)
if date_fin is not False:
if date_fin is None:
errors.append("param 'date_fin': manquant")
fin = scu.is_iso_formated(date_fin.replace(" ", "+"), convert=True)
if fin is None:
errors.append("param 'date_fin': format invalide")
# Mise à jour des dates
deb = deb if deb is not None else justificatif_unique.date_debut
fin = fin if fin is not None else justificatif_unique.date_fin
external_data = data.get("external_data")
if external_data is not None:
if not isinstance(external_data, dict):
errors.append("param 'external_data' : n'est pas un objet JSON")
else:
justificatif_unique.external_data = external_data
if fin <= deb:
errors.append("param 'dates' : Date de début après date de fin")
justificatif_unique.date_debut = deb
justificatif_unique.date_fin = fin
if errors:
err: str = ", ".join(errors)
return json_error(404, err)
db.session.add(justificatif_unique)
db.session.commit()
retour = {
"couverture": {
"avant": avant_ids,
"après": compute_assiduites_justified(
justificatif_unique.etudid,
[justificatif_unique],
False,
),
}
}
scass.simple_invalidate_cache(justificatif_unique.to_dict())
return retour
@bp.route("/justificatif/delete", methods=["POST"])
@api_web_bp.route("/justificatif/delete", methods=["POST"])
@login_required
@scodoc
@as_json
@permission_required(Permission.ScoAbsChange)
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"].append({"indice": i, "message": msg})
else:
output["success"].append({"indice": i, "message": "OK"})
db.session.commit()
return output
def _delete_singular(justif_id: int, database):
justificatif_unique: Query = 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()
try:
archiver.delete_justificatif(justificatif_unique.etudid, archive_name)
except ValueError:
pass
scass.simple_invalidate_cache(justificatif_unique.to_dict())
database.session.delete(justificatif_unique)
compute_assiduites_justified(
justificatif_unique.etudid,
Justificatif.query.filter_by(etudid=justificatif_unique.etudid).all(),
True,
)
return (200, "OK")
# Partie archivage
@bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/import", methods=["POST"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoAbsChange)
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: 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,
user_id=current_user.id,
)
justificatif_unique.fichier = archive_name
db.session.add(justificatif_unique)
db.session.commit()
return {"filename": fname}
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/export/<filename>", methods=["POST"])
@scodoc
@login_required
@permission_required(Permission.ScoAbsChange)
def justif_export(justif_id: int = None, filename: str = None):
"""
Retourne un fichier d'une archive d'un justificatif
"""
query: 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: Justificaitf = query.first_or_404()
archive_name: str = justificatif_unique.fichier
if archive_name is None:
return json_error(404, "le justificatif ne possède pas de fichier")
archiver: JustificatifArchiver = JustificatifArchiver()
try:
return archiver.get_justificatif_file(
archive_name, justificatif_unique.etudid, filename
)
except ScoValueError as err:
return json_error(404, err.args[0])
@bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@api_web_bp.route("/justificatif/<int:justif_id>/remove", methods=["POST"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoAbsChange)
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: 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 {"response": "removed"}
@bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/list", methods=["GET"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoView)
def justif_list(justif_id: int = None):
"""
Liste les fichiers du justificatif
"""
query: 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
)
retour = {"total": len(filenames), "filenames": []}
for fi in filenames:
if int(fi[1]) == current_user.id or current_user.has_permission(
Permission.ScoJustifView
):
retour["filenames"].append(fi[0])
return retour
# Partie justification
@bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@api_web_bp.route("/justificatif/<int:justif_id>/justifies", methods=["GET"])
@scodoc
@login_required
@as_json
@permission_required(Permission.ScoAbsChange)
def justif_justifies(justif_id: int = None):
"""
Liste assiduite_id justifiées par le justificatif
"""
query: 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 assiduites_list
# -- Utils --
def _filter_manager(requested, justificatifs_query):
"""
Retourne les justificatifs entrés filtrés en fonction de la request
"""
# cas 1 : etat justificatif
etat = requested.args.get("etat")
if etat is not None:
justificatifs_query = scass.filter_justificatifs_by_etat(
justificatifs_query, etat
)
# cas 2 : date de début
deb = requested.args.get("date_debut", "").replace(" ", "+")
deb: datetime = scu.is_iso_formated(deb, True)
# cas 3 : date de fin
fin = requested.args.get("date_fin", "").replace(" ", "+")
fin = scu.is_iso_formated(fin, True)
if (deb, fin) != (None, None):
justificatifs_query: Query = scass.filter_by_date(
justificatifs_query, Justificatif, deb, fin
)
user_id = requested.args.get("user_id", False)
if user_id is not False:
justificatifs_query: Query = scass.filter_by_user_id(
justificatifs_query, user_id
)
# 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()
justificatifs_query = scass.filter_by_formsemestre(
justificatifs_query, Justificatif, formsemestre
)
return justificatifs_query

View File

@ -110,7 +110,7 @@ def formsemestre_partitions(formsemestre_id: int):
def etud_in_group(group_id: int):
"""
Retourne la liste des étudiants dans un groupe
(inscrits au groupe et inscrits au semestre).
group_id : l'id d'un groupe
Exemple de résultat :
@ -133,7 +133,15 @@ def etud_in_group(group_id: int):
query.join(Partition).join(FormSemestre).filter_by(dept_id=g.scodoc_dept_id)
)
group = query.first_or_404()
return [etud.to_dict_short() for etud in group.etuds]
query = (
Identite.query.join(group_membership)
.filter_by(group_id=group_id)
.join(FormSemestreInscription)
.filter_by(formsemestre_id=group.partition.formsemestre_id)
)
return [etud.to_dict_short() for etud in query]
@bp.route("/group/<int:group_id>/etudiants/query")
@ -161,7 +169,6 @@ def etud_in_group_query(group_id: int):
query = query.filter_by(etat=etat)
query = query.join(group_membership).filter_by(group_id=group_id)
return [etud.to_dict_short() for etud in query]
@ -223,7 +230,9 @@ def group_remove_etud(group_id: int, etudid: int):
commit=True,
)
# Update parcours
group.partition.formsemestre.update_inscriptions_parcours_from_groups()
group.partition.formsemestre.update_inscriptions_parcours_from_groups(
etudid=etudid
)
sco_cache.invalidate_formsemestre(group.partition.formsemestre_id)
return {"group_id": group_id, "etudid": etudid}
@ -270,7 +279,7 @@ def partition_remove_etud(partition_id: int, etudid: int):
)
db.session.commit()
# Update parcours
partition.formsemestre.update_inscriptions_parcours_from_groups()
partition.formsemestre.update_inscriptions_parcours_from_groups(etudid=etudid)
app.set_sco_dept(partition.formsemestre.departement.acronym)
sco_cache.invalidate_formsemestre(partition.formsemestre_id)
return {"partition_id": partition_id, "etudid": etudid}

View File

@ -387,6 +387,11 @@ class BulletinBUT:
semestre_infos["absences"] = {
"injustifie": nbabs - nbabsjust,
"total": nbabs,
"metrique": {
"H.": "Heure(s)",
"J.": "Journée(s)",
"1/2 J.": "1/2 Jour.",
}.get(sco_preferences.get_preference("assi_metrique")),
}
decisions_ues = self.res.get_etud_decisions_ue(etud.id) or {}
if self.prefs["bul_show_ects"]:

View File

@ -212,6 +212,34 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
else:
self.ue_std_rows(rows, ue, title_bg)
@staticmethod
def affichage_bonus_malus(ue: dict) -> list:
fields_bmr = []
# lecture des bonus sport culture et malus (ou bonus autre) (0 si valeur non numérique)
try:
bonus_sc = float(ue.get("bonus", 0.0)) or 0
except ValueError:
bonus_sc = 0
try:
malus = float(ue.get("malus", 0.0)) or 0
except ValueError:
malus = 0
# Calcul de l affichage
if malus < 0:
if bonus_sc > 0:
fields_bmr.append(f"Bonus sport/culture: {bonus_sc}")
fields_bmr.append(f"Bonus autres: {-malus}")
else:
fields_bmr.append(f"Bonus: {-malus}")
elif malus > 0:
if bonus_sc > 0:
fields_bmr.append(f"Bonus: {bonus_sc}")
fields_bmr.append(f"Malus: {malus}")
else:
if bonus_sc > 0:
fields_bmr.append(f"Bonus: {bonus_sc}")
return fields_bmr
def ue_std_rows(self, rows: list, ue: dict, title_bg: tuple):
"Lignes décrivant une UE standard dans la table de synthèse"
# 2eme ligne titre UE (bonus/malus/ects)
@ -220,20 +248,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
else:
ects_txt = ""
# case Bonus/Malus/Rang "bmr"
fields_bmr = []
try:
value = float(ue.get("bonus", 0.0))
if value != 0:
fields_bmr.append(f"Bonus: {ue['bonus']}")
except ValueError:
pass
try:
value = float(ue.get("malus", 0.0))
if value != 0:
fields_bmr.append(f"Malus: {ue['malus']}")
except ValueError:
pass
fields_bmr = BulletinGeneratorStandardBUT.affichage_bonus_malus(ue)
moy_ue = ue.get("moyenne", "-")
if isinstance(moy_ue, dict): # UE non capitalisées
if self.preferences["bul_show_ue_rangs"]:

View File

@ -78,7 +78,11 @@ def compute_sem_moys_apc_using_ects(
else:
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 ZeroDivisionError:
# peut arriver si aucun module... on ignore
moy_gen = pd.Series(np.NaN, index=etud_moy_ue_df.index)
except TypeError:
if None in ects:
formation = db.session.get(Formation, formation_id)

View File

@ -79,13 +79,15 @@ Adresses d'origine:
to : {orig_to}
cc : {orig_cc}
bcc: {orig_bcc}
---
---
\n\n"""
+ msg.body
)
current_app.logger.info(
f"""email sent to{' (mode test)' if email_test_mode_address else ''}: {msg.recipients}
f"""email sent to{
' (mode test)' if email_test_mode_address else ''
}: {msg.recipients}
from sender {msg.sender}
"""
)
@ -98,7 +100,8 @@ def get_from_addr(dept_acronym: str = None):
"""L'adresse "from" à utiliser pour envoyer un mail
Si le departement est spécifié, ou si l'attribut `g.scodoc_dept`existe,
prend le `email_from_addr` des préférences de ce département si ce champ est non vide.
prend le `email_from_addr` des préférences de ce département si ce champ
est non vide.
Sinon, utilise le paramètre global `email_from_addr`.
Sinon, la variable de config `SCODOC_MAIL_FROM`.
"""

View File

@ -0,0 +1,88 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire configuration Module Assiduités
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField, DecimalField
from wtforms.fields.simple import StringField
from wtforms.widgets import TimeInput
import datetime
class TimeField(StringField):
"""HTML5 time input."""
widget = TimeInput()
def __init__(self, label=None, validators=None, fmt="%H:%M:%S", **kwargs):
super(TimeField, self).__init__(label, validators, **kwargs)
self.fmt = fmt
self.data = None
def _value(self):
if self.raw_data:
return " ".join(self.raw_data)
if self.data and isinstance(self.data, str):
self.data = datetime.time(*map(int, self.data.split(":")))
return self.data and self.data.strftime(self.fmt) or ""
def process_formdata(self, valuelist):
if valuelist:
time_str = " ".join(valuelist)
try:
components = time_str.split(":")
hour = 0
minutes = 0
seconds = 0
if len(components) in range(2, 4):
hour = int(components[0])
minutes = int(components[1])
if len(components) == 3:
seconds = int(components[2])
else:
raise ValueError
self.data = datetime.time(hour, minutes, seconds)
except ValueError:
self.data = None
raise ValueError(self.gettext("Not a valid time string"))
class ConfigAssiduitesForm(FlaskForm):
"Formulaire paramétrage Module Assiduités"
morning_time = TimeField("Début de la journée")
lunch_time = TimeField("Heure de midi (date pivot entre Matin et Après Midi)")
afternoon_time = TimeField("Fin de la journée")
tick_time = DecimalField("Granularité de la Time Line (temps en minutes)", places=0)
submit = SubmitField("Valider")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -0,0 +1,72 @@
"""
Formulaire configuration liens personalisés (menu "Liens")
"""
from flask import g, url_for
from flask_wtf import FlaskForm
from wtforms import FieldList, Form, validators
from wtforms.fields.simple import BooleanField, StringField, SubmitField
from app.models import ScoDocSiteConfig
class _PersonalizedLinksForm(FlaskForm):
"form. définition des liens personnalisés"
# construit dynamiquement ci-dessous
def PersonalizedLinksForm() -> _PersonalizedLinksForm:
"Création d'un formulaire pour éditer les liens"
# Formulaire dynamique, on créé une classe ad-hoc
class F(_PersonalizedLinksForm):
pass
F.links_by_id = dict(enumerate(ScoDocSiteConfig.get_perso_links()))
def _gen_link_form(idx):
setattr(
F,
f"link_{idx}",
StringField(
f"Titre",
validators=[
validators.Optional(),
validators.Length(min=1, max=80),
],
default="",
render_kw={"size": 6},
),
)
setattr(
F,
f"link_url_{idx}",
StringField(
f"URL",
description="adresse, incluant le http.",
validators=[
validators.Optional(),
validators.URL(),
validators.Length(min=1, max=256),
],
default="",
),
)
setattr(
F,
f"link_with_args_{idx}",
BooleanField(
f"ajouter arguments",
description="query string avec ids",
),
)
# Initialise un champ de saisie par lien
for idx in F.links_by_id:
_gen_link_form(idx)
_gen_link_form("new")
F.submit = SubmitField("Valider")
F.cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
return F()

View File

@ -81,3 +81,5 @@ from app.models.but_refcomp import (
from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
from app.models.config import ScoDocSiteConfig
from app.models.assiduites import Assiduite, Justificatif

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

@ -0,0 +1,386 @@
# -*- coding: UTF-8 -*
"""Gestion de l'assiduité (assiduités + justificatifs)
"""
from datetime import datetime
from app import db, log
from app.models import ModuleImpl, Scolog
from app.models.etudiants import Identite
from app.auth.models import User
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
localize_datetime,
)
from flask_sqlalchemy.query import Query
class Assiduite(db.Model):
"""
Représente une assiduité:
- une plage horaire lié à un état et un étudiant
- un module si spécifiée
- une description si spécifiée
"""
__tablename__ = "assiduites"
id = db.Column(db.Integer, primary_key=True, nullable=False)
assiduite_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
moduleimpl_id = db.Column(
db.Integer,
db.ForeignKey("notes_moduleimpl.id", ondelete="SET NULL"),
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(db.Integer, nullable=False)
description = db.Column(db.Text)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
)
est_just = db.Column(db.Boolean, server_default="false", nullable=False)
external_data = db.Column(db.JSON, nullable=True)
# Déclare la relation "joined" car on va très souvent vouloir récupérer
# l'étudiant en même tant que l'assiduité (perf.: évite nouvelle requete SQL)
etudiant = db.relationship("Identite", back_populates="assiduites", lazy="joined")
def to_dict(self, format_api=True) -> dict:
"""Retourne la représentation json de l'assiduité"""
etat = self.etat
username = self.user_id
if format_api:
etat = EtatAssiduite.inverse().get(self.etat).name
if self.user_id is not None:
user: User = db.session.get(User, self.user_id)
if user is None:
username = "Non renseigné"
else:
username = user.get_prenomnom()
data = {
"assiduite_id": self.id,
"etudid": self.etudid,
"code_nip": self.etudiant.code_nip,
"moduleimpl_id": self.moduleimpl_id,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"desc": self.description,
"entry_date": self.entry_date,
"user_id": username,
"est_just": self.est_just,
"external_data": self.external_data,
}
return data
def __str__(self) -> str:
"chaine pour journaux et debug (lisible par humain français)"
try:
etat_str = EtatAssiduite(self.etat).name.lower().capitalize()
except ValueError:
etat_str = "Invalide"
return f"""{etat_str} {
"just." if self.est_just else "non just."
} de {
self.date_debut.strftime("%d/%m/%Y %Hh%M")
} à {
self.date_fin.strftime("%d/%m/%Y %Hh%M")
}"""
@classmethod
def create_assiduite(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatAssiduite,
moduleimpl: ModuleImpl = None,
description: str = None,
entry_date: datetime = None,
user_id: int = None,
est_just: bool = False,
external_data: dict = None,
) -> object or int:
"""Créer une nouvelle assiduité pour l'étudiant"""
# Vérification de non duplication des périodes
assiduites: Query = 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 not est_just:
est_just = (
len(_get_assiduites_justif(etud.etudid, date_debut, date_fin)) > 0
)
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,
description=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
external_data=external_data,
)
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,
description=description,
entry_date=entry_date,
user_id=user_id,
est_just=est_just,
external_data=external_data,
)
db.session.add(nouv_assiduite)
log(f"create_assiduite: {etud.id} {nouv_assiduite}")
Scolog.logdb(
method="create_assiduite",
etudid=etud.id,
msg=f"assiduité: {nouv_assiduite}",
)
return nouv_assiduite
class Justificatif(db.Model):
"""
Représente un justificatif:
- une plage horaire lié à un état et un étudiant
- une raison si spécifiée
- un fichier si spécifié
"""
__tablename__ = "justificatifs"
id = db.Column(db.Integer, primary_key=True)
justif_id = db.synonym("id")
date_debut = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
date_fin = db.Column(
db.DateTime(timezone=True), server_default=db.func.now(), nullable=False
)
etudid = db.Column(
db.Integer,
db.ForeignKey("identite.id", ondelete="CASCADE"),
index=True,
nullable=False,
)
etat = db.Column(
db.Integer,
nullable=False,
)
entry_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now())
user_id = db.Column(
db.Integer,
db.ForeignKey("user.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
raison = db.Column(db.Text())
# Archive_id -> sco_archives_justificatifs.py
fichier = db.Column(db.Text())
# Déclare la relation "joined" car on va très souvent vouloir récupérer
# l'étudiant en même tant que le justificatif (perf.: évite nouvelle requete SQL)
etudiant = db.relationship(
"Identite", back_populates="justificatifs", lazy="joined"
)
external_data = db.Column(db.JSON, nullable=True)
def to_dict(self, format_api: bool = False) -> dict:
"""transformation de l'objet en dictionnaire sérialisable"""
etat = self.etat
username = self.user_id
if format_api:
etat = EtatJustificatif.inverse().get(self.etat).name
if self.user_id is not None:
user: User = db.session.get(User, self.user_id)
if user is None:
username = "Non renseigné"
else:
username = user.get_prenomnom()
data = {
"justif_id": self.justif_id,
"etudid": self.etudid,
"code_nip": self.etudiant.code_nip,
"date_debut": self.date_debut,
"date_fin": self.date_fin,
"etat": etat,
"raison": self.raison,
"fichier": self.fichier,
"entry_date": self.entry_date,
"user_id": username,
"external_data": self.external_data,
}
return data
def __str__(self) -> str:
"chaine pour journaux et debug (lisible par humain français)"
try:
etat_str = EtatJustificatif(self.etat).name
except ValueError:
etat_str = "Invalide"
return f"""Justificatif {etat_str} de {
self.date_debut.strftime("%d/%m/%Y %Hh%M")
} à {
self.date_fin.strftime("%d/%m/%Y %Hh%M")
}"""
@classmethod
def create_justificatif(
cls,
etud: Identite,
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
raison: str = None,
entry_date: datetime = None,
user_id: int = None,
external_data: dict = None,
) -> object or int:
"""Créer un nouveau justificatif pour l'étudiant"""
nouv_justificatif = Justificatif(
date_debut=date_debut,
date_fin=date_fin,
etat=etat,
etudiant=etud,
raison=raison,
entry_date=entry_date,
user_id=user_id,
external_data=external_data,
)
db.session.add(nouv_justificatif)
log(f"create_justificatif: {etud.id} {nouv_justificatif}")
Scolog.logdb(
method="create_justificatif",
etudid=etud.id,
msg=f"justificatif: {nouv_justificatif}",
)
return nouv_justificatif
def is_period_conflicting(
date_debut: datetime,
date_fin: datetime,
collection: Query,
collection_cls: Assiduite or Justificatif,
) -> bool:
"""
Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes
"""
date_debut = localize_datetime(date_debut)
date_fin = localize_datetime(date_fin)
if (
collection.filter_by(date_debut=date_debut, date_fin=date_fin).first()
is not None
):
return True
count: int = collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut
).count()
return count > 0
def compute_assiduites_justified(
etudid: int, justificatifs: list[Justificatif] = None, reset: bool = False
) -> list[int]:
"""
compute_assiduites_justified_faster
Args:
etudid (int): l'identifiant de l'étudiant
justificatifs (list[Justificatif]): La liste des justificatifs qui seront utilisés
reset (bool, optional): remet à false les assiduites non justifiés. Defaults to False.
Returns:
list[int]: la liste des assiduités qui ont été justifiées.
"""
if justificatifs is None:
justificatifs: Justificatif = Justificatif.query.filter_by(etudid=etudid).all()
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
assiduites_justifiees: list[int] = []
for assi in assiduites:
if any(
assi.date_debut >= j.date_debut and assi.date_fin <= j.date_fin
for j in justificatifs
):
assi.est_just = True
assiduites_justifiees.append(assi.assiduite_id)
db.session.add(assi)
elif reset:
assi.est_just = False
db.session.add(assi)
db.session.commit()
return assiduites_justifiees
def get_assiduites_justif(assiduite_id: int, long: bool):
assi: Assiduite = Assiduite.query.get_or_404(assiduite_id)
return _get_assiduites_justif(assi.etudid, assi.date_debut, assi.date_fin, long)
def _get_assiduites_justif(
etudid: int, date_debut: datetime, date_fin: datetime, long: bool = False
):
justifs: Justificatif = Justificatif.query.filter(
Justificatif.etudid == etudid,
Justificatif.date_debut <= date_debut,
Justificatif.date_fin >= date_fin,
)
return [j.justif_id if not long else j.to_dict(True) for j in justifs]

View File

@ -471,9 +471,16 @@ class ApcNiveau(db.Model, XMLModel):
for pn in parcour_niveaux
]
else:
niveaux: list[ApcNiveau] = competence.niveaux.filter_by(
annee=f"BUT{int(annee)}"
).all()
niveaux: list[ApcNiveau] = (
ApcNiveau.query.filter_by(annee=f"BUT{int(annee)}")
.join(ApcCompetence)
.filter_by(id=competence.id)
.join(ApcParcoursNiveauCompetence)
.filter(ApcParcoursNiveauCompetence.niveau == ApcNiveau.ordre)
.join(ApcAnneeParcours)
.filter_by(parcours_id=parcour.id)
.all()
)
_cache[key] = niveaux
return niveaux

View File

@ -3,11 +3,17 @@
"""Model : site config WORK IN PROGRESS #WIP
"""
import json
import urllib.parse
from flask import flash
from app import current_app, db, log
from app.comp import bonus_spo
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_utils as scu
from datetime import time
from app.scodoc.codes_cursus import (
ABAN,
ABL,
@ -96,6 +102,10 @@ class ScoDocSiteConfig(db.Model):
"cas_logout_route": str,
"cas_validate_route": str,
"cas_attribute_id": str,
# Assiduités
"morning_time": str,
"lunch_time": str,
"afternoon_time": str,
}
def __init__(self, name, value):
@ -247,7 +257,7 @@ class ScoDocSiteConfig(db.Model):
cfg = ScoDocSiteConfig.query.filter_by(name=name).first()
if cfg is None:
return default
return cfg.value or ""
return cls.NAMES.get(name, lambda x: x)(cfg.value or "")
@classmethod
def set(cls, name: str, value: str) -> bool:
@ -336,3 +346,47 @@ class ScoDocSiteConfig(db.Model):
log(f"set_month_debut_periode2({month})")
return True
return False
@classmethod
def get_perso_links(cls) -> list["PersonalizedLink"]:
"Return links"
data_links = cls.get("personalized_links")
if not data_links:
return []
try:
links_dict = json.loads(data_links)
except json.decoder.JSONDecodeError as exc:
# Corrupted data ? erase content
cls.set("personalized_links", "")
raise ScoValueError(
"Attention: liens personnalisés erronés: ils ont été effacés."
)
return [PersonalizedLink(**item) for item in links_dict]
@classmethod
def set_perso_links(cls, links: list["PersonalizedLink"] = None):
"Store all links"
if not links:
links = []
links_dict = [link.to_dict() for link in links]
data_links = json.dumps(links_dict)
cls.set("personalized_links", data_links)
class PersonalizedLink:
def __init__(self, title: str = "", url: str = "", with_args: bool = False):
self.title = str(title or "")
self.url = str(url or "")
self.with_args = bool(with_args)
def get_url(self, params: dict = {}) -> str:
if not self.with_args:
return self.url
query_string = urllib.parse.urlencode(params)
if "?" in self.url:
return self.url + "&" + query_string
return self.url + "?" + query_string
def to_dict(self) -> dict:
"as dict"
return {"title": self.title, "url": self.url, "with_args": self.with_args}

View File

@ -73,6 +73,12 @@ class Identite(db.Model):
passive_deletes=True,
)
# Relations avec les assiduites et les justificatifs
assiduites = db.relationship("Assiduite", back_populates="etudiant", lazy="dynamic")
justificatifs = db.relationship(
"Justificatif", back_populates="etudiant", lazy="dynamic"
)
def __repr__(self):
return (
f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"

View File

@ -39,9 +39,11 @@ from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus, sco_preferences
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV
from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
from app.scodoc.sco_vdi import ApoEtapeVDI
from app.scodoc.sco_utils import translate_assiduites_metric
GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024 # bytes
@ -712,10 +714,14 @@ class FormSemestre(db.Model):
tuple (nb abs, nb abs justifiées)
Utilise un cache.
"""
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
return sco_abs.get_abs_count_in_interval(
etudid, self.date_debut.isoformat(), self.date_fin.isoformat()
metrique = sco_preferences.get_preference("assi_metrique", self.id)
return sco_assiduites.get_assiduites_count_in_interval(
etudid,
self.date_debut.isoformat(),
self.date_fin.isoformat(),
translate_assiduites_metric(metrique),
)
def get_codes_apogee(self, category=None) -> set[str]:
@ -812,11 +818,15 @@ class FormSemestre(db.Model):
db.session.commit()
def update_inscriptions_parcours_from_groups(self) -> None:
def update_inscriptions_parcours_from_groups(self, etudid: int = None) -> None:
"""Met à jour les inscriptions dans les parcours du semestres en
fonction des groupes de parcours.
Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS
et leur nom est le code du parcours (eg "Cyber").
Si etudid est sépcifié, n'affecte que cet étudiant,
sinon traite tous les inscrits du semestre.
"""
if self.formation.referentiel_competence_id is None:
return # safety net
@ -827,17 +837,32 @@ class FormSemestre(db.Model):
return
# Efface les inscriptions aux parcours:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription
SET parcour_id=NULL
WHERE formsemestre_id=:formsemestre_id
"""
),
{
"formsemestre_id": self.id,
},
)
if etudid:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription
SET parcour_id=NULL
WHERE formsemestre_id=:formsemestre_id
AND etudid=:etudid
"""
),
{
"formsemestre_id": self.id,
"etudid": etudid,
},
)
else:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription
SET parcour_id=NULL
WHERE formsemestre_id=:formsemestre_id
"""
),
{
"formsemestre_id": self.id,
},
)
# Inscrit les étudiants des groupes de parcours:
for group in partition.groups:
query = (
@ -855,22 +880,42 @@ class FormSemestre(db.Model):
)
continue
parcour = query.first()
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription ins
SET parcour_id=:parcour_id
FROM group_membership gm
WHERE formsemestre_id=:formsemestre_id
AND gm.etudid = ins.etudid
AND gm.group_id = :group_id
"""
),
{
"formsemestre_id": self.id,
"parcour_id": parcour.id,
"group_id": group.id,
},
)
if etudid:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription ins
SET parcour_id=:parcour_id
FROM group_membership gm
WHERE formsemestre_id=:formsemestre_id
AND ins.etudid = :etudid
AND gm.etudid = :etudid
AND gm.group_id = :group_id
"""
),
{
"etudid": etudid,
"formsemestre_id": self.id,
"parcour_id": parcour.id,
"group_id": group.id,
},
)
else:
db.session.execute(
text(
"""UPDATE notes_formsemestre_inscription ins
SET parcour_id=:parcour_id
FROM group_membership gm
WHERE formsemestre_id=:formsemestre_id
AND gm.etudid = ins.etudid
AND gm.group_id = :group_id
"""
),
{
"formsemestre_id": self.id,
"parcour_id": parcour.id,
"group_id": group.id,
},
)
db.session.commit()
def etud_validations_description_html(self, etudid: int) -> str:

View File

@ -122,6 +122,22 @@ class ModuleImpl(db.Model):
raise AccessDenied(f"Modification impossible pour {user}")
return False
def est_inscrit(self, etud: Identite) -> bool:
"""
Vérifie si l'étudiant est bien inscrit au moduleimpl
Retourne Vrai si c'est le cas, faux sinon
"""
is_module: int = (
ModuleImplInscription.query.filter_by(
etudid=etud.id, moduleimpl_id=self.id
).count()
> 0
)
return is_module
# Enseignants (chargés de TD ou TP) d'un moduleimpl
notes_modules_enseignants = db.Table(

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

@ -30,7 +30,7 @@
import html
from flask import render_template
from flask import g, render_template
from flask import request
from flask_login import current_user
@ -148,6 +148,8 @@ def sco_header(
"Main HTML page header for ScoDoc"
from app.scodoc.sco_formsemestre_status import formsemestre_page_title
if etudid is not None:
g.current_etudid = etudid
scodoc_flash_status_messages()
# Get head message from http request:

21
app/scodoc/html_sidebar.py Normal file → Executable file
View File

@ -54,9 +54,12 @@ def sidebar_common():
<h2 class="insidebar">Scolarité</h2>
<a href="{scu.ScoURL()}" class="sidebar">Semestres</a> <br>
<a href="{scu.NotesURL()}" class="sidebar">Programmes</a> <br>
<a href="{scu.AbsencesURL()}" class="sidebar">Absences</a> <br>
"""
]
if current_user.has_permission(Permission.ScoAbsChange):
H.append(
f""" <a href="{scu.AssiduitesURL()}" class="sidebar">Assiduités</a> <br> """
)
if current_user.has_permission(
Permission.ScoUsersAdmin
) or current_user.has_permission(Permission.ScoUsersView):
@ -76,7 +79,7 @@ def sidebar_common():
def sidebar(etudid: int = None):
"Main HTML page sidebar"
# rewritten from legacy DTML code
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
from app.scodoc import sco_etud
params = {}
@ -116,19 +119,18 @@ def sidebar(etudid: int = None):
)
if etud["cursem"]:
cur_sem = etud["cursem"]
nbabs, nbabsjust = sco_abs.get_abs_count(etudid, cur_sem)
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, cur_sem)
nbabsnj = nbabs - nbabsjust
H.append(
f"""<span title="absences du { cur_sem["date_debut"] } au { cur_sem["date_fin"] }">(1/2 j.)
f"""<span title="absences du { cur_sem["date_debut"] } au { cur_sem["date_fin"] }">({sco_preferences.get_preference("assi_metrique", None)})
<br>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
)
H.append("<ul>")
if current_user.has_permission(Permission.ScoAbsChange):
H.append(
f"""
<li><a href="{ url_for('absences.SignaleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('absences.JustifAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li>
<li><a href="{ url_for('absences.AnnuleAbsenceEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Supprimer</a></li>
<li><a href="{ url_for('assiduites.signal_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Ajouter</a></li>
<li><a href="{ url_for('assiduites.ajout_justificatif_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Justifier</a></li>
"""
)
if sco_preferences.get_preference("handle_billets_abs"):
@ -137,8 +139,9 @@ def sidebar(etudid: int = None):
)
H.append(
f"""
<li><a href="{ url_for('absences.CalAbs', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Calendrier</a></li>
<li><a href="{ url_for('absences.ListeAbsEtud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li>
<li><a href="{ url_for('assiduites.calendrier_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Calendrier</a></li>
<li><a href="{ url_for('assiduites.liste_assiduites_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Liste</a></li>
<li><a href="{ url_for('assiduites.bilan_etud', scodoc_dept=g.scodoc_dept, etudid=etudid) }">Bilan</a></li>
</ul>
"""
)

View File

@ -6,7 +6,7 @@ from PIL import Image as PILImage
def ImageScale(img_file, maxx, maxy):
im = PILImage.open(img_file)
im.thumbnail((maxx, maxy), PILImage.ANTIALIAS)
im.thumbnail((maxx, maxy), PILImage.LANCZOS)
out_file_str = io.BytesIO()
im.save(out_file_str, im.format)
out_file_str.seek(0)
@ -20,7 +20,7 @@ def ImageScaleH(img_file, W=None, H=90):
if W is None:
# keep aspect
W = int((im.size[0] * H) / float(im.size[1]))
im.thumbnail((W, H), PILImage.ANTIALIAS)
im.thumbnail((W, H), PILImage.LANCZOS)
out_file_str = io.BytesIO()
im.save(out_file_str, im.format)
out_file_str.seek(0)

View File

@ -1,19 +1,17 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
import datetime
import html
import traceback
from flask import g, current_app, abort
import psycopg2
import psycopg2.pool
import psycopg2.extras
from flask import g, current_app, abort
import app
import app.scodoc.sco_utils as scu
from app import log
from app.scodoc.sco_exceptions import ScoException, ScoValueError, NoteProcessError
import datetime
quote_html = html.escape

23
app/scodoc/sco_abs.py Normal file → Executable file
View File

@ -318,7 +318,7 @@ def list_abs_in_range(
Returns:
List of absences
"""
if matin != None:
if matin is not None:
matin = _toboolean(matin)
ismatin = " AND A.MATIN = %(matin)s "
else:
@ -387,7 +387,7 @@ def count_abs_just(etudid, debut, fin, matin=None, moduleimpl_id=None) -> int:
Returns:
An integer.
"""
if matin != None:
if matin is not None:
matin = _toboolean(matin)
ismatin = " AND A.MATIN = %(matin)s "
else:
@ -482,7 +482,9 @@ def _get_abs_description(a, cursor=None):
else:
a["matin"] = False
cursor.execute(
"""select * from absences where etudid=%(etudid)s and jour=%(jour)s and matin=%(matin)s order by entry_date desc""",
"""SELECT * FROM absences
WHERE etudid=%(etudid)s AND jour=%(jour)s AND matin=%(matin)s
ORDER BY entry_date desc""",
a,
)
A = cursor.dictfetchall()
@ -517,9 +519,9 @@ def list_abs_jour(date, am=True, pm=True, is_abs=True, is_just=None):
req = """SELECT DISTINCT etudid, jour, matin FROM ABSENCES A
WHERE A.jour = %(date)s
"""
if is_abs != None:
if is_abs is not None:
req += " AND A.estabs = %(is_abs)s"
if is_just != None:
if is_just is not None:
req += " AND A.estjust = %(is_just)s"
if not am:
req += " AND NOT matin "
@ -883,7 +885,7 @@ def MonthTableBody(
descr = ev[4]
#
cc = []
if color != None:
if color is not None:
cc.append('<td bgcolor="%s" class="calcell">' % color)
else:
cc.append('<td class="calcell">')
@ -896,7 +898,7 @@ def MonthTableBody(
cc.append("<a %s %s>" % (href, descr))
if legend or d == 1:
if pad_width != None:
if pad_width is not None:
n = pad_width - len(legend) # pad to 8 cars
if n > 0:
legend = (
@ -959,7 +961,7 @@ def MonthTableBody(
ev_year = int(ev[0][:4])
ev_month = int(ev[0][5:7])
ev_day = int(ev[0][8:10])
if ev[4] != None:
if ev[4] is not None:
ev_half = int(ev[4])
else:
ev_half = 0
@ -978,7 +980,7 @@ def MonthTableBody(
if len(ev) > 5 and ev[5]:
descr = ev[5]
#
if color != None:
if color is not None:
cc.append('<td bgcolor="%s" class="calcell">' % (color))
else:
cc.append('<td class="calcell">')
@ -1072,7 +1074,8 @@ def invalidate_abs_count_sem(sem):
def invalidate_abs_etud_date(etudid, date): # was invalidateAbsEtudDate
"""Doit etre appelé à chaque modification des absences pour cet étudiant et cette date.
"""Doit etre appelé à chaque modification des absences
pour cet étudiant et cette date.
Invalide cache absence et caches semestre
date: date au format ISO
"""

View File

@ -47,6 +47,7 @@ import app.scodoc.notesdb as ndb
from app.scodoc import sco_etud
from app.scodoc import sco_preferences
from app.scodoc import sco_users
from app.scodoc import sco_utils as scu
def abs_notify(etudid, date):
@ -55,14 +56,21 @@ def abs_notify(etudid, date):
(s'il n'y a pas de semestre courant, ne fait rien,
car l'etudiant n'est pas inscrit au moment de l'absence!).
"""
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
formsemestre = retreive_current_formsemestre(etudid, date)
if not formsemestre:
return # non inscrit a la date, pas de notification
nbabs, nbabsjust = sco_abs.get_abs_count_in_interval(
etudid, formsemestre.date_debut.isoformat(), formsemestre.date_fin.isoformat()
nbabs, nbabsjust = sco_assiduites.get_assiduites_count_in_interval(
etudid,
formsemestre.date_debut.isoformat(),
formsemestre.date_fin.isoformat(),
scu.translate_assiduites_metric(
sco_preferences.get_preference(
"assi_metrique", formsemestre.formsemestre_id
)
),
)
do_abs_notify(formsemestre, etudid, date, nbabs, nbabsjust)
@ -85,6 +93,7 @@ def do_abs_notify(formsemestre: FormSemestre, etudid, date, nbabs, nbabsjust):
return # abort
# Vérification fréquence (pour ne pas envoyer de mails trop souvent)
# TODO Mettre la fréquence dans les préférences assiduités
abs_notify_max_freq = sco_preferences.get_preference("abs_notify_max_freq")
destinations_filtered = []
for email_addr in destinations:
@ -174,6 +183,8 @@ def abs_notify_is_above_threshold(etudid, nbabs, nbabsjust, formsemestre_id):
(nbabs > abs_notify_abs_threshold)
(nbabs - nbabs_last_notified) > abs_notify_abs_increment
TODO Mettre à jour avec le module assiduité + fonctionnement métrique
"""
abs_notify_abs_threshold = sco_preferences.get_preference(
"abs_notify_abs_threshold", formsemestre_id

View File

@ -137,14 +137,14 @@ def doSignaleAbsence(
]
if dates:
H.append(
f"""<p>Ajout de {nbadded} absences <b>{just_str}justifiées</b>
f"""<p>Ajout de {nbadded} absences <b>{just_str}justifiées</b>
du {datedebut} au {datefin} {indication_module}
</p>
"""
)
else:
H.append(
f"""<p class="warning">Aucune date ouvrable
f"""<p class="warning">Aucune date ouvrable
entre le {datedebut} et le {datefin} !
</p>
"""
@ -152,11 +152,11 @@ def doSignaleAbsence(
H.append(
f"""<ul>
<li><a class="stdlink" href="{url_for("absences.SignaleAbsenceEtud",
<li><a class="stdlink" href="{url_for("absences.SignaleAbsenceEtud",
scodoc_dept=g.scodoc_dept, etudid=etud.id
)}">Autre absence pour <b>{etud.nomprenom}</b></a>
</li>
<li><a class="stdlink" href="{url_for("absences.CalAbs",
<li><a class="stdlink" href="{url_for("absences.CalAbs",
scodoc_dept=g.scodoc_dept, etudid=etud.id
)}">Calendrier de ses absences</a>
</li>
@ -180,8 +180,12 @@ def SignaleAbsenceEtud(): # etudid implied
"abs_require_module"
) # on utilise la pref globale car pas de sem courant
if require_module:
menu_module = """<div class="ue_warning">Pas inscrit dans un semestre courant,
et l'indication du module est requise. Donc pas de saisie d'absence possible !</div>"""
menu_module = """<div class="ue_warning">Pas
inscrit dans un semestre courant,
et l'indication du module est requise.
Donc pas de saisie d'absence possible !
</div>
"""
disabled = True
else:
menu_module = ""
@ -197,17 +201,17 @@ def SignaleAbsenceEtud(): # etudid implied
menu_module = """
<script type="text/javascript">
function form_enable_disable() {
if ( $("select#sel_moduleimpl_id").val() == "" ) {
$("#butsubmit").prop("disabled", true);
} else {
$("#butsubmit").prop("disabled", false);
if ( $("select#sel_moduleimpl_id").val() == "" ) {
$("#butsubmit").prop("disabled", true);
} else {
$("#butsubmit").prop("disabled", false);
};
}
$(document).ready(function() {
form_enable_disable();
});
</script>
<p>Module:
<p>Module:
<select id="sel_moduleimpl_id" name="moduleimpl_id"
onChange="form_enable_disable();">"""
else:
@ -250,7 +254,10 @@ def SignaleAbsenceEtud(): # etudid implied
<p>
<table><tr>
<td>Date début : </td>
<td><input type="text" name="datedebut" size="10" class="datepicker"/> <em>j/m/a</em></td>
<td>
<input type="text" name="datedebut" size="10" class="datepicker"/>
<em>j/m/a</em>
</td>
<td>&nbsp;&nbsp;&nbsp;Date fin (optionnelle):</td>
<td><input type="text" name="datefin" size="10" class="datepicker"/> <em>j/m/a</em></td>
</tr>
@ -269,14 +276,14 @@ Raison: <input type="text" name="description" size="42"/> (optionnel)
</p>
<p>
<input id="butsubmit" type="submit" value="Envoyer" disable="%(disabled)s"/>
<input id="butsubmit" type="submit" value="Envoyer" disable="%(disabled)s"/>
<em>
<p>Seuls les modules du semestre en cours apparaissent.</p>
<p>Évitez de saisir une absence pour un module qui n'est pas en place à cette date.</p>
<p>Toutes les dates sont au format jour/mois/annee.</p>
</em>
</form>
</form>
"""
% {
"etudid": etud["etudid"],
@ -354,7 +361,10 @@ def doJustifAbsence(
)
H.append(
"""<ul><li><a href="JustifAbsenceEtud?etudid=%(etudid)s">Autre justification pour <b>%(nomprenom)s</b></a></li>
"""<ul>
<li><a href="JustifAbsenceEtud?etudid=%(etudid)s">Autre justification
pour <b>%(nomprenom)s</b>
</a></li>
<li><a href="SignaleAbsenceEtud?etudid=%(etudid)s">Signaler une absence</a></li>
<li><a href="CalAbs?etudid=%(etudid)s">Calendrier de ses absences</a></li>
<li><a href="ListeAbsEtud?etudid=%(etudid)s">Liste de ses absences</a></li>
@ -389,12 +399,12 @@ def JustifAbsenceEtud(): # etudid implied
),
"""</a></td></tr></table>""",
"""
<form action="doJustifAbsence" method="get">
<form action="doJustifAbsence" method="get">
<input type="hidden" name="etudid" value="%(etudid)s">
<p>
<table><tr>
<td>Date d&eacute;but : </td>
<td>Date début : </td>
<td>
<input type="text" name="datedebut" size="10" class="datepicker"/>
</td>
@ -412,7 +422,7 @@ def JustifAbsenceEtud(): # etudid implied
Raison: <input type="text" name="description" size="42"/> (optionnel)
<p>
<input type="submit" value="Envoyer">
<input type="submit" value="Envoyer">
</form> """
% etud,
@ -458,8 +468,10 @@ def doAnnuleAbsence(datedebut, datefin, demijournee, etudid=False): # etudid im
H.append(
"""<ul><li><a href="AnnuleAbsenceEtud?etudid=%(etudid)s">Annulation d'une
autre absence pour <b>%(nomprenom)s</b></a></li>
<li><a href="SignaleAbsenceEtud?etudid=%(etudid)s">Ajout d'une absence</a></li>
<li><a href="CalAbs?etudid=%(etudid)s">Calendrier de ses absences</a></li>
<li><a href="SignaleAbsenceEtud?etudid=%(etudid)s">Ajout d'une
absence</a></li>
<li><a href="CalAbs?etudid=%(etudid)s">Calendrier de ses
absences</a></li>
</ul>
<hr>"""
% etud
@ -480,10 +492,11 @@ def AnnuleAbsenceEtud(): # etudid implied
page_title="Annulation d'une absence pour %(nomprenom)s" % etud,
),
"""<table><tr><td>
<h2><font color="#FF0000">Annulation</font> d'une absence pour %(nomprenom)s</h2>
<h2><font color="#FF0000">Annulation</font> d'une absence
pour %(nomprenom)s</h2>
</td><td>
"""
% etud, # "
% etud,
"""<a href="%s">"""
% url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid),
sco_photos.etud_photo_html(
@ -491,16 +504,19 @@ def AnnuleAbsenceEtud(): # etudid implied
title="fiche de " + etud["nomprenom"],
),
"""</a></td></tr></table>""",
"""<p>A n'utiliser que suite à une erreur de saisie ou lorsqu'il s'avère que l'étudiant était en fait présent. </p>
<p><font color="#FF0000">Si plusieurs modules sont affectés, les absences seront toutes effacées. </font></p>
"""
"""<p>A n'utiliser que suite à une erreur de saisie ou lorsqu'il s'avère que
l'étudiant était en fait présent.
</p>
<p><font color="#FF0000">Si plusieurs modules sont affectés,
les absences seront toutes effacées. </font></p>
"""
% etud,
"""<table frame="border" border="1"><tr><td>
<form action="doAnnuleAbsence" method="get">
<form action="doAnnuleAbsence" method="get">
<input type="hidden" name="etudid" value="%(etudid)s">
<p>
<table><tr>
<td>Date d&eacute;but : </td>
<td>Date début : </td>
<td>
<input type="text" name="datedebut" size="10" class="datepicker"/> <em>j/m/a</em>
</td>
@ -511,22 +527,22 @@ def AnnuleAbsenceEtud(): # etudid implied
</tr>
</table>
<input type="radio" name="demijournee" value="2" checked>journ&eacute;e(s)
<input type="radio" name="demijournee" value="2" checked>journée(s)
&nbsp;<input type="radio" name="demijournee" value="1">Matin(s)
&nbsp;<input type="radio" name="demijournee" value="0">Apr&egrave;s midi
<p>
<input type="submit" value="Supprimer les absences">
</form>
<input type="submit" value="Supprimer les absences">
</form>
</td></tr>
<tr><td>
<form action="doAnnuleJustif" method="get">
<form action="doAnnuleJustif" method="get">
<input type="hidden" name="etudid" value="%(etudid)s">
<p>
<table><tr>
<td>Date d&eacute;but : </td>
<td>Date début : </td>
<td>
<input type="text" name="datedebut0" size="10" class="datepicker"/> <em>j/m/a</em>
</td>
@ -538,15 +554,16 @@ def AnnuleAbsenceEtud(): # etudid implied
</table>
<p>
<input type="radio" name="demijournee" value="2" checked>journ&eacute;e(s)
<input type="radio" name="demijournee" value="2" checked>journée(s)
&nbsp;<input type="radio" name="demijournee" value="1">Matin(s)
&nbsp;<input type="radio" name="demijournee" value="0">Apr&egrave;s midi
<p>
<input type="submit" value="Supprimer les justificatifs">
<i>(utiliser ceci en cas de justificatif erron&eacute; saisi ind&eacute;pendemment d'une absence)</i>
</form>
<input type="submit" value="Supprimer les justificatifs">
<i>(utiliser ceci en cas de justificatif erroné saisi indépendemment
d'une absence)</i>
</form>
</td></tr></table>"""
% etud,
html_sco_header.sco_footer(),
@ -591,8 +608,10 @@ def doAnnuleJustif(datedebut0, datefin0, demijournee): # etudid implied
H.append(
"""<ul><li><a href="AnnuleAbsenceEtud?etudid=%(etudid)s">Annulation d'une
autre absence pour <b>%(nomprenom)s</b></a></li>
<li><a href="SignaleAbsenceEtud?etudid=%(etudid)s">Ajout d'une absence</a></li>
<li><a href="CalAbs?etudid=%(etudid)s">Calendrier de ses absences</a></li>
<li><a href="SignaleAbsenceEtud?etudid=%(etudid)s">Ajout d'une
absence</a></li>
<li><a href="CalAbs?etudid=%(etudid)s">Calendrier de ses
absences</a></li>
</ul>
<hr>"""
% etud
@ -634,8 +653,11 @@ def AnnuleAbsencesDatesNoJust(etudid, dates, moduleimpl_id=None):
# supr les absences non justifiees
for date in dates:
cursor.execute(
"""DELETE FROM absences
WHERE etudid=%(etudid)s and (not estjust) and jour=%(date)s and moduleimpl_id=%(moduleimpl_id)s
"""DELETE FROM absences
WHERE etudid=%(etudid)s
AND (not estjust)
AND jour=%(date)s
AND moduleimpl_id=%(moduleimpl_id)s
""",
vars(),
)
@ -643,8 +665,11 @@ def AnnuleAbsencesDatesNoJust(etudid, dates, moduleimpl_id=None):
# s'assure que les justificatifs ne sont pas "absents"
for date in dates:
cursor.execute(
"""UPDATE absences SET estabs=FALSE
WHERE etudid=%(etudid)s AND jour=%(date)s AND moduleimpl_id=%(moduleimpl_id)s
"""UPDATE absences
SET estabs=FALSE
WHERE etudid=%(etudid)s
AND jour=%(date)s
AND moduleimpl_id=%(moduleimpl_id)s
""",
vars(),
)
@ -724,7 +749,7 @@ def _convert_sco_year(year) -> int:
year = int(year)
if year > 1900 and year < 2999:
return year
except:
except ValueError:
raise ScoValueError("année scolaire invalide")
@ -771,7 +796,8 @@ def CalAbs(etudid, sco_year=None):
"""<b><font color="#EE0000">A : absence NON justifiée</font><br>
<font color="#F8B7B0">a : absence justifiée</font><br>
<font color="#8EA2C6">X : justification sans absence</font><br>
%d absences sur l'année, dont %d justifiées (soit %d non justifiées)</b> <em>(%d justificatifs inutilisés)</em>
%d absences sur l'année, dont %d justifiées (soit %d non justifiées)</b>
<em>(%d justificatifs inutilisés)</em>
</p>
"""
% (nbabs, nbabsjust, nbabs - nbabsjust, len(justifs_noabs)),
@ -790,7 +816,8 @@ def CalAbs(etudid, sco_year=None):
"""<form method="GET" action="CalAbs" name="f">""",
"""<input type="hidden" name="etudid" value="%s"/>""" % etudid,
"""Année scolaire %s-%s""" % (annee_scolaire, annee_scolaire + 1),
"""&nbsp;&nbsp;Changer année: <select name="sco_year" onchange="document.f.submit()">""",
"""&nbsp;&nbsp;Changer année:
<select name="sco_year" onchange="document.f.submit()">""",
]
for y in range(annee_courante, min(annee_courante - 6, annee_scolaire - 6), -1):
H.append("""<option value="%s" """ % y)
@ -819,7 +846,8 @@ def ListeAbsEtud(
etudid:
with_evals: indique les evaluations aux dates d'absences
absjust_only: si vrai, renvoie table absences justifiées
sco_year: année scolaire à utiliser. Si non spécifier, utilie l'année en cours. e.g. "2005"
sco_year: année scolaire à utiliser.
Si non spécifier, utilie l'année en cours. e.g. "2005"
"""
# si absjust_only, table absjust seule (export xls ou pdf)
absjust_only = scu.to_bool(absjust_only)
@ -941,10 +969,12 @@ def _tables_abs_etud(
for a in absnonjust + absjust:
cursor.execute(
"""SELECT eval.*
FROM notes_evaluation eval, notes_moduleimpl_inscription mi, notes_moduleimpl m
WHERE eval.jour = %(jour)s
FROM notes_evaluation eval,
notes_moduleimpl_inscription mi,
notes_moduleimpl m
WHERE eval.jour = %(jour)s
and eval.moduleimpl_id = m.id
and mi.moduleimpl_id = m.id
and mi.moduleimpl_id = m.id
and mi.etudid = %(etudid)s
""",
{"jour": a["jour"].strftime("%Y-%m-%d"), "etudid": etudid},
@ -984,9 +1014,10 @@ def _tables_abs_etud(
)[0]
if format == "html":
ex.append(
f"""<a title="{mod['module']['titre']}" href="{url_for('notes.moduleimpl_status',
scodoc_dept=g.scodoc_dept, moduleimpl_id=mod["moduleimpl_id"])}
">{mod["module"]["code"] or "(module sans code)"}</a>"""
f"""<a title="{mod['module']['titre']}" href="{
url_for('notes.moduleimpl_status',
scodoc_dept=g.scodoc_dept, moduleimpl_id=mod["moduleimpl_id"])
}">{mod["module"]["code"] or "(module sans code)"}</a>"""
)
else:
ex.append(mod["module"]["code"] or "(module sans code)")
@ -1003,7 +1034,7 @@ def _tables_abs_etud(
if format == "html":
ex.append(
f"""<a title="{mod['module']['titre']}"
href="{url_for('notes.moduleimpl_status',
href="{url_for('notes.moduleimpl_status',
scodoc_dept=g.scodoc_dept, moduleimpl_id=mod["moduleimpl_id"])}
">{mod["module"]["code"] or '(module sans code)'}</a>"""
)

View File

@ -68,7 +68,7 @@ from app import log, ScoDocJSONEncoder
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 ScoException, ScoPermissionDenied
from app.scodoc import html_sco_header
@ -86,6 +86,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:
@ -107,6 +112,8 @@ class BaseArchiver(object):
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: int):
"""
@ -114,8 +121,7 @@ class BaseArchiver(object):
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):
@ -140,8 +146,7 @@ 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]

View File

@ -34,6 +34,7 @@ from flask import flash, render_template, url_for
from flask import g, request
from flask_login import current_user
from app.models import Identite
import app.scodoc.sco_utils as scu
from app.scodoc import sco_import_etuds
from app.scodoc import sco_groups
@ -351,10 +352,8 @@ def etudarchive_import_files(
):
"Importe des fichiers"
def callback(etud, data, filename):
return _store_etud_file_to_new_archive(
etud["etudid"], data, filename, description
)
def callback(etud: Identite, data, filename):
return _store_etud_file_to_new_archive(etud.id, data, filename, description)
# Utilise la fontion developpée au depart pour les photos
(

View File

@ -0,0 +1,231 @@
"""
Gestion de l'archivage des justificatifs
Ecrit par Matthias HARTMANN
"""
import os
from datetime import datetime
from shutil import rmtree
from app.models import Identite
from app.scodoc.sco_archives import BaseArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import is_iso_formated
class Trace:
"""gestionnaire de la trace des fichiers justificatifs"""
def __init__(self, path: str) -> None:
self.path: str = path + "/_trace.csv"
self.content: dict[str, list[datetime, datetime, str]] = {}
self.import_from_file()
def import_from_file(self):
"""import trace from file"""
if os.path.isfile(self.path):
with open(self.path, "r", encoding="utf-8") as file:
for line in file.readlines():
csv = line.split(",")
if len(csv) < 4:
continue
fname: str = csv[0]
entry_date: datetime = is_iso_formated(csv[1], True)
delete_date: datetime = is_iso_formated(csv[2], True)
user_id = csv[3]
self.content[fname] = [entry_date, delete_date, user_id]
def set_trace(self, *fnames: str, mode: str = "entry", current_user: str = None):
"""Ajoute une trace du fichier donné
mode : entry / delete
"""
modes: list[str] = ["entry", "delete", "user_id"]
for fname in fnames:
if fname in modes:
continue
traced: list[datetime, datetime, str] = self.content.get(fname, False)
if not traced:
self.content[fname] = [None, None, None]
traced = self.content[fname]
traced[modes.index(mode)] = (
datetime.now() if mode != "user_id" else current_user
)
self.save_trace()
def save_trace(self):
"""Enregistre la trace dans le fichier _trace.csv"""
lines: list[str] = []
for fname, traced in self.content.items():
date_fin: datetime or None = traced[1].isoformat() if traced[1] else "None"
if traced[0] is not None:
lines.append(f"{fname},{traced[0].isoformat()},{date_fin}, {traced[2]}")
with open(self.path, "w", encoding="utf-8") as file:
file.write("\n".join(lines))
def get_trace(
self, fnames: list[str] = None
) -> dict[str, list[datetime, datetime, str]]:
"""Récupère la trace pour les noms de fichiers.
si aucun nom n'est donné, récupère tous les fichiers"""
if fnames is None:
return self.content
traced: dict = {}
for fname in fnames:
traced[fname] = self.content.get(fname, None)
return traced
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>
[_trace.csv]
<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 = "",
user_id: str = None,
) -> 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)
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(fname, mode="entry")
if user_id is not None:
trace.set_trace(fname, mode="user_id", current_user=user_id)
return self.get_archive_name(archive_id), fname
def delete_justificatif(
self,
etudid: int,
archive_name: str,
filename: str = None,
has_trace: bool = True,
):
"""
Supprime une archive ou un fichier particulier de l'archivage de l'étudiant donné
Si trace == True : sauvegarde le nom du/des fichier(s) supprimé(s) dans la trace de l'étudiant
"""
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):
if has_trace:
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(filename, mode="delete")
os.remove(path)
else:
if has_trace:
trace = Trace(self.get_obj_dir(etudid))
trace.set_trace(*self.list_archive(archive_id), mode="delete")
self.delete_archive(
os.path.join(
self.get_obj_dir(etudid),
archive_id,
)
)
def list_justificatifs(
self, archive_name: str, etudid: int
) -> list[tuple[str, int]]:
"""
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)
trace: Trace = Trace(self.get_obj_dir(etudid))
traced = trace.get_trace(filenames)
retour = [(key, value[2]) for key, value in traced.items()]
return retour
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)
def remove_dept_archive(self, dept_id: int = None):
"""
Supprime toutes les archives d'un département (ou de tous les départements)
Supprime aussi les fichiers de trace
"""
self.set_dept_id(1)
self.initialize()
if dept_id is None:
rmtree(self.root, ignore_errors=True)
else:
rmtree(os.path.join(self.root, str(dept_id)), ignore_errors=True)
def get_trace(
self, etudid: int, *fnames: str
) -> dict[str, list[datetime, datetime]]:
"""Récupère la trace des justificatifs de l'étudiant"""
trace = Trace(self.get_obj_dir(etudid))
return trace.get_trace(fnames)

View File

@ -0,0 +1,503 @@
"""
Ecrit par Matthias Hartmann.
"""
from datetime import date, datetime, time, timedelta
from pytz import UTC
from app import log
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
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_preferences
from app.scodoc import sco_cache
from app.scodoc import sco_etud
from flask_sqlalchemy.query import Query
class CountCalculator:
"""Classe qui gére le comptage des assiduités"""
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 reset(self):
"""Remet à zero le compteur"""
self.days = []
self.half_days = []
self.hours = 0.0
self.count = 0
def add_half_day(self, day: date, is_morning: bool = True):
"""Ajoute une demi journée dans le comptage"""
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):
"""Ajoute un jour dans le comptage"""
if day not in self.days:
self.days.append(day)
def check_in_morning(self, period: tuple[datetime, datetime]) -> bool:
"""Vérifiée si la période donnée fait partie du matin
(Test sur la date de début)
"""
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, bornes=False
)
return in_morning
def check_in_evening(self, period: tuple[datetime, datetime]) -> bool:
"""Vérifie si la période fait partie de l'aprèm
(test sur la date de début)
"""
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):
"""Calcule les métriques sur une assiduité longue (plus d'un jour)"""
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():
# TODO : Utiliser la préférence de département : workdays
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):
"""Calcule les métriques pour la collection d'assiduité donnée"""
assi: Assiduite
assiduites: list[Assiduite] = (
assiduites.all() if isinstance(assiduites, Assiduite) else assiduites
)
for assi in assiduites:
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, int or float]:
"""Retourne les métriques sous la forme d'un dictionnaire"""
return {
"compte": self.count,
"journee": len(self.days),
"demi": len(self.half_days),
"heure": round(self.hours, 2),
}
def get_assiduites_stats(
assiduites: Query, metric: str = "all", filtered: dict[str, object] = None
) -> dict[str, int or float]:
"""Compte les assiduités en fonction des filtres"""
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, Assiduite, filtered[key]
)
elif key == "est_just":
assiduites = filter_assiduites_by_est_just(assiduites, filtered[key])
elif key == "user_id":
assiduites = filter_by_user_id(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 filter_assiduites_by_etat(assiduites: Assiduite, etat: str) -> Query:
"""
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_assiduites_by_est_just(assiduites: Assiduite, est_just: bool) -> Query:
"""
Filtrage d'une collection d'assiduites en fonction de s'ils sont justifiés
"""
return assiduites.filter_by(est_just=est_just)
def filter_by_user_id(
collection: Assiduite or Justificatif,
user_id: int,
) -> Query:
"""
Filtrage d'une collection en fonction de l'user_id
"""
return collection.filter_by(user_id=user_id)
def filter_by_date(
collection: Assiduite or Justificatif,
collection_cls: Assiduite or Justificatif,
date_deb: datetime = None,
date_fin: datetime = None,
strict: bool = False,
) -> Query:
"""
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) -> Query:
"""
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_by_module_impl(assiduites: Assiduite, module_impl_id: int or None) -> Query:
"""
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(
collection_query: Assiduite or Justificatif,
collection_class: Assiduite or Justificatif,
formsemestre: FormSemestre,
) -> Query:
"""
Filtrage d'une collection en fonction d'un formsemestre
"""
if formsemestre is None:
return collection_query.filter(False)
collection_result = (
collection_query.join(Identite, collection_class.etudid == Identite.id)
.join(
FormSemestreInscription,
Identite.id == FormSemestreInscription.etudid,
)
.filter(FormSemestreInscription.formsemestre_id == formsemestre.id)
)
form_date_debut = formsemestre.date_debut + timedelta(days=1)
form_date_fin = formsemestre.date_fin + timedelta(days=1)
collection_result = collection_result.filter(
collection_class.date_debut >= form_date_debut
)
return collection_result.filter(collection_class.date_fin <= form_date_fin)
def justifies(justi: Justificatif, obj: bool = False) -> list[int] or Query:
"""
Retourne la liste des assiduite_id qui sont justifié par la justification
Une assiduité est justifiée si elle est COMPLETEMENT ou PARTIELLEMENT
comprise dans la plage du justificatif
et que l'état du justificatif est "valide".
Renvoie des id si obj == False, sinon les Assiduités
"""
if justi.etat != scu.EtatJustificatif.VALIDE:
return []
assiduites_query: Assiduite = Assiduite.query.filter_by(etudid=justi.etudid)
assiduites_query = assiduites_query.filter(
Assiduite.date_debut >= justi.date_debut, Assiduite.date_fin <= justi.date_fin
)
if not obj:
return [assi.id for assi in assiduites_query.all()]
return assiduites_query
def get_all_justified(
etudid: int, date_deb: datetime = None, date_fin: datetime = None
) -> Query:
"""Retourne toutes les assiduités justifiées sur une période"""
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)
justified = Assiduite.query.filter_by(est_just=True, etudid=etudid)
after = filter_by_date(
justified,
Assiduite,
date_deb,
date_fin,
)
return after
# Gestion du cache
def get_assiduites_count(etudid, sem):
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non justifiées, nb abs justifiées)
Utilise un cache.
"""
metrique = sco_preferences.get_preference("assi_metrique", sem["formsemestre_id"])
return get_assiduites_count_in_interval(
etudid,
sem["date_debut_iso"],
sem["date_fin_iso"],
scu.translate_assiduites_metric(metrique),
)
def get_assiduites_count_in_interval(
etudid, date_debut_iso, date_fin_iso, metrique="demi"
):
"""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
+ f"{metrique}_assiduites"
)
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_fin_iso, True)
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
assiduites = assiduites.filter(Assiduite.etat == scu.EtatAssiduite.ABSENT)
justificatifs: Justificatif = Justificatif.query.filter_by(etudid=etudid)
assiduites = filter_by_date(assiduites, Assiduite, date_debut, date_fin)
justificatifs = filter_by_date(
justificatifs, Justificatif, date_debut, date_fin
)
calculator: CountCalculator = CountCalculator()
calculator.compute_assiduites(assiduites)
nb_abs: dict = calculator.to_dict()[metrique]
abs_just: list[Assiduite] = get_all_justified(etudid, date_debut, date_fin)
calculator.reset()
calculator.compute_assiduites(abs_just)
nb_abs_just: dict = calculator.to_dict()[metrique]
r = (nb_abs, nb_abs_just)
ans = sco_cache.AbsSemEtudCache.set(key, r)
if not ans:
log("warning: get_assiduites_count failed to cache")
return r
def invalidate_assiduites_count(etudid, sem):
"""Invalidate (clear) cached counts"""
date_debut = sem["date_debut_iso"]
date_fin = sem["date_fin_iso"]
for met in scu.AssiduitesMetrics.TAG:
key = str(etudid) + "_" + date_debut + "_" + date_fin + f"{met}_assiduites"
sco_cache.AbsSemEtudCache.delete(key)
def invalidate_assiduites_count_sem(sem):
"""Invalidate (clear) cached abs counts for all the students of this semestre"""
inscriptions = (
sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits(
sem["formsemestre_id"]
)
)
for ins in inscriptions:
invalidate_assiduites_count(ins["etudid"], sem)
def invalidate_assiduites_etud_date(etudid, date: datetime):
"""Doit etre appelé à chaque modification des assiduites
pour cet étudiant et cette date.
Invalide cache absence et caches semestre
"""
from app.scodoc import sco_compute_moy
# Semestres a cette date:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)
if len(etud) == 0:
return
else:
etud = etud[0]
sems = [
sem
for sem in etud["sems"]
if scu.is_iso_formated(sem["date_debut_iso"], True).replace(tzinfo=UTC)
<= date.replace(tzinfo=UTC)
and scu.is_iso_formated(sem["date_fin_iso"], True).replace(tzinfo=UTC)
>= date.replace(tzinfo=UTC)
]
# Invalide les PDF et les absences:
for sem in sems:
# Inval cache bulletin et/ou note_table
if sco_compute_moy.formsemestre_expressions_use_abscounts(
sem["formsemestre_id"]
):
# certaines formules utilisent les absences
pdfonly = False
else:
# efface toujours le PDF car il affiche en général les absences
pdfonly = True
sco_cache.invalidate_formsemestre(
formsemestre_id=sem["formsemestre_id"], pdfonly=pdfonly
)
# Inval cache compteurs absences:
invalidate_assiduites_count(etudid, sem)
def simple_invalidate_cache(obj: dict, etudid: str or int = None):
"""Invalide le cache de l'étudiant et du / des semestres"""
date_debut = (
obj["date_debut"]
if isinstance(obj["date_debut"], datetime)
else scu.is_iso_formated(obj["date_debut"], True)
)
date_fin = (
obj["date_fin"]
if isinstance(obj["date_fin"], datetime)
else scu.is_iso_formated(obj["date_fin"], True)
)
etudid = etudid if etudid is not None else obj["etudid"]
invalidate_assiduites_etud_date(etudid, date_debut)
invalidate_assiduites_etud_date(etudid, date_fin)

View File

@ -56,7 +56,7 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
from app.scodoc import sco_abs_views
from app.scodoc import sco_bulletins_generator
from app.scodoc import sco_bulletins_json
@ -142,7 +142,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
Cette fonction est utilisée pour les bulletins CLASSIQUES (DUT, ...)
en HTML et PDF, mais pas ceux en XML.
"""
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
if version not in scu.BULLETINS_VERSIONS:
raise ValueError("invalid version code !")
@ -197,7 +197,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
pid = partition["partition_id"]
partitions_etud_groups[pid] = sco_groups.get_etud_groups_in_partition(pid)
# --- Absences
I["nbabs"], I["nbabsjust"] = sco_abs.get_abs_count(etudid, nt.sem)
I["nbabs"], I["nbabsjust"] = sco_assiduites.get_assiduites_count(etudid, nt.sem)
# --- Decision Jury
infos, dpv = etud_descr_situation_semestre(
@ -489,7 +489,7 @@ def _ue_mod_bulletin(
) # peut etre 'NI'
is_malus = mod["module"]["module_type"] == ModuleType.MALUS
if bul_show_abs_modules:
nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem)
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, sem)
mod_abs = [nbabs, nbabsjust]
mod["mod_abs_txt"] = scu.fmt_abs(mod_abs)
else:

View File

@ -43,7 +43,7 @@ from app.models.formsemestre import FormSemestre
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
from app.scodoc import sco_edit_ue
from app.scodoc import sco_evaluations
from app.scodoc import sco_evaluation_db
@ -112,7 +112,7 @@ def formsemestre_bulletinetud_published_dict(
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if not etudid in nt.identdict:
if etudid not in nt.identdict:
abort(404, "etudiant non inscrit dans ce semestre")
d = {"type": "classic", "version": "0"}
if (not sem["bul_hide_xml"]) or force_publishing:
@ -297,7 +297,7 @@ def formsemestre_bulletinetud_published_dict(
# --- Absences
if prefs["bul_show_abs"]:
nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem)
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, sem)
d["absences"] = dict(nbabs=nbabs, nbabsjust=nbabsjust)
# --- Décision Jury

View File

@ -51,7 +51,7 @@ import app.scodoc.notesdb as ndb
from app import log
from app.but.bulletin_but_xml_compat import bulletin_but_xml_compat
from app.models.formsemestre import FormSemestre
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_ue
from app.scodoc import sco_evaluation_db
@ -63,6 +63,7 @@ from app.scodoc import sco_etud
from app.scodoc import sco_xml
from app.scodoc.sco_xml import quote_xml_attr
# -------- Bulletin en XML
# (fonction séparée: n'utilise pas formsemestre_bulletinetud_dict()
# pour simplifier le code, mais attention a la maintenance !)
@ -369,7 +370,7 @@ def make_xml_formsemestre_bulletinetud(
# --- Absences
if sco_preferences.get_preference("bul_show_abs", formsemestre_id):
nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem)
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, sem)
doc.append(Element("absences", nbabs=str(nbabs), nbabsjust=str(nbabsjust)))
# --- Decision Jury
if (

View File

@ -40,7 +40,6 @@ from flask import request
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
from app.models import ScolarNews
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
@ -217,19 +216,19 @@ def do_evaluation_etat(
(TotalNbMissing > 0)
and (E["evaluation_type"] != scu.EVALUATION_RATTRAPAGE)
and (E["evaluation_type"] != scu.EVALUATION_SESSION2)
and not is_malus
):
complete = False
else:
complete = True
if (
TotalNbMissing > 0
and ((TotalNbMissing == TotalNbAtt) or E["publish_incomplete"])
and not is_malus
):
evalattente = True
else:
evalattente = False
complete = (
(TotalNbMissing == 0)
or (E["evaluation_type"] == scu.EVALUATION_RATTRAPAGE)
or (E["evaluation_type"] == scu.EVALUATION_SESSION2)
)
evalattente = (TotalNbMissing > 0) and (
(TotalNbMissing == TotalNbAtt) or E["publish_incomplete"]
)
# mais ne met pas en attente les evals immediates sans aucune notes:
if E["publish_incomplete"] and nb_notes == 0:
evalattente = False
@ -668,10 +667,13 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True):
group_id = sco_groups.get_default_group(formsemestre_id)
H.append(
f"""<span class="noprint"><a href="{url_for(
'absences.EtatAbsencesDate',
'assiduites.get_etat_abs_date',
scodoc_dept=g.scodoc_dept,
group_ids=group_id,
date=E["jour"]
desc=E["description"],
jour=E["jour"],
heure_debut=E["heure_debut"],
heure_fin=E["heure_fin"],
)
}">(absences ce jour)</a></span>"""
)

View File

@ -93,7 +93,7 @@ _formsemestreEditor = ndb.EditableTable(
)
def get_formsemestre(formsemestre_id: int):
def get_formsemestre(formsemestre_id: int) -> dict:
"list ONE formsemestre"
if formsemestre_id is None:
raise ValueError("get_formsemestre: id manquant")

View File

@ -29,7 +29,10 @@
"""
import flask
from flask import g, url_for, request
from flask_login import current_user
from app.models.config import ScoDocSiteConfig, PersonalizedLink
from app.models import FormSemestre
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc.TrivialFormulator import TrivialFormulator
@ -58,6 +61,28 @@ def formsemestre_custommenu_get(formsemestre_id):
return vals
def build_context_dict(formsemestre_id: int) -> dict:
"""returns a dict with "current" ids, to pass to external links"""
params = {
"dept": g.scodoc_dept,
"formsemestre_id": formsemestre_id,
"user_name": current_user.user_name,
}
cas_id = getattr(current_user, "cas_id", None)
if cas_id:
params["cas_id"] = cas_id
etudid = getattr(g, "current_etudid", None)
if etudid is not None:
params["etudid"] = etudid
evaluation_id = getattr(g, "current_evaluation_id", None)
if evaluation_id is not None:
params["evaluation_id"] = evaluation_id
moduleimpl_id = getattr(g, "current_moduleimpl_id", None)
if moduleimpl_id is not None:
params["moduleimpl_id"] = moduleimpl_id
return params
def formsemestre_custommenu_html(formsemestre_id):
"HTML code for custom menu"
menu = []
@ -66,6 +91,13 @@ def formsemestre_custommenu_html(formsemestre_id):
ics_url = sco_edt_cal.formsemestre_get_ics_url(sem)
if ics_url:
menu.append({"title": "Emploi du temps (ics)", "url": ics_url})
# Liens globaux (config. générale)
params = build_context_dict(formsemestre_id)
for link in ScoDocSiteConfig.get_perso_links():
if link.title:
menu.append({"title": link.title, "url": link.get_url(params=params)})
# Liens propres à ce semestre
menu += formsemestre_custommenu_get(formsemestre_id)
menu.append(
{
@ -79,14 +111,25 @@ def formsemestre_custommenu_html(formsemestre_id):
def formsemestre_custommenu_edit(formsemestre_id):
"""Dialog to edit the custom menu"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
dest_url = (
scu.NotesURL() + "/formsemestre_status?formsemestre_id=%s" % formsemestre_id
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
dest_url = url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
H = [
html_sco_header.html_sem_header("Modification du menu du semestre "),
"""<p class="help">Ce menu, spécifique à chaque semestre, peut être utilisé pour placer des liens vers vos applications préférées.</p>
<p class="help">Procédez en plusieurs fois si vous voulez ajouter plusieurs items.</p>""",
"""<div class="help">
<p>Ce menu, spécifique à chaque semestre, peut être utilisé pour
placer des liens vers vos applications préférées.
</p>
<p>Les premiers liens du menus sont définis au niveau global (pour tous les
départements) et peuvent être modifiés par l'administrateur via la page
de configuration principale.
</p>
<p>Procédez en plusieurs fois si vous voulez ajouter plusieurs items.
</p>
""",
]
descr = [
("formsemestre_id", {"input_type": "hidden"}),

View File

@ -314,7 +314,7 @@ def do_formsemestre_inscription_with_modules(
formsemestre_id=formsemestre_id,
)
# Mise à jour des inscriptions aux parcours:
formsemestre.update_inscriptions_parcours_from_groups()
formsemestre.update_inscriptions_parcours_from_groups(etudid=etudid)
def formsemestre_inscription_with_modules_etud(

78
app/scodoc/sco_formsemestre_status.py Normal file → Executable file
View File

@ -219,13 +219,14 @@ def formsemestre_status_menubar(formsemestre: FormSemestre) -> str:
"enabled": True,
"helpmsg": "",
},
{
"title": "Vérifier absences aux évaluations",
"endpoint": "notes.formsemestre_check_absences_html",
"args": {"formsemestre_id": formsemestre_id},
"enabled": True,
"helpmsg": "",
},
# TODO: Mettre à jour avec module Assiduités
# {
# "title": "Vérifier absences aux évaluations",
# "endpoint": "notes.formsemestre_check_absences_html",
# "args": {"formsemestre_id": formsemestre_id},
# "enabled": True,
# "helpmsg": "",
# },
{
"title": "Lister tous les enseignants",
"endpoint": "notes.formsemestre_enseignants_list",
@ -837,40 +838,33 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
weekday = datetime.datetime.today().weekday()
try:
if with_absences:
first_monday = sco_abs.ddmmyyyy(
formsemestre.date_debut.strftime("%d/%m/%Y")
).prev_monday()
form_abs_tmpl = f"""
<td>
<a href="%(url_etat)s">absences</a>
</td>
<td>
<form action="{url_for(
"absences.SignaleAbsenceGrSemestre", scodoc_dept=g.scodoc_dept
)}" method="get">
<input type="hidden" name="datefin" value="{
formsemestre.date_fin.strftime("%d/%m/%Y")}"/>
<input type="hidden" name="group_ids" value="%(group_id)s"/>
<input type="hidden" name="destination" value="{destination}"/>
<input type="submit" value="Saisir abs des" />
<select name="datedebut" class="noprint">
<a class="btn" href="{
url_for("assiduites.visu_assi_group", scodoc_dept=g.scodoc_dept)
}?group_ids=%(group_id)s&date_debut={formsemestre.date_debut.isoformat()}&date_fin={formsemestre.date_fin.isoformat()}"><button>Visualiser l'assiduité</button></a>
"""
date = first_monday
for idx, jour in enumerate(sco_abs.day_names()):
form_abs_tmpl += f"""<option value="{date}" {
'selected' if idx == weekday else ''
}>{jour}s</option>"""
date = date.next_day()
form_abs_tmpl += f"""
</select>
<a href="{
url_for("absences.choix_semaine", scodoc_dept=g.scodoc_dept)
}?group_id=%(group_id)s">saisie par semaine</a>
</form></td>
<a class="btn" href="{
url_for("assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept)
}?group_ids=%(group_id)s&jour={
datetime.date.today().isoformat()
}&formsemestre_id={formsemestre.id}"><button>Saisie journalière</button></a>
<a class="btn" href="{
url_for("assiduites.signal_assiduites_diff", scodoc_dept=g.scodoc_dept)
}?group_ids=%(group_id)s&formsemestre_id={
formsemestre.formsemestre_id
}"><button>Saisie différée</button></a>
</td>
"""
else:
form_abs_tmpl = ""
form_abs_tmpl = f"""
<td>
<a class="btn" href="{
url_for("assiduites.visu_assiduites_group", scodoc_dept=g.scodoc_dept)
}?group_ids=%(group_id)s&jour={datetime.date.today().isoformat()}&formsemestre_id={formsemestre.id}"><button>Voir l'assiduité</button></a>
</td>
"""
except ScoInvalidDateError: # dates incorrectes dans semestres ?
form_abs_tmpl = ""
#
@ -917,8 +911,7 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
"""
)
if with_absences:
H.append(form_abs_tmpl % group)
H.append(form_abs_tmpl % group)
H.append("</tr>")
H.append("</table>")
@ -935,12 +928,11 @@ def _make_listes_sem(formsemestre: FormSemestre, with_absences=True):
H.append("</p>")
if sco_groups.sco_permissions_check.can_change_groups(formsemestre.id):
H.append(
f"""<h4><a
href="{
url_for("scolar.edit_partition_form",
formsemestre_id=formsemestre.id,
scodoc_dept=g.scodoc_dept,
)
f"""<h4><a class="stdlink"
href="{url_for("scolar.partition_editor",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
edit_partition=1)
}">Ajouter une partition</a></h4>"""
)

View File

@ -54,7 +54,7 @@ from app.scodoc.codes_cursus import *
from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
from app.scodoc import html_sco_header
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
from app.scodoc import codes_cursus
from app.scodoc import sco_cache
from app.scodoc import sco_edit_ue
@ -704,7 +704,7 @@ def formsemestre_recap_parcours_table(
f"""<td class="rcp_moy">{scu.fmt_note(nt.get_etud_moy_gen(etudid))}</td>"""
)
# Absences (nb d'abs non just. dans ce semestre)
nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem)
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, sem)
H.append(f"""<td class="rcp_abs">{nbabs - nbabsjust}</td>""")
# UEs

View File

@ -684,7 +684,7 @@ def change_etud_group_in_partition(etudid: int, group: GroupDescr) -> bool:
# - Update parcours
if group.partition.partition_name == scu.PARTITION_PARCOURS:
formsemestre.update_inscriptions_parcours_from_groups()
formsemestre.update_inscriptions_parcours_from_groups(etudid=etudid)
# - invalidate cache
sco_cache.invalidate_formsemestre(

View File

@ -33,7 +33,6 @@
import collections
import datetime
import operator
import urllib
from urllib.parse import parse_qs
import time
@ -42,6 +41,8 @@ import time
from flask import url_for, g, request
from flask_login import current_user
from app import db
from app.models import FormSemestre
import app.scodoc.sco_utils as scu
from app.scodoc import html_sco_header
from app.scodoc import sco_abs
@ -65,6 +66,7 @@ JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [
CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS
# view:
def groups_view(
group_ids=(),
@ -285,7 +287,7 @@ if (group_id) {
return "\n".join(H)
class DisplayedGroupsInfos(object):
class DisplayedGroupsInfos:
"""Container with attributes describing groups to display in the page
.groups_query_args : 'group_ids=xxx&group_ids=yyy'
.base_url : url de la requete, avec les groupes, sans les autres paramètres
@ -346,7 +348,7 @@ class DisplayedGroupsInfos(object):
self.tous_les_etuds_du_sem = (
False # affiche tous les etuds du semestre ? (si un seul semestre)
)
self.sems = collections.OrderedDict() # formsemestre_id : sem
self.sems = {} # formsemestre_id : sem
self.formsemestre = None
self.formsemestre_id = formsemestre_id
self.nbdem = 0 # nombre d'étudiants démissionnaires en tout
@ -422,6 +424,13 @@ class DisplayedGroupsInfos(object):
H.append(f'<input type="hidden" name="group_ids" value="{group_id}"/>')
return "\n".join(H)
def get_formsemestre(self) -> FormSemestre:
return (
db.session.get(FormSemestre, self.formsemestre_id)
if self.formsemestre_id
else None
)
# Ancien ZScolar.group_list renommé ici en group_table
def groups_table(

View File

@ -138,10 +138,13 @@ def moduleimpl_evaluation_menu(evaluation_id, nbnotes=0) -> str:
},
{
"title": "Absences ce jour",
"endpoint": "absences.EtatAbsencesDate",
"endpoint": "assiduites.get_etat_abs_date",
"args": {
"date": E["jour"],
"group_ids": group_id,
"desc": E["description"],
"jour": E["jour"],
"heure_debut": E["heure_debut"],
"heure_fin": E["heure_fin"],
},
"enabled": E["jour"],
},
@ -191,6 +194,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
if not isinstance(moduleimpl_id, int):
raise ScoInvalidIdType("moduleimpl_id must be an integer !")
modimpl: ModuleImpl = ModuleImpl.query.get_or_404(moduleimpl_id)
g.current_moduleimpl_id = modimpl.id
module: Module = modimpl.module
formsemestre_id = modimpl.formsemestre_id
formsemestre: FormSemestre = modimpl.formsemestre
@ -560,7 +564,10 @@ def _ligne_evaluation(
if modimpl.module.ue.type != UE_SPORT:
# Avertissement si coefs x poids nuls
if coef < scu.NOTES_PRECISION:
H.append("""<span class="eval_warning_coef">coef. nul !</span>""")
if modimpl.module.module_type == scu.ModuleType.MALUS:
H.append("""<span class="eval_warning_coef">malus</span>""")
else:
H.append("""<span class="eval_warning_coef">coef. nul !</span>""")
elif is_apc:
# visualisation des poids (Hinton map)
H.append(_evaluation_poids_html(evaluation, max_poids))

View File

@ -57,6 +57,8 @@ _SCO_PERMISSIONS = (
(1 << 29, "ScoUsersChangeCASId", "Paramétrer l'id CAS"),
#
(1 << 40, "ScoEtudChangePhoto", "Modifier la photo d'un étudiant"),
# Permissions du module Assiduité)
(1 << 50, "ScoJustifView", "Visualisation des fichiers justificatifs"),
# Attention: les permissions sont codées sur 64 bits.
)
@ -71,7 +73,7 @@ class Permission:
@staticmethod
def init_permissions():
for (perm, symbol, description) in _SCO_PERMISSIONS:
for perm, symbol, description in _SCO_PERMISSIONS:
setattr(Permission, symbol, perm)
Permission.description[symbol] = description
Permission.permission_by_name[symbol] = perm

69
app/scodoc/sco_photos.py Normal file → Executable file
View File

@ -59,7 +59,7 @@ from flask.helpers import make_response, url_for
from app import log
from app import db
from app.models import Identite
from app.models import Identite, Scolog
from app.scodoc import sco_etud
from app.scodoc import sco_portal_apogee
from app.scodoc import sco_preferences
@ -86,12 +86,12 @@ def unknown_image_url() -> str:
return url_for("scolar.get_photo_image", scodoc_dept=g.scodoc_dept, etudid="")
def photo_portal_url(etud):
def photo_portal_url(code_nip: str):
"""Returns external URL to retreive photo on portal,
or None if no portal configured"""
photo_url = sco_portal_apogee.get_photo_url()
if photo_url and etud["code_nip"]:
return photo_url + "?nip=" + etud["code_nip"]
if photo_url and code_nip:
return photo_url + "?nip=" + code_nip
else:
return None
@ -120,13 +120,13 @@ def etud_photo_url(etud: dict, size="small", fast=False) -> str:
path = photo_pathname(etud["photo_filename"], size=size)
if not path:
# Portail ?
ext_url = photo_portal_url(etud)
ext_url = photo_portal_url(etud["code_nip"])
if not ext_url:
# fallback: Photo "unknown"
photo_url = unknown_image_url()
else:
# essaie de copier la photo du portail
new_path, _ = copy_portal_photo_to_fs(etud)
new_path, _ = copy_portal_photo_to_fs(etud["etudid"])
if not new_path:
# copy failed, can we use external url ?
# nb: rarement utile, car le portail est rarement accessible sans authentification
@ -148,11 +148,11 @@ def get_photo_image(etudid=None, size="small"):
filename = photo_pathname(etud.photo_filename, size=size)
if not filename:
filename = UNKNOWN_IMAGE_PATH
r = _http_jpeg_file(filename)
r = build_image_response(filename)
return r
def _http_jpeg_file(filename):
def build_image_response(filename):
"""returns an image as a Flask response"""
st = os.stat(filename)
last_modified = st.st_mtime # float timestamp
@ -185,8 +185,8 @@ def _http_jpeg_file(filename):
return response
def etud_photo_is_local(etud: dict, size="small"):
return photo_pathname(etud["photo_filename"], size=size)
def etud_photo_is_local(photo_filename: str, size="small"):
return photo_pathname(photo_filename, size=size)
def etud_photo_html(etud: dict = None, etudid=None, title=None, size="small") -> str:
@ -205,7 +205,7 @@ def etud_photo_html(etud: dict = None, etudid=None, title=None, size="small") ->
nom = etud.get("nomprenom", etud["nom_disp"])
if title is None:
title = nom
if not etud_photo_is_local(etud):
if not etud_photo_is_local(etud["photo_filename"]):
fallback = (
f"""onerror='this.onerror = null; this.src="{unknown_image_url()}"'"""
)
@ -254,7 +254,7 @@ def photo_pathname(photo_filename: str, size="orig"):
return False
def store_photo(etud: dict, data, filename: str) -> tuple[bool, str]:
def store_photo(etud: Identite, data, filename: str) -> tuple[bool, str]:
"""Store image for this etud.
If there is an existing photo, it is erased and replaced.
data is a bytes string with image raw data.
@ -268,21 +268,17 @@ def store_photo(etud: dict, data, filename: str) -> tuple[bool, str]:
if filesize < 10 or filesize > MAX_FILE_SIZE:
return False, f"Fichier image '{filename}' de taille invalide ! ({filesize})"
try:
saved_filename = save_image(etud["etudid"], data)
saved_filename = save_image(etud, data)
except (OSError, PIL.UnidentifiedImageError) as exc:
raise ScoValueError(
msg="Fichier d'image '{filename}' invalide ou format non supporté"
) from exc
# update database:
etud["photo_filename"] = saved_filename
etud["foto"] = None
cnx = ndb.GetDBConnexion()
sco_etud.identite_edit_nocheck(cnx, etud)
cnx.commit()
#
logdb(cnx, method="changePhoto", msg=saved_filename, etudid=etud["etudid"])
etud.photo_filename = saved_filename
db.session.add(etud)
Scolog.logdb(method="changePhoto", msg=saved_filename, etudid=etud.id)
db.session.commit()
#
return True, "ok"
@ -313,7 +309,7 @@ def suppress_photo(etud: Identite) -> None:
# Internal functions
def save_image(etudid, data):
def save_image(etud: Identite, data: bytes):
"""data is a bytes string.
Save image in JPEG in 2 sizes (original and h90).
Returns filename (relative to PHOTO_DIR), without extension
@ -322,7 +318,7 @@ def save_image(etudid, data):
data_file.write(data)
data_file.seek(0)
img = PILImage.open(data_file)
filename = get_new_filename(etudid)
filename = get_new_filename(etud)
path = os.path.join(PHOTO_DIR, filename)
log("saving %dx%d jpeg to %s" % (img.size[0], img.size[1], path))
img = img.convert("RGB")
@ -338,16 +334,16 @@ def scale_height(img, W=None, H=REDUCED_HEIGHT):
if W is None:
# keep aspect
W = int((img.size[0] * H) / img.size[1])
img.thumbnail((W, H), PILImage.ANTIALIAS)
img.thumbnail((W, H), PILImage.LANCZOS)
return img
def get_new_filename(etudid):
def get_new_filename(etud: Identite):
"""Constructs a random filename to store a new image.
The path is constructed as: Fxx/etudid
"""
dept = g.scodoc_dept
return find_new_dir() + dept + "_" + str(etudid)
dept = etud.departement.acronym
return find_new_dir() + dept + "_" + str(etud.id)
def find_new_dir():
@ -367,15 +363,14 @@ def find_new_dir():
return d + "/"
def copy_portal_photo_to_fs(etud: dict):
def copy_portal_photo_to_fs(etudid: int):
"""Copy the photo from portal (distant website) to local fs.
Returns rel. path or None if copy failed, with a diagnostic message
"""
if "nomprenom" not in etud:
sco_etud.format_etud_ident(etud)
url = photo_portal_url(etud)
etud: Identite = Identite.query.get_or_404(etudid)
url = photo_portal_url(etud.code_nip)
if not url:
return None, f"""{etud['nomprenom']}: pas de code NIP"""
return None, f"""{etud.nomprenom}: pas de code NIP"""
portal_timeout = sco_preferences.get_preference("portal_timeout")
error_message = None
try:
@ -394,11 +389,11 @@ def copy_portal_photo_to_fs(etud: dict):
log(f"copy_portal_photo_to_fs: {error_message}")
return (
None,
f"""{etud["nomprenom"]}: erreur chargement de {url}\n{error_message}""",
f"""{etud.nomprenom}: erreur chargement de {url}\n{error_message}""",
)
if r.status_code != 200:
log(f"copy_portal_photo_to_fs: download failed {r.status_code }")
return None, f"""{etud["nomprenom"]}: erreur chargement de {url}"""
return None, f"""{etud.nomprenom}: erreur chargement de {url}"""
data = r.content # image bytes
try:
@ -410,8 +405,8 @@ def copy_portal_photo_to_fs(etud: dict):
if status:
log(f"copy_portal_photo_to_fs: copied {url}")
return (
photo_pathname(etud["photo_filename"]),
f"{etud['nomprenom']}: photo chargée",
photo_pathname(etud.photo_filename),
f"{etud.nomprenom}: photo chargée",
)
else:
return None, f"{etud['nomprenom']}: <b>{error_message}</b>"
return None, f"{etud.nomprenom}: <b>{error_message}</b>"

View File

@ -37,7 +37,7 @@ from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre
import app.scodoc.sco_utils as scu
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
from app.scodoc import sco_cache
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups
@ -107,7 +107,7 @@ def etud_get_poursuite_info(sem, etud):
rangs.append(["rang_" + codeModule, rangModule])
# Absences
nbabs, nbabsjust = sco_abs.get_abs_count(etudid, nt.sem)
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, nt.sem)
if (
dec
and not sem_descr # not sem_descr pour ne prendre que le semestre validé le plus récent

View File

@ -162,7 +162,7 @@ def _convert_pref_type(p, pref_spec):
# special case for float values (where NULL means 0)
p["value"] = float(p["value"] or 0)
elif typ == "int":
p["value"] = int(p["value"] or 0)
p["value"] = int(float(p["value"] or 0))
else:
raise ValueError("invalid preference type")
@ -204,6 +204,7 @@ PREF_CATEGORIES = (
("misc", {"title": "Divers"}),
("apc", {"title": "BUT et Approches par Compétences"}),
("abs", {"title": "Suivi des absences", "related": ("bul",)}),
("assi", {"title": "Gestion de l'assiduité"}),
("portal", {"title": "Liaison avec portail (Apogée, etc)"}),
("apogee", {"title": "Exports Apogée"}),
(
@ -598,6 +599,86 @@ class BasePreferences(object):
"category": "abs",
},
),
# Assiduités
(
"forcer_module",
{
"initvalue": 0,
"title": "Forcer la déclaration du module.",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "assi",
},
),
(
"forcer_present",
{
"initvalue": 0,
"title": "Forcer l'appel des présents",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "assi",
},
),
(
"periode_defaut",
{
"initvalue": 2.0,
"size": 10,
"title": "Durée par défaut d'un créneau",
"type": "float",
"category": "assi",
"only_global": True,
"explanation": "Durée d'un créneau en heure. Utilisé dans les pages de saisie",
},
),
(
"assi_etat_defaut",
{
"initvalue": "aucun",
"input_type": "menu",
"labels": ["aucun", "present", "retard", "absent"],
"allowed_values": ["aucun", "present", "retard", "absent"],
"title": "Définir l'état par défaut",
"category": "assi",
},
),
(
"non_travail",
{
"initvalue": "sam, dim",
"title": "Jours non travaillés",
"size": 40,
"category": "assi",
"only_global": True,
"explanation": "Liste des jours (lun,mar,mer,jeu,ven,sam,dim)",
},
),
(
"assi_metrique",
{
"initvalue": "1/2 J.",
"input_type": "menu",
"labels": scu.AssiduitesMetrics.LONG,
"allowed_values": scu.AssiduitesMetrics.SHORT,
"title": "Métrique de l'assiduité",
"explanation": "Unité utilisée dans la fiche étudiante, les bilans et les calculs",
"category": "assi",
"only_global": True,
},
),
(
"assi_seuil",
{
"initvalue": 3,
"size": 10,
"title": "Seuil d'alerte des absences",
"type": "int",
"explanation": "Nombres d'absences limite avant alerte dans le bilan (utilisation de l'unité métrique ↑ )",
"category": "assi",
"only_global": True,
},
),
# portal
(
"portal_url",
@ -1700,7 +1781,7 @@ class BasePreferences(object):
(
"feuille_releve_abs_taille",
{
"initvalue": "A3",
"initvalue": "A4",
"input_type": "menu",
"labels": ["A3", "A4"],
"allowed_values": ["A3", "A4"],

View File

@ -39,7 +39,7 @@ from flask_login import current_user
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, Identite, ScolarAutorisationInscription
from app.scodoc import sco_abs
from app.scodoc import sco_assiduites
from app.scodoc import codes_cursus
from app.scodoc import sco_groups
from app.scodoc import sco_etud
@ -139,7 +139,7 @@ def feuille_preparation_jury(formsemestre_id):
main_partition_id, ""
)
# absences:
e_nbabs, e_nbabsjust = sco_abs.get_abs_count(etud.id, sem)
e_nbabs, e_nbabsjust = sco_assiduites.get_assiduites_count(etud.id, sem)
nbabs[etud.id] = e_nbabs
nbabsjust[etud.id] = e_nbabs - e_nbabsjust

View File

@ -43,7 +43,8 @@ from PIL import Image as PILImage
import flask
from flask import url_for, g, send_file, request
from app import log
from app import db, log
from app.models import Identite
import app.scodoc.sco_utils as scu
from app.scodoc.TrivialFormulator import TrivialFormulator
from app.scodoc.sco_exceptions import ScoValueError
@ -146,7 +147,7 @@ def trombino_html(groups_infos):
'<span class="trombi_box"><span class="trombi-photo" id="trombi-%s">'
% t["etudid"]
)
if sco_photos.etud_photo_is_local(t, size="small"):
if sco_photos.etud_photo_is_local(t["photo_filename"], size="small"):
foto = sco_photos.etud_photo_html(t, title="")
else: # la photo n'est pas immédiatement dispo
foto = f"""<span class="unloaded_img" id="{t["etudid"]
@ -194,7 +195,7 @@ def check_local_photos_availability(groups_infos, fmt=""):
nb_missing = 0
for t in groups_infos.members:
_ = sco_photos.etud_photo_url(t) # -> copy distant files if needed
if not sco_photos.etud_photo_is_local(t):
if not sco_photos.etud_photo_is_local(t["photo_filename"]):
nb_missing += 1
if nb_missing > 0:
parameters = {"group_ids": groups_infos.group_ids, "format": fmt}
@ -278,7 +279,7 @@ def trombino_copy_photos(group_ids=[], dialog_confirmed=False):
msg = []
nok = 0
for etud in groups_infos.members:
path, diag = sco_photos.copy_portal_photo_to_fs(etud)
path, diag = sco_photos.copy_portal_photo_to_fs(etud["etudid"])
msg.append(diag)
if path:
nok += 1
@ -539,7 +540,7 @@ def photos_import_files_form(group_ids=()):
return flask.redirect(back_url)
else:
def callback(etud, data, filename):
def callback(etud: Identite, data, filename):
return sco_photos.store_photo(etud, data, filename)
(
@ -640,14 +641,12 @@ def zip_excel_import_files(
if normname in filename_to_etudid:
etudid = filename_to_etudid[normname]
# ok, store photo
try:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
del filename_to_etudid[normname]
except Exception as exc:
etud: Identite = db.session.get(Identite, etudid)
if not etud:
raise ScoValueError(
f"ID étudiant invalide: {etudid}", dest_url=back_url
) from exc
)
del filename_to_etudid[normname]
status, err_msg = callback(
etud,
data,

View File

@ -32,13 +32,14 @@ import base64
import bisect
import collections
import datetime
from enum import IntEnum
from enum import IntEnum, Enum
import io
import json
from hashlib import md5
import numbers
import os
import re
from shutil import get_terminal_size
import _thread
import time
import unicodedata
@ -50,6 +51,10 @@ from PIL import Image as PILImage
import pydot
import requests
from pytz import timezone
import dateutil.parser as dtparser
import flask
from flask import g, request, Response
from flask import flash, url_for, make_response
@ -91,6 +96,216 @@ ETATS_INSCRIPTION = {
}
def print_progress_bar(
iteration,
total,
prefix="",
suffix="",
finish_msg="",
decimals=1,
length=100,
fill="",
autosize=False,
):
"""
Affiche une progress bar à un point donné (mettre dans une boucle pour rendre dynamique)
@params:
iteration - Required : index du point donné (Int)
total - Required : nombre total avant complétion (eg: len(List))
prefix - Optional : Préfix -> écrit à gauche de la barre (Str)
suffix - Optional : Suffix -> écrit à droite de la barre (Str)
decimals - Optional : nombres de chiffres après la virgule (Int)
length - Optional : taille de la barre en nombre de caractères (Int)
fill - Optional : charactère de remplissange de la barre (Str)
autosize - Optional : Choisir automatiquement la taille de la barre en fonction du terminal (Bool)
"""
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
color = TerminalColor.RED
if 50 >= float(percent) > 25:
color = TerminalColor.MAGENTA
if 75 >= float(percent) > 50:
color = TerminalColor.BLUE
if 90 >= float(percent) > 75:
color = TerminalColor.CYAN
if 100 >= float(percent) > 90:
color = TerminalColor.GREEN
styling = f"{prefix} |{fill}| {percent}% {suffix}"
if autosize:
cols, _ = get_terminal_size(fallback=(length, 1))
length = cols - len(styling)
filled_length = int(length * iteration // total)
pg_bar = fill * filled_length + "-" * (length - filled_length)
print(f"\r{color}{styling.replace(fill, pg_bar)}{TerminalColor.RESET}", end="\r")
# Affiche une nouvelle ligne vide
if iteration == total:
print(f"\n{finish_msg}")
class TerminalColor:
"""Ensemble de couleur pour terminaux"""
BLUE = "\033[94m"
CYAN = "\033[96m"
GREEN = "\033[92m"
MAGENTA = "\033[95m"
RED = "\033[91m"
RESET = "\033[0m"
class BiDirectionalEnum(Enum):
"""Permet la recherche inverse d'un enum
Condition : les clés et les valeurs doivent être uniques
les clés doivent être en MAJUSCULES
"""
@classmethod
def contains(cls, attr: str):
"""Vérifie sur un attribut existe dans l'enum"""
return attr.upper() in cls._member_names_
@classmethod
def all(cls, keys=True):
"""Retourne toutes les clés de l'enum"""
return cls._member_names_ if keys else list(cls._value2member_map_.keys())
@classmethod
def get(cls, attr: str, default: any = None):
"""Récupère une valeur à partir de son attribut"""
val = None
try:
val = cls[attr.upper()]
except (KeyError, AttributeError):
val = default
return val
@classmethod
def inverse(cls):
"""Retourne un dictionnaire représentant la map inverse de l'Enum"""
return cls._value2member_map_
class EtatAssiduite(int, BiDirectionalEnum):
"""Code des états d'assiduité"""
# Stockés en BD ne pas modifier
PRESENT = 0
RETARD = 1
ABSENT = 2
class EtatJustificatif(int, BiDirectionalEnum):
"""Code des états des justificatifs"""
# Stockés en BD ne pas modifier
VALIDE = 0
NON_VALIDE = 1
ATTENTE = 2
MODIFIE = 3
def is_iso_formated(date: str, convert=False) -> bool or datetime.datetime or None:
"""
Vérifie si une date est au format iso
Retourne un booléen Vrai (ou un objet Datetime si convert = True)
si l'objet est au format iso
Retourne Faux si l'objet n'est pas au format et convert = False
Retourne None sinon
"""
try:
date: datetime.datetime = dtparser.isoparse(date)
return date if convert else True
except (dtparser.ParserError, ValueError, TypeError):
return None if convert else False
def localize_datetime(date: datetime.datetime or str) -> datetime.datetime:
"""Ajoute un timecode UTC à la date donnée."""
if isinstance(date, str):
date = is_iso_formated(date, convert=True)
new_date: datetime.datetime = date
if new_date.tzinfo is None:
try:
new_date = timezone("Europe/Paris").localize(date)
except OverflowError:
new_date = timezone("UTC").localize(date)
return new_date
def is_period_overlapping(
periode: tuple[datetime.datetime, datetime.datetime],
interval: tuple[datetime.datetime, datetime.datetime],
bornes: bool = True,
) -> bool:
"""
Vérifie si la période et l'interval s'intersectent
si strict == True : les extrémitées ne comptes pas
Retourne Vrai si c'est le cas, faux sinon
"""
p_deb, p_fin = periode
i_deb, i_fin = interval
if bornes:
return p_deb <= i_fin and p_fin >= i_deb
return p_deb < i_fin and p_fin > i_deb
class AssiduitesMetrics:
"""Labels associés au métrique de l'assiduité"""
SHORT: list[str] = ["1/2 J.", "J.", "H."]
LONG: list[str] = ["Demi-journée", "Journée", "Heure"]
TAG: list[str] = ["demi", "journee", "heure"]
def translate_assiduites_metric(metric, inverse=True, short=True) -> str:
"""
translate_assiduites_metric
SHORT[true] : "J." "H." "N." "1/2 J."
SHORT[false] : "Journée" "Heure" "Nombre" "Demi-Journée"
inverse[false] : "demi" -> "1/2 J."
inverse[true] : "1/2 J." -> "demi"
Args:
metric (str): la métrique à traduire
inverse (bool, optional). Defaults to True.
short (bool, optional). Defaults to True.
Returns:
str: la métrique traduite
"""
index: int = None
if not inverse:
try:
index = AssiduitesMetrics.TAG.index(metric)
return (
AssiduitesMetrics.SHORT[index]
if short
else AssiduitesMetrics.LONG[index]
)
except ValueError:
return None
try:
index = (
AssiduitesMetrics.SHORT.index(metric)
if short
else AssiduitesMetrics.LONG.index(metric)
)
return AssiduitesMetrics.TAG[index]
except ValueError:
return None
# Types de modules
class ModuleType(IntEnum):
"""Code des types de module."""
@ -448,6 +663,13 @@ def AbsencesURL():
]
def AssiduitesURL():
"""URL of Assiduités"""
return url_for("assiduites.index_html", scodoc_dept=g.scodoc_dept)[
: -len("/index_html")
]
def UsersURL():
"""URL of Users
e.g. https://scodoc.xxx.fr/ScoDoc/DEPT/Scolarite/Users

View File

@ -0,0 +1,583 @@
* {
box-sizing: border-box;
}
.selectors>* {
margin: 10px 0;
}
.selectors:disabled {
opacity: 0.5;
}
#validate_selectors {
margin: 15px 0;
}
.no-display {
display: none !important;
}
/* === Gestion de la timeline === */
#tl_date {
visibility: hidden;
width: 0px;
height: 0px;
position: absolute;
left: 15%;
}
.infos {
position: relative;
width: fit-content;
display: flex;
justify-content: space-evenly;
align-content: center;
}
#datestr {
cursor: pointer;
background-color: white;
border: 1px #444 solid;
border-radius: 5px;
padding: 5px;
min-width: 100px;
display: inline-block;
min-height: 20px;
}
#tl_slider {
width: 90%;
cursor: grab;
/* visibility: hidden; */
}
#datestr,
#time {
width: fit-content;
}
.ui-slider-handle.tl_handle {
background: none;
width: 25px;
height: 25px;
visibility: visible;
background-position: top;
background-size: cover;
border: none;
top: -180%;
cursor: grab;
}
#l_handle {
background-image: url(../icons/l_handle.svg);
}
#r_handle {
background-image: url(../icons/r_handle.svg);
}
.ui-slider-range.ui-widget-header.ui-corner-all {
background-color: #F9C768;
background-image: none;
opacity: 0.50;
visibility: visible;
}
/* === Gestion des etuds row === */
.etud_row {
display: grid;
grid-template-columns: 2% 20% 55% auto;
gap: 16px;
background-color: white;
border-radius: 15px;
padding: 4px 16px;
margin: 0.5% 0;
box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
-webkit-box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
-moz-box-shadow: -1px 12px 5px -8px rgba(68, 68, 68, 0.61);
max-width: 800px;
}
.etud_row * {
display: flex;
justify-content: center;
align-items: center;
height: 50px;
}
.etud_row.def,
.etud_row.dem {
background-color: #c8c8c8;
}
/* --- Index --- */
.etud_row .index_field {
grid-column: 1;
}
/* --- Nom étud --- */
.etud_row .name_field {
grid-column: 2;
height: 100%;
justify-content: start;
}
.etud_row .name_field .name_set {
flex-direction: column;
align-items: flex-start;
margin: 0 5%;
}
.etud_row.def .nom::after,
.tr.def .td.sticky span::after {
display: block;
content: " (Déf.)";
color: #d61616;
margin-left: 2px;
}
.etud_row.dem .nom::after,
.tr.dem .td.sticky span::after {
display: block;
content: " (Dém.)";
color: #d61616;
margin-left: 2px;
}
.etud_row .name_field .name_set * {
padding: 0;
margin: 0;
}
.etud_row .name_field .name_set h4 {
font-size: small;
font-weight: 600;
}
.etud_row .name_field .name_set h5 {
font-size: x-small;
}
.etud_row .pdp {
border-radius: 15px;
}
/* --- Barre assiduités --- */
.etud_row .assiduites_bar {
display: grid;
grid-template-columns: 7px 1fr;
gap: 13px;
grid-column: 3;
position: relative;
}
.etud_row .assiduites_bar .filler {
height: 5px;
width: 90%;
background-color: white;
border: 1px solid #444;
}
.etud_row .assiduites_bar #prevDateAssi {
height: 7px;
width: 7px;
background-color: white;
border: 1px solid #444;
margin: 0px 8px;
}
.etud_row .assiduites_bar #prevDateAssi.single {
height: 9px;
width: 9px;
}
.etud_row.conflit {
background-color: #ff0000c2;
}
.etud_row .assiduites_bar .absent,
.demo.absent {
background-color: #F1A69C !important;
}
.etud_row .assiduites_bar .present,
.demo.present {
background-color: #9CF1AF !important;
}
.etud_row .assiduites_bar .retard,
.demo.retard {
background-color: #F1D99C !important;
}
.etud_row .assiduites_bar .justified,
.demo.justified {
background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #7059FF 4px, #7059FF 8px);
}
.etud_row .assiduites_bar .invalid_justified,
.demo.invalid_justified {
background-image: repeating-linear-gradient(225deg, transparent, transparent 4px, #d61616 4px, #d61616 8px);
}
/* --- Boutons assiduités --- */
.etud_row .btns_field {
grid-column: 4;
}
.btns_field:disabled {
opacity: 0.7;
}
.etud_row .btns_field * {
margin: 0 5%;
cursor: pointer;
width: 35px;
height: 35px;
}
.rbtn {
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.rbtn::before {
content: "";
display: inline-block;
width: 35px;
height: 35px;
background-position: center;
background-size: cover;
}
.rbtn.present::before {
background-image: url(../icons/present.svg);
}
.rbtn.absent::before {
background-image: url(../icons/absent.svg);
}
.rbtn.aucun::before {
background-image: url(../icons/aucun.svg);
}
.rbtn.retard::before {
background-image: url(../icons/retard.svg);
}
.rbtn:checked:before {
outline: 3px solid #7059FF;
border-radius: 5px;
}
.rbtn:focus {
outline: none !important;
}
/*<== Modal conflit ==>*/
.modal {
display: block;
position: fixed;
z-index: 500;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.4);
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: 80%;
height: 320px;
position: relative;
border-radius: 10px;
}
.close {
color: #111;
position: absolute;
right: 5px;
top: 0px;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
/* Ajout de styles pour la frise chronologique */
.modal-timeline {
display: flex;
flex-direction: column;
align-items: stretch;
margin-bottom: 20px;
}
.time-labels,
.assiduites-container {
display: flex;
justify-content: space-between;
position: relative;
}
.time-label {
font-size: 14px;
margin-bottom: 4px;
}
.assiduite {
position: absolute;
top: 20px;
cursor: pointer;
border-radius: 4px;
z-index: 10;
height: 100px;
padding: 4px;
}
.assiduite-info {
display: flex;
flex-direction: column;
height: 100%;
justify-content: space-between;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.assiduites-container {
min-height: 20px;
height: calc(50% - 60px);
/* Augmentation de la hauteur du conteneur des assiduités */
position: relative;
margin-bottom: 10px;
}
.action-buttons {
position: absolute;
text-align: center;
display: flex;
justify-content: space-evenly;
align-items: center;
height: 60px;
width: 100%;
bottom: 5%;
}
/* Ajout de la classe CSS pour la bordure en pointillés */
.assiduite.selected {
border: 2px dashed black;
}
.assiduite-special {
height: 120px;
position: absolute;
z-index: 5;
border: 2px solid #000;
background-color: rgba(36, 36, 36, 0.25);
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, rgba(81, 81, 81, 0.61) 5px, rgba(81, 81, 81, 0.61) 10px);
border-radius: 5px;
}
/*<== Info sur l'assiduité sélectionnée ==>*/
.modal-assiduite-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: max-content;
position: relative;
border-radius: 10px;
display: none;
}
.modal-assiduite-content.show {
display: block;
}
.modal-assiduite-content .infos {
display: flex;
flex-direction: column;
justify-content: space-evenly;
align-items: flex-start;
}
/*<=== Mass Action ==>*/
.mass-selection {
display: flex;
justify-content: flex-start;
align-items: center;
width: 100%;
margin: 2% 0;
}
.mass-selection span {
margin: 0 1%;
}
.mass-selection .rbtn {
background-color: transparent;
cursor: pointer;
}
/*<== Loader ==> */
.loader-container {
display: none;
/* Cacher le loader par défaut */
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
/* Fond semi-transparent pour bloquer les clics */
z-index: 9999;
/* Placer le loader au-dessus de tout le contenu */
}
.loader {
border: 6px solid #f3f3f3;
border-radius: 50%;
border-top: 6px solid #3498db;
width: 60px;
height: 60px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: translate(-50%, -50%) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) rotate(360deg);
}
}
.fieldsplit {
display: flex;
justify-content: flex-start;
align-items: center;
flex-direction: column;
}
.fieldsplit legend {
margin: 0;
}
#page-assiduite-content {
display: flex;
flex-wrap: wrap;
gap: 5%;
flex-direction: column;
}
#page-assiduite-content>* {
margin: 1.5% 0;
}
.rouge {
color: crimson;
}
.legende {
border: 1px dashed #333;
width: 75%;
padding: 20px;
}
.order {
background-image: url(../icons/sort.svg);
}
.filter {
background-image: url(../icons/filter.svg);
}
[name='destroyFile'] {
-webkit-appearance: none;
appearance: none;
cursor: pointer;
background-image: url(../icons/trash.svg);
}
[name='destroyFile']:checked {
background-image: url(../icons/remove_circle.svg);
}
.icon {
display: block;
width: 24px;
height: 24px;
outline: none !important;
border: none !important;
cursor: pointer;
margin: 0 2px !important;
}
.icon:focus {
outline: none;
border: none;
}
#forcemodule {
border-radius: 8px;
background: crimson;
max-width: fit-content;
padding: 5px;
color: white;
}
.demo {
width: 23px;
height: 13px;
display: inline-block;
border: solid 1px #333;
}

View File

@ -1,11 +1,10 @@
div.jury_decisions_list div {
font-size: 120%;
font-weight: bold;
}
span.parcours {
color:blueviolet;
color: blueviolet;
}
div.ue_list_etud_validations ul.liste_validations li {

View File

@ -79,7 +79,7 @@ div.competence {
padding-left: calc(var(--arrow-width) + 8px);
}
.niveaux>div:not(:last-child)::after {
.niveaux>div:not(:last-child)::before {
content: "";
position: absolute;

View File

@ -394,6 +394,7 @@ body.editionActivated .filtres .nonEditable .move {
padding: 4px 8px;
margin-bottom: 16px;
border-radius: 4px;
position: relative;
}
#zoneChoix .autoAffectation>select {
@ -415,6 +416,23 @@ body.editionActivated .filtres .nonEditable .move {
margin-bottom: 4px;
width: fit-content;
}
#zoneChoix .autoAffectation .progress {
position: absolute;
top: 100%;
left: 0;
right: 0;
height: 4px;
background: #717171;
}
#zoneChoix .autoAffectation .progress>div {
position: absolute;
top: 0;
left: 0;
width: calc(100% * var(--nombre) / var(--reference));
bottom: 0;
background: #0c9;
}
#zoneChoix .etudiants>div {
background: #FFF;

View File

@ -1328,6 +1328,13 @@ tr.etuddem td {
color: rgb(100, 100, 100);
font-style: italic;
}
table.gt_table tr.etuddem td a {
color: red;
}
table.gt_table tr.etuddem td.etudinfo:first-child::after {
color: red;
content: " (dém.)";
}
td.etudabs,
td.etudabs a.discretelink,
@ -1785,6 +1792,10 @@ td.formsemestre_status_inscrits {
text-align: center;
}
div.formsemestre_status button {
margin-left: 12px;;
}
td.rcp_titre_sem a.jury_link {
margin-left: 8px;
color: red;

11
app/static/icons/absent.svg Executable file
View File

@ -0,0 +1,11 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#F1A69C"/>
<g opacity="0.5" clip-path="url(#clip0_120_4425)">
<path d="M67.2116 70L43 45.707L18.7885 70L15.0809 66.3043L39.305 41.9995L15.0809 17.6939L18.7885 14L43 38.2922L67.2116 14L70.9191 17.6939L46.695 41.9995L70.9191 66.3043L67.2116 70Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_120_4425">
<rect width="56" height="56" fill="white" transform="matrix(1 0 0 -1 15 70)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 547 B

8
app/static/icons/aucun.svg Executable file
View File

@ -0,0 +1,8 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#BBB"/>
<defs>
<clipPath id="clip0_120_4425">
<rect width="56" height="56" fill="white" transform="matrix(1 0 0 -1 15 70)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M4 3h16a1 1 0 011 1v1.586a1 1 0 01-.293.707l-6.415 6.414a1 1 0 00-.292.707v6.305a1 1 0 01-1.243.97l-2-.5a1 1 0 01-.757-.97v-5.805a1 1 0 00-.293-.707L3.292 6.293A1 1 0 013 5.586V4a1 1 0 011-1z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 470 B

13
app/static/icons/present.svg Executable file
View File

@ -0,0 +1,13 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#9CF1AF"/>
<g clip-path="url(#clip0_120_4405)">
<g opacity="0.5">
<path d="M70.7713 27.5875L36.0497 62.3091C35.7438 62.6149 35.2487 62.6149 34.9435 62.3091L15.2286 42.5935C14.9235 42.2891 14.9235 41.7939 15.2286 41.488L20.0191 36.6976C20.3249 36.3924 20.8201 36.3924 21.1252 36.6976L35.4973 51.069L64.8754 21.6909C65.1819 21.3858 65.6757 21.3858 65.9815 21.6909L70.7713 26.4814C71.0771 26.7865 71.0771 27.281 70.7713 27.5875Z" fill="black"/>
</g>
</g>
<defs>
<clipPath id="clip0_120_4405">
<rect width="56" height="56" fill="white" transform="translate(15 14)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 729 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" stroke-width="1.5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M9.172 14.828L12.001 12m2.828-2.828L12.001 12m0 0L9.172 9.172M12.001 12l2.828 2.828M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z" stroke="#fe4217" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 434 B

12
app/static/icons/retard.svg Executable file
View File

@ -0,0 +1,12 @@
<svg width="85" height="85" viewBox="0 0 85 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="85" height="85" rx="15" fill="#F1D99C"/>
<g opacity="0.5" clip-path="url(#clip0_120_4407)">
<path d="M55.2901 49.1836L44.1475 41.3918V28C44.1475 27.3688 43.6311 26.8524 43 26.8524C42.3688 26.8524 41.8524 27.3688 41.8524 28V42C41.8524 42.3787 42.036 42.7229 42.3459 42.941L53.9819 51.077C54.177 51.2147 54.4065 51.2836 54.636 51.2836C54.9918 51.2836 55.3475 51.1115 55.577 50.7787C55.9327 50.2623 55.8065 49.5508 55.2901 49.1836Z" fill="black"/>
<path d="M62.7836 22.2164C57.482 16.9148 50.459 14 43 14C35.541 14 28.518 16.9148 23.2164 22.2164C17.9148 27.518 15 34.541 15 42C15 49.459 17.9148 56.482 23.2164 61.7836C28.518 67.0852 35.541 70 43 70C50.459 70 57.482 67.0852 62.7836 61.7836C68.0852 56.482 71 49.459 71 42C71 34.541 68.0852 27.518 62.7836 22.2164ZM44.1475 67.682V63C44.1475 62.3689 43.6311 61.8525 43 61.8525C42.3689 61.8525 41.8525 62.3689 41.8525 63V67.682C28.5869 67.0967 17.9033 56.4131 17.318 43.1475H22C22.6311 43.1475 23.1475 42.6311 23.1475 42C23.1475 41.3689 22.6311 40.8525 22 40.8525H17.318C17.9033 27.5869 28.5869 16.9033 41.8525 16.318V21C41.8525 21.6311 42.3689 22.1475 43 22.1475C43.6311 22.1475 44.1475 21.6311 44.1475 21V16.318C57.4131 16.9033 68.0967 27.5869 68.682 40.8525H64C63.3689 40.8525 62.8525 41.3689 62.8525 42C62.8525 42.6311 63.3689 43.1475 64 43.1475H68.682C68.0967 56.4131 57.4131 67.0967 44.1475 67.682Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_120_4407">
<rect width="56" height="56" fill="white" transform="translate(15 14)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10 14H2m6-4H2m4-4H2m10 12H2m17 2V4m0 16l3-3m-3 3l-3-3m3-13l3 3m-3-3l-3 3"/></svg>

After

Width:  |  Height:  |  Size: 274 B

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg width="24px" height="24px" viewBox="0 0 24 24" stroke-width="1.5" fill="none" xmlns="http://www.w3.org/2000/svg" color="#000000"><path d="M20 9l-1.995 11.346A2 2 0 0116.035 22h-8.07a2 2 0 01-1.97-1.654L4 9M21 6h-5.625M3 6h5.625m0 0V4a2 2 0 012-2h2.75a2 2 0 012 2v2m-6.75 0h6.75" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>

After

Width:  |  Height:  |  Size: 418 B

1734
app/static/js/assiduites.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,59 +3,64 @@
// utilise jQuery / qTip
function get_etudid_from_elem(e) {
// renvoie l'etudid, obtenu a partir de l'id de l'element
// qui est soit de la forme xxxx-etudid, soit tout simplement etudid
var etudid = e.id.split("-")[1];
if (etudid == undefined) {
return e.id;
} else {
return etudid;
}
// renvoie l'etudid, obtenu a partir de l'id de l'element
// qui est soit de la forme xxxx-etudid, soit tout simplement etudid
var etudid = e.id.split("-")[1];
if (etudid == undefined) {
return e.id;
} else {
return etudid;
}
}
$().ready(function () {
var elems = $(".etudinfo:not(th)");
var elems = $(".etudinfo");
var q_args = get_query_args();
var args_to_pass = new Set(
["formsemestre_id", "group_ids", "group_id", "partition_id",
"moduleimpl_id", "evaluation_id"
]);
var qs = "";
for (var k in q_args) {
if (args_to_pass.has(k)) {
qs += '&' + k + '=' + q_args[k];
}
}
for (var i = 0; i < elems.length; i++) {
$(elems[i]).qtip({
content: {
ajax: {
url: SCO_URL + "/etud_info_html?etudid=" + get_etudid_from_elem(elems[i]) + qs,
type: "GET"
//success: function(data, status) {
// this.set('content.text', data);
// xxx called twice on each success ???
// console.log(status);
}
},
text: "Loading...",
position: {
at: "right bottom",
my: "left top"
},
style: {
classes: 'qtip-etud'
},
hide: {
fixed: true,
delay: 300
}
// utile pour debugguer le css:
// hide: { event: 'unfocus' }
});
var q_args = get_query_args();
var args_to_pass = new Set([
"formsemestre_id",
"group_ids",
"group_id",
"partition_id",
"moduleimpl_id",
"evaluation_id",
]);
var qs = "";
for (var k in q_args) {
if (args_to_pass.has(k)) {
qs += "&" + k + "=" + q_args[k];
}
}
for (var i = 0; i < elems.length; i++) {
$(elems[i]).qtip({
content: {
ajax: {
url:
SCO_URL +
"/etud_info_html?etudid=" +
get_etudid_from_elem(elems[i]) +
qs,
type: "GET",
//success: function(data, status) {
// this.set('content.text', data);
// xxx called twice on each success ???
// console.log(status);
},
},
text: "Loading...",
position: {
at: "right bottom",
my: "left top",
},
style: {
classes: "qtip-etud",
},
hide: {
fixed: true,
delay: 300,
},
// utile pour debugguer le css:
// hide: { event: 'unfocus' }
});
}
});

View File

@ -1,70 +1,79 @@
/* Module par Seb. L. */
class releveBUT extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
/* Config par defaut */
this.config = {
showURL: true
};
/* Config par defaut */
this.config = {
showURL: true,
};
/* Template du module */
this.shadow.innerHTML = this.template();
/* Template du module */
this.shadow.innerHTML = this.template();
/* Style du module */
const styles = document.createElement('link');
styles.setAttribute('rel', 'stylesheet');
if (location.href.includes("ScoDoc")) {
styles.setAttribute('href', removeLastTwoComponents(getCurrentScriptPath()) + '/css/releve-but.css'); // Scodoc
} else {
styles.setAttribute('href', '/assets/styles/releve-but.css'); // Passerelle
}
this.shadow.appendChild(styles);
}
listeOnOff() {
this.parentElement.parentElement.classList.toggle("listeOff");
this.parentElement.parentElement.querySelectorAll(".moduleOnOff").forEach(e => {
e.classList.remove("moduleOnOff")
})
}
moduleOnOff() {
this.parentElement.classList.toggle("moduleOnOff");
}
goTo() {
let module = this.dataset.module;
this.parentElement.parentElement.parentElement.parentElement.querySelector("#Module_" + module).scrollIntoView();
}
/* Style du module */
const styles = document.createElement("link");
styles.setAttribute("rel", "stylesheet");
if (location.href.includes("ScoDoc")) {
styles.setAttribute(
"href",
removeLastTwoComponents(getCurrentScriptPath()) + "/css/releve-but.css"
); // Scodoc
} else {
styles.setAttribute("href", "/assets/styles/releve-but.css"); // Passerelle
}
this.shadow.appendChild(styles);
}
listeOnOff() {
this.parentElement.parentElement.classList.toggle("listeOff");
this.parentElement.parentElement
.querySelectorAll(".moduleOnOff")
.forEach((e) => {
e.classList.remove("moduleOnOff");
});
}
moduleOnOff() {
this.parentElement.classList.toggle("moduleOnOff");
}
goTo() {
let module = this.dataset.module;
this.parentElement.parentElement.parentElement.parentElement
.querySelector("#Module_" + module)
.scrollIntoView();
}
set setConfig(config) {
this.config.showURL = config.showURL ?? this.config.showURL;
}
set setConfig(config) {
this.config.showURL = config.showURL ?? this.config.showURL;
}
set showData(data) {
// this.showInformations(data);
this.showSemestre(data);
this.showSynthese(data);
this.showEvaluations(data);
set showData(data) {
// this.showInformations(data);
this.showSemestre(data);
this.showSynthese(data);
this.showEvaluations(data);
this.showCustom(data);
this.showCustom(data);
this.setOptions(data.options);
this.setOptions(data.options);
this.shadow.querySelectorAll(".CTA_Liste").forEach(e => {
e.addEventListener("click", this.listeOnOff)
})
this.shadow.querySelectorAll(".ue, .module").forEach(e => {
e.addEventListener("click", this.moduleOnOff)
})
this.shadow.querySelectorAll(":not(.ueBonus)+.syntheseModule").forEach(e => {
e.addEventListener("click", this.goTo)
})
this.shadow.querySelectorAll(".CTA_Liste").forEach((e) => {
e.addEventListener("click", this.listeOnOff);
});
this.shadow.querySelectorAll(".ue, .module").forEach((e) => {
e.addEventListener("click", this.moduleOnOff);
});
this.shadow
.querySelectorAll(":not(.ueBonus)+.syntheseModule")
.forEach((e) => {
e.addEventListener("click", this.goTo);
});
this.shadow.children[0].classList.add("ready");
}
this.shadow.children[0].classList.add("ready");
}
template() {
return `
template() {
return `
<div>
<div class="wait"></div>
<main class="releve">
@ -140,33 +149,36 @@ class releveBUT extends HTMLElement {
</main>
</div>`;
}
}
/********************************/
/* Informations sur l'étudiant */
/********************************/
showInformations(data) {
this.shadow.querySelector(".studentPic").src = data.etudiant.photo_url || "default_Student.svg";
/********************************/
/* Informations sur l'étudiant */
/********************************/
showInformations(data) {
this.shadow.querySelector(".studentPic").src =
data.etudiant.photo_url || "default_Student.svg";
let output = '';
let output = "";
if (this.config.showURL) {
output += `<a href="${data.etudiant.fiche_url}" class=info_etudiant>`;
} else {
output += `<div class=info_etudiant>`;
}
if (this.config.showURL) {
output += `<a href="${data.etudiant.fiche_url}" class=info_etudiant>`;
} else {
output += `<div class=info_etudiant>`;
}
output += `
output += `
<div class=civilite>
${this.civilite(data.etudiant.civilite)}
${data.etudiant.nom}
${data.etudiant.prenom}`;
if (data.etudiant.date_naissance) {
output += ` <div class=dateNaissance>né${(data.etudiant.civilite == "F") ? "e" : ""} le ${this.ISOToDate(data.etudiant.date_naissance)}</div>`;
}
if (data.etudiant.date_naissance) {
output += ` <div class=dateNaissance>né${
data.etudiant.civilite == "F" ? "e" : ""
} le ${this.ISOToDate(data.etudiant.date_naissance)}</div>`;
}
output += `
output += `
</div>
<div class=numerosEtudiant>
Numéro étudiant : ${data.etudiant.code_nip || "~"} -
@ -174,46 +186,51 @@ class releveBUT extends HTMLElement {
</div>
<div>${data.formation.titre}</div>
`;
if (this.config.showURL) {
output += `</a>`;
} else {
output += `</div>`;
}
if (this.config.showURL) {
output += `</a>`;
} else {
output += `</div>`;
}
this.shadow.querySelector(".infoEtudiant").innerHTML = output;
}
this.shadow.querySelector(".infoEtudiant").innerHTML = output;
}
/*******************************/
/* Affichage local */
/*******************************/
showCustom(data) {
this.shadow.querySelector(".custom").innerHTML = data.custom || "";
}
/*******************************/
/* Affichage local */
/*******************************/
showCustom(data) {
this.shadow.querySelector(".custom").innerHTML = data.custom || "";
}
/*******************************/
/* Information sur le semestre */
/*******************************/
showSemestre(data) {
let correspondanceCodes = {
"ADM": "Admis",
"AJD": "Admis par décision de jury",
"PASD": "Passage de droit : tout n'est pas validé, mais d'après les règles du BUT, vous passez",
"PAS1NCI": "Vous passez par décision de jury mais attention, vous n'avez pas partout le niveau suffisant",
"RED": "Ajourné mais autorisé à redoubler",
"NAR": "Non admis et non autorisé à redoubler : réorientation",
"DEM": "Démission",
"ABAN": "Abandon constaté sans lettre de démission",
"RAT": "En attente d'un rattrapage",
"EXCLU": "Exclusion dans le cadre d'une décision disciplinaire",
"DEF": "Défaillance : non évalué par manque d'assiduité",
"ABL": "Année blanche"
}
/*******************************/
/* Information sur le semestre */
/*******************************/
showSemestre(data) {
let correspondanceCodes = {
ADM: "Admis",
AJD: "Admis par décision de jury",
PASD: "Passage de droit : tout n'est pas validé, mais d'après les règles du BUT, vous passez",
PAS1NCI:
"Vous passez par décision de jury mais attention, vous n'avez pas partout le niveau suffisant",
RED: "Ajourné mais autorisé à redoubler",
NAR: "Non admis et non autorisé à redoubler : réorientation",
DEM: "Démission",
ABAN: "Abandon constaté sans lettre de démission",
RAT: "En attente d'un rattrapage",
EXCLU: "Exclusion dans le cadre d'une décision disciplinaire",
DEF: "Défaillance : non évalué par manque d'assiduité",
ABL: "Année blanche",
};
this.shadow.querySelector("#identite_etudiant").innerHTML = ` <a href="${data.etudiant.fiche_url}">${data.etudiant.nomprenom}</a> `;
this.shadow.querySelector(".dateInscription").innerHTML += this.ISOToDate(data.semestre.inscription);
let output = '';
if (!data.options.block_moyenne_generale) {
output += `
this.shadow.querySelector(
"#identite_etudiant"
).innerHTML = ` <a href="${data.etudiant.fiche_url}">${data.etudiant.nomprenom}</a> `;
this.shadow.querySelector(".dateInscription").innerHTML += this.ISOToDate(
data.semestre.inscription
);
let output = "";
if (!data.options.block_moyenne_generale) {
output += `
<div>
<div class=enteteSemestre>Moyenne</div><div class=enteteSemestre>${data.semestre.notes.value}</div>
<div class=rang>Rang :</div><div class=rang>${data.semestre.rang.value} / ${data.semestre.rang.total}</div>
@ -222,64 +239,72 @@ class releveBUT extends HTMLElement {
<div>Min. promo. :</div><div>${data.semestre.notes.min}</div>
</div>
`;
}
output += `
}
output += `
${(() => {
if ((!data.semestre.rang.groupes) ||
(Object.keys(data.semestre.rang.groupes).length == 0)) {
return "";
}
let output = "";
let [idGroupe, dataGroupe] = Object.entries(data.semestre.rang.groupes)[0];
output += `<div>
if (
!data.semestre.rang.groupes ||
Object.keys(data.semestre.rang.groupes).length == 0
) {
return "";
}
let output = "";
let [idGroupe, dataGroupe] = Object.entries(
data.semestre.rang.groupes
)[0];
output += `<div>
<div class=enteteSemestre>${data.semestre.groupes[0]?.group_name}</div><div></div>
<div class=rang>Rang :</div><div class=rang>${dataGroupe.value} / ${dataGroupe.total}</div>
</div>`;
// <div>Max. promo. :</div><div>${dataGroupe.max || "-"}</div>
// <div>Moy. promo. :</div><div>${dataGroupe.moy || "-"}</div>
// <div>Min. promo. :</div><div>${dataGroupe.min || "-"}</div>
return output;
})()}
// <div>Max. promo. :</div><div>${dataGroupe.max || "-"}</div>
// <div>Moy. promo. :</div><div>${dataGroupe.moy || "-"}</div>
// <div>Min. promo. :</div><div>${dataGroupe.min || "-"}</div>
return output;
})()}
<div class=absencesRecap>
<div class=enteteSemestre>Absences</div><div class=enteteSemestre>1/2 jour.</div>
<div class=enteteSemestre>Absences</div><div class=enteteSemestre>${
data.semestre.absences?.metrique ?? "1/2 jour."
}</div>
<div class=abs>Non justifiées</div>
<div>${data.semestre.absences?.injustifie ?? "-"}</div>
<div class=abs>Total</div><div>${data.semestre.absences?.total ?? "-"}</div>
</div>`;
if (data.semestre.decision_rcue?.length) {
output += `
if (data.semestre.decision_rcue?.length) {
output += `
<div>
<div class=enteteSemestre>RCUE</div><div></div>
${(() => {
let output = "";
data.semestre.decision_rcue.forEach(competence => {
output += `<div class=competence>${competence.niveau.competence.titre}</div><div>${competence.code}</div>`;
})
return output;
})()}
let output = "";
data.semestre.decision_rcue.forEach((competence) => {
output += `<div class=competence>${competence.niveau.competence.titre}</div><div>${competence.code}</div>`;
});
return output;
})()}
</div>
</div>`
}
if (data.semestre.decision_ue?.length) {
output += `
</div>`;
}
if (data.semestre.decision_ue?.length) {
output += `
<div>
<div class=enteteSemestre>UE</div><div></div>
${(() => {
let output = "";
data.semestre.decision_ue.forEach(ue => {
output += `<div class=competence>${ue.acronyme}</div><div>${ue.code}</div>`;
})
return output;
})()}
let output = "";
data.semestre.decision_ue.forEach((ue) => {
output += `<div class=competence>${ue.acronyme}</div><div>${ue.code}</div>`;
});
return output;
})()}
</div>
</div>`
}
</div>`;
}
output += `
output += `
<a class=photo href="${data.etudiant.fiche_url}">
<img src="${data.etudiant.photo_url || "default_Student.svg"}" alt="photo de l'étudiant" title="fiche de l'étudiant" height="120" border="0">
<img src="${
data.etudiant.photo_url || "default_Student.svg"
}" alt="photo de l'étudiant" title="fiche de l'étudiant" height="120" border="0">
</a>`;
/*${data.semestre.groupes.map(groupe => {
/*${data.semestre.groupes.map(groupe => {
return `
<div>
<div class=enteteSemestre>Groupe</div><div class=enteteSemestre>${groupe.nom}</div>
@ -291,37 +316,42 @@ class releveBUT extends HTMLElement {
`;
}).join("")
}*/
this.shadow.querySelector(".infoSemestre").innerHTML = output;
this.shadow.querySelector(".infoSemestre").innerHTML = output;
/*if(data.semestre.decision_annee?.code){
/*if(data.semestre.decision_annee?.code){
this.shadow.querySelector(".decision_annee").innerHTML = "Décision année : " + data.semestre.decision_annee.code + " - " + correspondanceCodes[data.semestre.decision_annee.code];
}*/
this.shadow.querySelector(".decision").innerHTML = data.semestre.situation || "";
/*if (data.semestre.decision?.code) {
this.shadow.querySelector(".decision").innerHTML =
data.semestre.situation || "";
/*if (data.semestre.decision?.code) {
this.shadow.querySelector(".decision").innerHTML = "Décision jury: " + (data.semestre.decision?.code || "");
}*/
this.shadow.querySelector("#ects_tot").innerHTML = "ECTS&nbsp;:&nbsp;" + (data.semestre.ECTS?.acquis ?? "-") + "&nbsp;/&nbsp;" + (data.semestre.ECTS?.total ?? "-");
}
this.shadow.querySelector("#ects_tot").innerHTML =
"ECTS&nbsp;:&nbsp;" +
(data.semestre.ECTS?.acquis ?? "-") +
"&nbsp;/&nbsp;" +
(data.semestre.ECTS?.total ?? "-");
}
/*******************************/
/* Synthèse */
/*******************************/
showSynthese(data) {
let output = ``;
/* Fusion et tri des UE et UE capitalisées */
let fusionUE = [
...Object.entries(data.ues),
...Object.entries(data.ues_capitalisees)
].sort((a, b) => {
return a[1].numero - b[1].numero
});
/*******************************/
/* Synthèse */
/*******************************/
showSynthese(data) {
let output = ``;
/* Fusion et tri des UE et UE capitalisées */
let fusionUE = [
...Object.entries(data.ues),
...Object.entries(data.ues_capitalisees),
].sort((a, b) => {
return a[1].numero - b[1].numero;
});
/* Affichage */
fusionUE.forEach(([ue, dataUE]) => {
if (dataUE.type == 1) { // UE Sport / Bonus
output += `
/* Affichage */
fusionUE.forEach(([ue, dataUE]) => {
if (dataUE.type == 1) {
// UE Sport / Bonus
output += `
<div>
<div class="ue ueBonus">
<h3>Bonus</h3>
@ -330,52 +360,64 @@ class releveBUT extends HTMLElement {
${this.ueSport(dataUE.modules)}
</div>
`;
} else {
output += `
} else {
output += `
<div>
<div class="ue ${dataUE.date_capitalisation ? "capitalisee" : ""}">
<h3>
${ue}${(dataUE.titre) ? " - " + dataUE.titre : ""}
${ue}${dataUE.titre ? " - " + dataUE.titre : ""}
</h3>
<div>
<div class=moyenne>Moyenne&nbsp;:&nbsp;${dataUE.moyenne?.value || dataUE.moyenne || "-"}</div>
<div class=ue_rang>Rang&nbsp;:&nbsp;${dataUE.moyenne?.rang}&nbsp;/&nbsp;${dataUE.moyenne?.total}</div>
<div class=moyenne>Moyenne&nbsp;:&nbsp;${
dataUE.moyenne?.value || dataUE.moyenne || "-"
}</div>
<div class=ue_rang>Rang&nbsp;:&nbsp;${dataUE.moyenne?.rang}&nbsp;/&nbsp;${
dataUE.moyenne?.total
}</div>
<div class=info>`;
if (!dataUE.date_capitalisation) {
output += ` Bonus&nbsp;:&nbsp;${dataUE.bonus || 0}&nbsp;-
Malus&nbsp;:&nbsp;${dataUE.malus || 0}`;
} else {
output += ` le ${this.ISOToDate(dataUE.date_capitalisation.split("T")[0])} <a href="${dataUE.bul_orig_url}">dans ce semestre</a>`;
}
if (!dataUE.date_capitalisation) {
output += ` Bonus&nbsp;:&nbsp;${dataUE.bonus || 0}&nbsp;- `;
if(dataUE.malus >= 0) {
output += `Malus&nbsp;:&nbsp;${dataUE.malus || 0}`;
} else {
output += `Bonus&nbsp;complémentaire&nbsp;:&nbsp;${-dataUE.malus || 0}`;
}
} else {
output += ` le ${this.ISOToDate(
dataUE.date_capitalisation.split("T")[0]
)} <a href="${dataUE.bul_orig_url}">dans ce semestre</a>`;
}
output += ` <span class=ects>&nbsp;-
ECTS&nbsp;:&nbsp;${dataUE.ECTS?.acquis ?? "-"}&nbsp;/&nbsp;${dataUE.ECTS?.total ?? "-"}
output += ` <span class=ects>&nbsp;-
ECTS&nbsp;:&nbsp;${dataUE.ECTS?.acquis ?? "-"}&nbsp;/&nbsp;${
dataUE.ECTS?.total ?? "-"
}
</span>
</div>
</div>`;
/*<div class=absences>
/*<div class=absences>
<div>Abs&nbsp;N.J.</div><div>${dataUE.absences?.injustifie || 0}</div>
<div>Total</div><div>${dataUE.absences?.total || 0}</div>
</div>*/
output += "</div>";
output += "</div>";
if (!dataUE.date_capitalisation) {
output +=
this.synthese(data, dataUE.ressources) +
this.synthese(data, dataUE.saes);
}
if (!dataUE.date_capitalisation) {
output +=
this.synthese(data, dataUE.ressources) +
this.synthese(data, dataUE.saes);
}
output += "</div>";
}
});
this.shadow.querySelector(".synthese").innerHTML = output;
}
synthese(data, modules) {
let output = "";
Object.entries(modules).forEach(([module, dataModule]) => {
let titre = data.ressources[module]?.titre || data.saes[module]?.titre;
//let url = data.ressources[module]?.url || data.saes[module]?.url;
output += `
output += "</div>";
}
});
this.shadow.querySelector(".synthese").innerHTML = output;
}
synthese(data, modules) {
let output = "";
Object.entries(modules).forEach(([module, dataModule]) => {
let titre = data.ressources[module]?.titre || data.saes[module]?.titre;
//let url = data.ressources[module]?.url || data.saes[module]?.url;
output += `
<div class=syntheseModule data-module="${module.replace(/[^a-zA-Z0-9]/g, "")}">
<div>${module}&nbsp;- ${titre}</div>
<div>
@ -384,14 +426,14 @@ class releveBUT extends HTMLElement {
</div>
</div>
`;
})
return output;
}
ueSport(modules) {
let output = "";
Object.values(modules).forEach((module) => {
Object.values(module.evaluations).forEach((evaluation) => {
output += `
});
return output;
}
ueSport(modules) {
let output = "";
Object.values(modules).forEach((module) => {
Object.values(module.evaluations).forEach((evaluation) => {
output += `
<div class=syntheseModule>
<div>${module.titre} - ${evaluation.description || "Note"}</div>
<div>
@ -400,27 +442,31 @@ class releveBUT extends HTMLElement {
</div>
</div>
`;
})
})
return output;
}
});
});
return output;
}
/*******************************/
/* Evaluations */
/*******************************/
showEvaluations(data) {
this.shadow.querySelector(".evaluations").innerHTML = this.module(data.ressources);
this.shadow.querySelector(".sae").innerHTML += this.module(data.saes);
}
module(module) {
let output = "";
Object.entries(module).forEach(([numero, content]) => {
output += `
/*******************************/
/* Evaluations */
/*******************************/
showEvaluations(data) {
this.shadow.querySelector(".evaluations").innerHTML = this.module(
data.ressources
);
this.shadow.querySelector(".sae").innerHTML += this.module(data.saes);
}
module(module) {
let output = "";
Object.entries(module).forEach(([numero, content]) => {
output += `
<div id="Module_${numero.replace(/[^a-zA-Z0-9]/g, "")}">
<div class=module>
<h3>${this.URL(content.url, `${numero} - ${content.titre}`)}</h3>
<div>
<div class=moyenne>Moyenne&nbsp;indicative&nbsp;:&nbsp;${content.moyenne.value}</div>
<div class=moyenne>Moyenne&nbsp;indicative&nbsp;:&nbsp;${
content.moyenne.value
}</div>
<div class=info>
Classe&nbsp;:&nbsp;${content.moyenne.moy}&nbsp;-
Max&nbsp;:&nbsp;${content.moyenne.max}&nbsp;-
@ -435,14 +481,14 @@ class releveBUT extends HTMLElement {
${this.evaluation(content.evaluations)}
</div>
`;
})
return output;
}
});
return output;
}
evaluation(evaluations) {
let output = "";
evaluations.forEach((evaluation) => {
output += `
evaluation(evaluations) {
let output = "";
evaluations.forEach((evaluation) => {
output += `
<div class=eval>
<div>${this.URL(evaluation.url, evaluation.description || "Évaluation")}</div>
<div>
@ -454,52 +500,55 @@ class releveBUT extends HTMLElement {
<div>Max. promo.</div><div>${evaluation.note.max}</div>
<div>Moy. promo.</div><div>${evaluation.note.moy}</div>
<div>Min. promo.</div><div>${evaluation.note.min}</div>
${Object.entries(evaluation.poids).map(([UE, poids]) => {
return `
${Object.entries(evaluation.poids)
.map(([UE, poids]) => {
return `
<div>Poids ${UE}</div>
<div>${poids}</div>
`;
}).join("")}
})
.join("")}
</div>
</div>
`;
})
return output;
}
});
return output;
}
/********************/
/* Options */
/********************/
setOptions(options) {
Object.entries(options).forEach(([option, value]) => {
if (value === false) {
this.shadow.children[0].classList.add(option.replace("show", "hide"));
}
});
}
/********************/
/* Options */
/********************/
setOptions(options) {
Object.entries(options).forEach(([option, value]) => {
if (value === false) {
this.shadow.children[0].classList.add(option.replace("show", "hide"));
}
});
}
/********************/
/* Fonctions d'aide */
/********************/
URL(href, content) {
if (this.config.showURL) {
return `<a href=${href}>${content}</a>`;
} else {
return content;
}
}
civilite(txt) {
switch (txt) {
case "M":
return "M.";
case "F":
return "Mme";
default:
return "";
}
}
/********************/
/* Fonctions d'aide */
/********************/
URL(href, content) {
if (this.config.showURL) {
return `<a href=${href}>${content}</a>`;
} else {
return content;
}
}
civilite(txt) {
switch (txt) {
case "M": return "M.";
case "F": return "Mme";
default: return "";
}
}
ISOToDate(ISO) {
return ISO.split("-").reverse().join("/");
}
ISOToDate(ISO) {
return ISO.split("-").reverse().join("/");
}
}
customElements.define('releve-but', releveBUT);
customElements.define("releve-but", releveBUT);

File diff suppressed because it is too large Load Diff

3309
app/static/libjs/moment.new.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,162 @@
##############################################################################
# ScoDoc
# Copyright (c) 1999 - 2023 Emmanuel Viennet. All rights reserved.
# See LICENSE
##############################################################################
"""Liste simple d'étudiants
"""
from flask import g, url_for
from app import log
from app.models import FormSemestre, Identite, Justificatif
from app.tables import table_builder as tb
import app.scodoc.sco_assiduites as scass
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
class TableAssi(tb.Table):
"""Table listant l'assiduité des étudiants
L'id de la ligne est etuid, et le row stocke etud.
"""
def __init__(
self,
etuds: list[Identite] = None,
dates: tuple[str, str] = None,
formsemestre: FormSemestre = None,
**kwargs,
):
self.rows: list["RowEtud"] = [] # juste pour que VSCode nous aide sur .rows
classes = ["gt_table", "gt_left"]
self.dates = [str(dates[0]) + "T00:00", str(dates[1]) + "T23:59"]
self.formsemestre = formsemestre
super().__init__(
row_class=RowAssi,
classes=classes,
**kwargs,
with_foot_titles=False,
)
self.add_etuds(etuds)
def add_etuds(self, etuds: list[Identite]):
"Ajoute des étudiants à la table"
for etud in etuds:
row = self.row_class(self, etud)
row.add_etud_cols()
self.add_row(row)
class RowAssi(tb.Row):
"Ligne de la table assiduité"
# pour le moment très simple, extensible (codes, liens bulletins, ...)
def __init__(self, table: TableAssi, etud: Identite, *args, **kwargs):
# Etat de l'inscription au formsemestre
if "classes" not in kwargs:
kwargs["classes"] = []
try:
inscription = table.formsemestre.etuds_inscriptions[etud.id]
if inscription.etat == scu.DEMISSION:
kwargs["classes"].append("etuddem")
except KeyError:
log(f"RowAssi: etudid {etud.id} non inscrit à {table.formsemestre.id}")
kwargs["classes"].append("non_inscrit") # ne devrait pas arriver !
super().__init__(table, etud.id, *args, **kwargs)
self.etud = etud
self.dates = table.dates
def add_etud_cols(self):
"""Ajoute les colonnes"""
etud = self.etud
self.table.group_titles.update(
{
"etud_codes": "Codes",
"identite_detail": "",
"identite_court": "",
}
)
bilan_etud = url_for(
"assiduites.bilan_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
)
self.add_cell(
"nom_disp",
"Nom",
etud.nom_disp(),
"etudinfo",
attrs={"id": str(etud.id)},
data={"order": etud.sort_key},
target=bilan_etud,
target_attrs={"class": "discretelink"},
)
self.add_cell(
"prenom",
"Prénom",
etud.prenom_str,
"etudinfo",
attrs={"id": str(etud.id)},
data={"order": etud.sort_key},
target=bilan_etud,
target_attrs={"class": "discretelink"},
)
stats = self._get_etud_stats(etud)
for key, value in stats.items():
self.add_cell(key, value[0], f"{value[1] - value[2]}", "assi_stats")
self.add_cell(
key + "_justi",
value[0] + " Justifiées",
f"{value[2]}",
"assi_stats",
)
compte_justificatifs = scass.filter_by_date(
etud.justificatifs, Justificatif, self.dates[0], self.dates[1]
).count()
self.add_cell("justificatifs", "Justificatifs", f"{compte_justificatifs}")
def _get_etud_stats(self, etud: Identite) -> dict[str, list[str, float, float]]:
retour: dict[str, tuple[str, float, float]] = {
"present": ["Présences", 0.0, 0.0],
"retard": ["Retards", 0.0, 0.0],
"absent": ["Absences", 0.0, 0.0],
}
assi_metric = scu.translate_assiduites_metric(
sco_preferences.get_preference("assi_metrique", dept_id=g.scodoc_dept_id),
)
for etat, valeur in retour.items():
compte_etat = scass.get_assiduites_stats(
assiduites=etud.assiduites,
metric=assi_metric,
filtered={
"date_debut": self.dates[0],
"date_fin": self.dates[1],
"etat": etat,
},
)
compte_etat_just = scass.get_assiduites_stats(
assiduites=etud.assiduites,
metric=assi_metric,
filtered={
"date_debut": self.dates[0],
"date_fin": self.dates[1],
"etat": etat,
"est_just": True,
},
)
valeur[1] = compte_etat[assi_metric]
valeur[2] = compte_etat_just[assi_metric]
return retour
def etuds_sorted_from_ids(etudids) -> list[Identite]:
"Liste triée d'etuds à partir d'une collections d'etudids"
etuds = [Identite.get_etud(etudid) for etudid in etudids]
return sorted(etuds, key=lambda etud: etud.sort_key)

View File

@ -0,0 +1,221 @@
{% block pageContent %}
<div class="pageContent">
<h3>Justifier des assiduités</h3>
{% include "assiduites/widgets/tableau_base.j2" %}
<section class="liste">
<a class="icon filter" onclick="filter(false)"></a>
{% include "assiduites/widgets/tableau_justi.j2" %}
</section>
<section class="justi-form">
<fieldset>
<div class="justi-row">
<button onclick="validerFormulaire()">Créer le justificatif</button>
<button onclick="effacerFormulaire()">Remettre à zero</button>
</div>
<div class="justi-row">
<div class="justi-label">
<legend for="justi_date_debut" required>Date de début</legend>
<input type="datetime-local" name="justi_date_debut" id="justi_date_debut">
</div>
<div class="justi-label">
<legend for="justi_date_fin" required>Date de fin</legend>
<input type="datetime-local" name="justi_date_fin" id="justi_date_fin">
</div>
</div>
<div class="justi-row">
<div class="justi-label">
<legend for="justi_etat" required>Etat du justificatif</legend>
<select name="justi_etat" id="justi_etat">
<option value="attente" selected>En Attente de validation</option>
<option value="non_valide">Non Valide</option>
<option value="modifie">Modifié</option>
<option value="valide">Valide</option>
</select>
</div>
</div>
<div class="justi-row">
<div class="justi-label">
<legend for="justi_raison">Raison</legend>
<textarea name="justi_raison" id="justi_raison" cols="50" rows="10" maxlength="500"></textarea>
</div>
</div>
<div class="justi-row">
<div class="justi-sect">
</div>
<div class="justi-label">
<legend for="justi_fich">Importer un fichier</legend>
<input type="file" name="justi_fich" id="justi_fich" multiple>
</div>
</div>
</fieldset>
</section>
<div class="legende">
<h3>Gestion des justificatifs</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
<ul>
<li>Détails : Affiche les détails du justificatif sélectionné</li>
<li>Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)</li>
<li>Supprimer : Permet de supprimer le justificatif (Action Irréversible)</li>
</ul>
</p>
<p>Cliquer sur l'icone d'entonoir afin de filtrer le tableau des justificatifs</p>
</div>
</div>
<style>
.justi-row {
margin: 5px 0;
}
.justi-form fieldset {
display: flex;
flex-direction: column;
justify-content: space-evenly;
}
.pageContent {
max-width: var(--sco-content-max-width);
margin-top: 15px;
}
.justi-label {
margin: 0 10px;
}
[required]::after {
content: "*";
color: crimson;
}
</style>
<script>
function validateFields() {
const field = document.querySelector('.justi-form')
const in_date_debut = field.querySelector('#justi_date_debut');
const in_date_fin = field.querySelector('#justi_date_fin');
if (in_date_debut.value == "" || in_date_fin.value == "") {
openAlertModal("Erreur détéctée", document.createTextNode("Il faut indiquer une date de début et une date de fin."), "", color = "crimson");
return false;
}
const date_debut = moment.tz(in_date_debut.value, TIMEZONE);
const date_fin = moment.tz(in_date_fin.value, TIMEZONE);
if (date_fin.isBefore(date_debut)) {
openAlertModal("Erreur détéctée", document.createTextNode("La date de fin doit se trouver après la date de début."), "", color = "crimson");
return false;
}
return true
}
function fieldsToJustificatif() {
const field = document.querySelector('.justi-form')
const date_debut = field.querySelector('#justi_date_debut').value;
const date_fin = field.querySelector('#justi_date_fin').value;
const etat = field.querySelector('#justi_etat').value;
const raison = field.querySelector('#justi_raison').value;
return {
date_debut: date_debut,
date_fin: date_fin,
etat: etat,
raison: raison,
}
}
function importFiles(justif_id) {
const field = document.querySelector('.justi-form')
const in_files = field.querySelector('#justi_fich');
const path = getUrl() + `/api/justificatif/${justif_id}/import`;
const requests = []
Array.from(in_files.files).forEach((f) => {
const fd = new FormData();
fd.append('file', f);
requests.push(
$.ajax(
{
url: path,
type: 'POST',
data: fd,
dateType: 'json',
contentType: false,
processData: false,
success: () => { console.log("done") },
}
)
)
});
$.when(
requests
).done(() => {
loadAll();
})
}
function validerFormulaire() {
if (!validateFields()) return
const justificatif = fieldsToJustificatif();
let justif_id = null;
let couverture = null;
createJustificatif(justificatif, (data) => {
if (Object.keys(data.errors).length > 0) {
console.error(data.errors);
errorAlert();
}
if (Object.keys(data.success).length > 0) {
couverture = data.success[0].message.couverture
justif_id = data.success[0].message.justif_id;
importFiles(justif_id);
return;
}
})
}
function effacerFormulaire() {
const field = document.querySelector('.justi-form')
field.querySelector('#justi_date_debut').value = "";
field.querySelector('#justi_date_fin').value = "";
field.querySelector('#justi_etat').value = "attente";
field.querySelector('#justi_raison').value = "";
field.querySelector('#justi_fich').value = "";
}
const etudid = {{ sco.etud.id }};
window.onload = () => {
loadAll();
}
</script>
{% endblock pageContent %}

View File

@ -0,0 +1,170 @@
{% include "assiduites/widgets/tableau_base.j2" %}
<section class="alerte invisible">
<p>Attention, cet étudiant a trop d'absences</p>
</section>
<section class="nonvalide">
<!-- Tableaux des justificatifs à valider (attente / modifié ) -->
<h4>Justificatifs en attente (ou modifiés)</h4>
{% include "assiduites/widgets/tableau_justi.j2" %}
</section>
<div class="annee">
<span>Année scolaire 2022-2023 Changer année: </span>
<select name="" id="annee" onchange="setterAnnee(this.value)">
</select>
</div>
<div class="legende">
<h3>Gestion des justificatifs</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu contextuel :
<ul>
<li>Détails : Affiche les détails du justificatif sélectionné</li>
<li>Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)</li>
<li>Supprimer : Permet de supprimer le justificatif (Action Irréversible)</li>
</ul>
</p>
</div>
<script>
function loadAll() {
generate(defAnnee)
}
function getDeptJustificatifsFromPeriod(action) {
const path = getUrl() + `/api/justificatifs/dept/${dept_id}/query?date_debut=${bornes.deb}&date_fin=${bornes.fin}&etat=attente,modifie`
async_get(
path,
(data, status) => {
console.log(data);
justificatifCallBack(data);
},
(data, status) => {
console.error(data, status)
errorAlert();
}
)
}
function generate(annee) {
if (annee < 1999 || annee > 2999) {
openAlertModal("Année impossible", document.createTextNode("L'année demandé n'existe pas."));
return;
}
bornes = {
deb: `${annee}-09-01T00:00`,
fin: `${annee + 1}-06-30T23:59`
}
defAnnee = annee;
getDeptJustificatifsFromPeriod()
}
function setterAnnee(annee) {
annee = parseInt(annee);
document.querySelector('.annee span').textContent = `Année scolaire ${annee}-${annee + 1} Changer année: `
generate(annee)
}
let defAnnee = {{ annee }};
let bornes = {
deb: `${defAnnee}-09-01T00:00`,
fin: `${defAnnee + 1}-06-30T23:59`
}
const dept_id = {{ dept_id }};
window.addEventListener('load', () => {
filterJustificatifs = {
"columns": [
"etudid",
"entry_date",
"date_debut",
"date_fin",
"etat",
"raison",
"fichier"
],
"filters": {
"etat": [
"attente",
"modifie"
]
}
}
const select = document.querySelector('#annee');
for (let i = defAnnee + 1; i > defAnnee - 6; i--) {
const opt = document.createElement("option");
opt.value = i + "",
opt.textContent = i + "";
if (i === defAnnee) {
opt.selected = true;
}
select.appendChild(opt)
}
setterAnnee(defAnnee)
})
</script>
<style>
.stats-values-item {
display: flex;
justify-content: space-evenly;
align-items: center;
flex-direction: column;
}
.stats {
border: 1px solid #333;
padding: 5px 2px;
width: fit-content;
}
.stats-values {
display: flex;
justify-content: flex-start;
gap: 15px;
}
.stats-values-item h5 {
font-weight: bold;
text-decoration-line: underline;
}
.stats-values-part {
display: flex;
flex-direction: column;
}
.alerte {
display: flex;
justify-content: center;
align-items: center;
padding: 10px;
margin: 5px 0;
border-radius: 7px;
background-color: crimson;
}
.alerte.invisible {
display: none;
}
.alerte p {
font-size: larger;
color: whitesmoke;
}
.suppr {
margin: 5px 0;
}
</style>

View File

@ -0,0 +1,371 @@
{% block app_content %}
{% include "assiduites/widgets/tableau_base.j2" %}
<div class="pageContent">
<h2>Bilan de l'assiduité de <span class="rouge">{{sco.etud.nomprenom}}</span></h2>
<section class="alerte invisible">
<p>Attention, cet étudiant a trop d'absences</p>
</section>
<section class="stats">
<!-- Statistiques d'assiduité (nb pres, nb retard, nb absence) + nb justifié -->
<h4>Statistiques d'assiduité</h4>
<div class="stats-inputs">
<label class="stats-label"> Date de début<input type="date" name="stats_date_debut" id="stats_date_debut"
value="{{date_debut}}"></label>
<label class="stats-label"> Date de fin<input type="date" name="stats_date_fin" id="stats_date_fin"
value="{{date_fin}}"></label>
<button onclick="stats()">Actualiser</button>
</div>
<div class="stats-values">
</div>
</section>
<section class="nonvalide">
<!-- Tableaux des assiduités (retard/abs) non justifiées -->
<h4>Assiduités non justifiées (Uniquement les retards et les absences)</h4>
{% include "assiduites/widgets/tableau_assi.j2" %}
<!-- Tableaux des justificatifs à valider (attente / modifié ) -->
<h4>Justificatifs en attente (ou modifiés)</h4>
{% include "assiduites/widgets/tableau_justi.j2" %}
</section>
<section class="suppr">
<h4>Boutons de suppresions (toute suppression est définitive) </h4>
<button type="button" onclick="removeAllAssiduites()">Suppression des assiduités</button>
<button type="button" onclick="removeAllJustificatifs()">Suppression des justificatifs</button>
</section>
<div class="legende">
<h3>Statistiques</h3>
<p>Un message d'alerte apparait si le nombre d'absence dépasse le seuil (indiqué dans les préférences du
département)</p>
<p>Les statistiques sont effectuées entre les deux dates séléctionnées. Si vous modifier les dates il faudra
appuyer sur le bouton "Actualiser"</p>
<h3>Gestion des justificatifs</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
</p>
<ul>
<li>Détails : Affiche les détails du justificatif sélectionné</li>
<li>Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)</li>
<li>Supprimer : Permet de supprimer le justificatif (Action Irréversible)</li>
</ul>
<h3>Gestion des Assiduités</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
</p>
<ul>
<li>Détails : Affiche les détails de l'assiduité sélectionnée</li>
<li>Editer : Permet de modifier l'assiduité (moduleimpl, etat)</li>
<li>Supprimer : Permet de supprimer l'assiduité (Action Irréversible)</li>
</ul>
</div>
</div>
{% endblock app_content %}
<script>
function stats() {
const dd_val = document.getElementById('stats_date_debut').value;
const df_val = document.getElementById('stats_date_fin').value;
if (dd_val == "" || df_val == "") {
openAlertModal("Dates invalides", document.createTextNode('Les dates sélectionnées sont invalides'));
return;
}
const date_debut = new moment.tz(dd_val + "T00:00", TIMEZONE);
const date_fin = new moment.tz(df_val + "T23:59", TIMEZONE);
if (date_debut.valueOf() > date_fin.valueOf()) {
openAlertModal("Dates invalides", document.createTextNode('La date de début se situe après la date de fin.'));
return;
}
countAssiduites(date_debut.format(), date_fin.format())
}
function getAssiduitesCount(dateDeb, dateFin, query) {
const url_api = getUrl() + `/api/assiduites/${etudid}/count/query?date_debut=${dateDeb}&date_fin=${dateFin}&${query}`;
return $.ajax({
async: true,
type: "GET",
url: url_api,
success: (data, status) => {
if (status === "success") {
}
},
error: () => { },
});
}
function countAssiduites(dateDeb, dateFin) {
$.when(
getAssiduitesCount(dateDeb, dateFin, `etat=present`),
getAssiduitesCount(dateDeb, dateFin, `etat=present&est_just=v`),
getAssiduitesCount(dateDeb, dateFin, `etat=retard`),
getAssiduitesCount(dateDeb, dateFin, `etat=retard&est_just=v`),
getAssiduitesCount(dateDeb, dateFin, `etat=absent`),
getAssiduitesCount(dateDeb, dateFin, `etat=absent&est_just=v`),
).then(
(pt, pj, rt, rj, at, aj) => {
const counter = {
"present": {
"total": pt[0],
"justi": pj[0],
},
"retard": {
"total": rt[0],
"justi": rj[0],
},
"absent": {
"total": at[0],
"justi": aj[0],
}
}
const values = document.querySelector('.stats-values');
values.innerHTML = "";
Object.keys(counter).forEach((key) => {
const item = document.createElement('div');
item.classList.add('stats-values-item');
const div = document.createElement('div');
div.classList.add('stats-values-part');
const heure = document.createElement('span');
heure.textContent = `${counter[key].total.heure} heure(s) dont ${counter[key].justi.heure} justifiées`;
const demi = document.createElement('span');
demi.textContent = `${counter[key].total.demi} demi-journée(s) dont ${counter[key].justi.demi} justifiées`;
const jour = document.createElement('span');
jour.textContent = `${counter[key].total.journee} journée(s) dont ${counter[key].justi.journee} justifiées`;
div.append(jour, demi, heure);
const title = document.createElement('h5');
title.textContent = key.capitalize();
item.append(title, div)
values.appendChild(item);
});
const nbAbs = counter.absent.total[assi_metric] - counter.absent.justi[assi_metric];
if (nbAbs > assi_seuil) {
document.querySelector('.alerte').classList.remove('invisible');
document.querySelector('.alerte p').textContent = `Attention, cet étudiant a trop d'absences ${nbAbs} / ${assi_seuil} (${metriques[assi_metric]})`
} else {
document.querySelector('.alerte').classList.add('invisible');
}
}
);
}
function removeAllAssiduites() {
openPromptModal(
"Suppression des assiduités",
document.createTextNode(
'Souhaitez vous réelement supprimer toutes les assiduités de cet étudiant ? Cette supression est irréversible.')
,
() => {
getAllAssiduitesFromEtud(etudid, (data) => {
const toRemove = data.map((a) => a.assiduite_id);
console.log(toRemove)
deleteAssiduites(toRemove);
})
})
}
function removeAllJustificatifs() {
openPromptModal(
"Suppression des justificatifs",
document.createTextNode(
'Souhaitez vous réelement supprimer tous les justificatifs de cet étudiant ? Cette supression est irréversible.')
,
() => {
getAllJustificatifsFromEtud(etudid, (data) => {
const toRemove = data.map((a) => a.justif_id);
deleteJustificatifs(toRemove);
})
})
}
/**
* Suppression des assiduties
*/
function deleteAssiduites(assi) {
const path = getUrl() + `/api/assiduite/delete`;
async_post(
path,
assi,
(data, status) => {
//success
if (data.success.length > 0) {
}
location.reload();
},
(data, status) => {
//error
console.error(data, status);
errorAlert();
}
);
}
/**
* Suppression des justificatifs
*/
function deleteJustificatifs(justis) {
const path = getUrl() + `/api/justificatif/delete`;
async_post(
path,
justis,
(data, status) => {
//success
location.reload();
},
(data, status) => {
//error
console.error(data, status);
errorAlert();
}
);
}
const metriques = {
"heure": "H.",
"demi": "1/2 J.",
"journee": "J."
}
const etudid = {{ sco.etud.id }};
const assi_metric = "{{ assi_metric | safe }}";
const assi_seuil = {{ assi_seuil }};
const assi_date_debut = "{{date_debut}}";
const assi_date_fin = "{{date_fin}}";
window.addEventListener('load', () => {
filterAssiduites = {
"columns": [
"entry_date",
"date_debut",
"date_fin",
"etat",
"moduleimpl_id",
"est_just"
],
"filters": {
"etat": [
"retard",
"absent"
],
"moduleimpl_id": "",
"est_just": "false"
}
};
filterJustificatifs = {
"columns": [
"entry_date",
"date_debut",
"date_fin",
"etat",
"raison",
"fichier"
],
"filters": {
"etat": [
"attente",
"modifie"
]
}
}
document.getElementById('stats_date_fin').value = assi_date_fin;
document.getElementById('stats_date_debut').value = assi_date_debut;
loadAll();
stats();
})
</script>
<style>
.stats-values-item {
display: flex;
justify-content: space-evenly;
align-items: center;
flex-direction: column;
}
.stats {
border: 1px solid #333;
padding: 5px 2px;
width: fit-content;
}
.stats-values {
display: flex;
justify-content: flex-start;
gap: 15px;
}
.stats-values-item h5 {
font-weight: bold;
text-decoration-line: underline;
}
.stats-values-part {
display: flex;
flex-direction: column;
}
.alerte {
display: flex;
justify-content: center;
align-items: center;
padding: 10px;
margin: 5px 0;
border-radius: 7px;
background-color: crimson;
}
.alerte.invisible {
display: none;
}
.alerte p {
font-size: larger;
color: whitesmoke;
}
.suppr {
margin: 5px 0;
}
</style>

View File

@ -0,0 +1,358 @@
{% block pageContent %}
{% include "assiduites/widgets/alert.j2" %}
<div class="pageContent">
{{minitimeline | safe }}
<h2>Assiduités de {{sco.etud.nomprenom}}</h2>
<div class="calendrier">
</div>
<div class="annee">
<span>Année scolaire 2022-2023 Changer année: </span>
<select name="" id="annee" onchange="setterAnnee(this.value)">
</select>
</div>
<div class="legende">
<h3>Calendrier</h3>
<p>Les jours non travaillés sont affiché en violet</p>
<p>Les jours possèdant une bordure "bleu" sont des jours où des assiduités ont été justifiées par un
justificatif valide</p>
<p>Les jours possèdant une bordure "rouge" sont des jours où des assiduités ont été justifiées par un
justificatif non valide</p>
<p>Le jour sera affiché en : </p>
<ul>
<li>Rouge : S'il y a une assiduité "Absent"</li>
<li>Orange : S'il y a une assiduité "Retard" et pas d'assiduité "Absent"</li>
<li>Vert : S'il y a une assiduité "Present" et pas d'assiduité "Absent" ni "Retard"</li>
<li>Blanc : S'il n'y a pas d'assiduité</li>
</ul>
<p>Vous pouvez passer votre curseur sur les jours colorés afin de voir les assiduités de cette journée.</p>
</div>
</div>
<style>
.pageContent {
margin-top: 1vh;
max-width: var(--sco-content-max-width);
}
.calendrier {
display: flex;
justify-content: space-evenly;
flex-wrap: wrap;
border: 1px solid #444;
}
.month h2 {
text-align: center;
}
.day {
border: 1px solid #444;
border-radius: 8px;
padding: 0 5px;
text-align: center;
margin: 2px;
cursor: default;
font-size: 12px;
position: relative;
}
.day.est_just {
border-left: 10px solid #7059FF;
}
.day.est_just.invalide {
border-left: 10px solid #f64e4e;
}
.day .dayline {
position: absolute;
display: none;
left: -237%;
bottom: -420%;
z-index: 50;
width: 250px;
height: 75px;
background-color: #dedede;
border-radius: 15px;
padding: 5px;
}
.day:hover .dayline {
display: block;
}
.dayline .mini-timeline {
margin-top: 14%;
}
.dayline .mini_tick {
position: absolute;
text-align: center;
top: 0;
transform: translateY(-110%);
z-index: 50;
}
.dayline .mini_tick::after {
display: block;
content: "|";
position: absolute;
bottom: -69%;
z-index: 2;
transform: translateX(200%);
}
</style>
<script>
function getDaysBetweenDates(start, end) {
let now = moment(start);
let dates = [];
while (now.isSameOrBefore(end)) {
dates.push(now.clone());
now.add(1, "days");
}
return dates;
}
function organizeByMonth(dates) {
let datesByMonth = {};
dates.forEach((date) => {
let month = date.format("MMMM"); // Obtenir le mois
if (!datesByMonth[month]) {
datesByMonth[month] = [];
}
datesByMonth[month].push(date);
});
return datesByMonth;
}
function organizeAssiduitiesByDay(datesByMonth, assiduities, justificatifs) {
let assiduitiesByDay = {};
Object.keys(datesByMonth).forEach((month) => {
assiduitiesByDay[month] = {};
datesByMonth[month].forEach((date) => {
let dayAssiduities = assiduities.filter((assiduity) => {
return moment.tz(date, TIMEZONE).isBetween(
moment.tz(assiduity.date_debut, TIMEZONE),
moment.tz(assiduity.date_fin, TIMEZONE),
"day",
"[]"
)
});
let dayJustificatifs = justificatifs.filter((justif) => {
return moment.tz(date, TIMEZONE).isBetween(
moment.tz(justif.date_debut, TIMEZONE),
moment.tz(justif.date_fin, TIMEZONE),
"day",
"[]"
)
});
assiduitiesByDay[month][date.format("YYYY-MM-DD")] = {
assiduites: dayAssiduities,
justificatifs: dayJustificatifs
};
});
});
return assiduitiesByDay;
}
function getDayColor(etat) {
let color;
switch (etat.toUpperCase()) {
case "PRESENT":
color = "#6bdb83";
break;
case "ABSENT":
color = "#F1A69C";
break;
case "RETARD":
color = "#f0c865";
break;
case "NONWORK":
color = "#bd81ca"
break;
default:
color = "#FFF";
break;
}
return color;
}
function generateCalendar(assiduitiesByDay, nonWorkdays = []) {
const calendar = document.querySelector('.calendrier')
calendar.innerHTML = ""
const days = {
Mon: "Lun",
Tue: "Mar",
Wed: "Mer",
Thu: "Jeu",
Fri: "Ven",
Sat: "Sam",
Sun: "Dim",
};
const months = {
January: "Jan.",
February: "Fev.",
March: "Mar.",
April: "Avr.",
May: "Mai",
June: "Juin",
July: "Juil.",
August: "Août",
September: "Sep.",
October: "Oct.",
November: "Nov.",
December: "Déc.",
};
Object.keys(assiduitiesByDay).forEach((month) => {
const monthEl = document.createElement('div')
monthEl.classList.add("month")
const title = document.createElement('h2');
title.textContent = `${months[month]}`;
monthEl.appendChild(title)
const daysEl = document.createElement('div')
daysEl.classList.add('days');
Object.keys(assiduitiesByDay[month]).forEach((date) => {
let dayAssiduities = assiduitiesByDay[month][date].assiduites;
let dayJustificatifs = assiduitiesByDay[month][date].justificatifs;
let color = "white";
if (dayAssiduities.some((a) => a.etat.toLowerCase() === "absent")) color = "absent";
else if (dayAssiduities.some((a) => a.etat.toLowerCase() === "retard"))
color = "retard";
else if (dayAssiduities.some((a) => a.etat.toLowerCase() === "present"))
color = "present";
let est_just = ""
if (dayJustificatifs.some((j) => j.etat.toLowerCase() === "valide")) {
est_just = "est_just";
} else if (dayJustificatifs.some((j) => j.etat.toLowerCase() !== "valide")) {
est_just = "est_just invalide";
}
const momentDate = moment.tz(date, TIMEZONE);
let dayOfMonth = momentDate.format("D");
let dayOfWeek = momentDate.format("ddd");
dayOfWeek = days[dayOfWeek];
if (nonWorkdays.includes(dayOfWeek.toLowerCase())) color = "nonwork";
const day = document.createElement('div');
day.className = `day ${est_just}`
day.style.backgroundColor = getDayColor(color);
day.textContent = `${dayOfWeek} ${dayOfMonth}`;
if (!nonWorkdays.includes(dayOfWeek.toLowerCase()) && dayAssiduities.length > 0) {
const cache = document.createElement('div')
cache.classList.add('dayline');
cache.appendChild(
createMiniTimeline(dayAssiduities, date)
)
day.appendChild(cache)
}
daysEl.appendChild(day);
});
monthEl.appendChild(daysEl)
calendar.appendChild(monthEl)
});
}
function getEtudAssiduites(deb, fin, callback = () => { }) {
const url_api =
getUrl() +
`/api/assiduites/${etudid}/query?date_debut=${deb}&date_fin=${fin}`;
async_get(url_api, (data, status) => {
if (status === "success") {
callback(data);
}
});
}
function getEtudJustificatifs(deb, fin) {
let list = [];
const url_api =
getUrl() +
`/api/justificatifs/${etudid}/query?date_debut=${deb}&date_fin=${fin}`;
sync_get(url_api, (data, status) => {
if (status === "success") {
list = data;
}
});
return list
}
function generate(annee) {
if (annee < 1999 || annee > 2999) {
openAlertModal("Année impossible", document.createTextNode("L'année demandé n'existe pas."));
return;
}
const bornes = {
deb: `${annee}-09-01T00:00`,
fin: `${annee + 1}-08-31T23:59`
}
let assiduities = getEtudAssiduites(bornes.deb, bornes.fin, (data) => {
let dates = getDaysBetweenDates(bornes.deb, bornes.fin);
let datesByMonth = organizeByMonth(dates);
const justifs = getEtudJustificatifs(bornes.deb, bornes.fin);
let assiduitiesByDay = organizeAssiduitiesByDay(datesByMonth, data, justifs);
generateCalendar(assiduitiesByDay, nonwork);
});
}
function setterAnnee(annee) {
annee = parseInt(annee);
document.querySelector('.annee span').textContent = `Année scolaire ${annee}-${annee + 1} Changer année: `
generate(annee)
}
const defAnnee = {{ annee }}
let annees = {{ annees | safe }}
annees = annees.filter((x, i) => annees.indexOf(x) === i)
const etudid = {{ sco.etud.id }};
const nonwork = [{{ nonworkdays | safe }}];
window.onload = () => {
const select = document.querySelector('#annee');
annees.forEach((a) => {
const opt = document.createElement("option");
opt.value = a + "",
opt.textContent = `${a} - ${a + 1}`;
if (a === defAnnee) {
opt.selected = true;
}
select.appendChild(opt)
})
setterAnnee(defAnnee)
};
</script>
{% endblock pageContent %}

View File

@ -0,0 +1,38 @@
{% extends "base.j2" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block app_content %}
<div class="row">
<h1>Configuration du suivi de l'assiduité</h1>
<div class="help"> Ces paramètres seront utilisés par tous les départements et
affectent notamment les comptages d'absences de tous les bulletins des
étudiants&nbsp;: ne changer que lorsque c'est vraiment nécessaire.
</div>
</div>
<div class="row">
<div class="col-md-8">
<form class="form form-horizontal" method="post" enctype="multipart/form-data" role="form">
{{ form.hidden_tag() }}
{{ wtf.form_errors(form, hiddens="only") }}
{{ wtf.form_field(form.morning_time) }}
{{ wtf.form_field(form.lunch_time) }}
{{ wtf.form_field(form.afternoon_time) }}
{{ wtf.form_field(form.tick_time) }}
<div class="form-group">
{{ wtf.form_field(form.submit) }}
{{ wtf.form_field(form.cancel) }}
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,36 @@
<h2>Présence lors de l'évaluation {{eval.title}} </h2>
<h3>Réalisé le {{eval.jour}} de {{eval.heure_debut}} à {{eval.heure_fin}}</h3>
<table>
<thead>
<tr>
<th>
Nom
</th>
<th>
Assiduité
</th>
</tr>
</thead>
<tbody>
{% for etud in etudiants %}
<tr>
<td>
{{etud.nom | safe}}
</td>
<td style="text-align: center;">
{{etud.etat}}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<style>
tr,
td {
background-color: #FFFFFF;
}
</style>

View File

@ -0,0 +1,55 @@
{% block app_content %}
<div class="pageContent">
<h2>Liste de l'assiduité et des justificatifs de <span class="rouge">{{sco.etud.nomprenom}}</span></h2>
{% include "assiduites/widgets/tableau_base.j2" %}
<h3>Assiduités :</h3>
<a class="icon filter" onclick="filter()"></a>
{% include "assiduites/widgets/tableau_assi.j2" %}
<h3>Justificatifs :</h3>
<a class="icon filter" onclick="filter(false)"></a>
{% include "assiduites/widgets/tableau_justi.j2" %}
<ul id="contextMenu" class="context-menu">
<li id="detailOption">Detail</li>
<li id="editOption">Editer</li>
<li id="deleteOption">Supprimer</li>
</ul>
<div class="legende">
<h3>Gestion des justificatifs</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
</p>
<ul>
<li>Détails : Affiche les détails du justificatif sélectionné</li>
<li>Editer : Permet de modifier le justificatif (dates, etat, ajouter/supprimer fichier etc)</li>
<li>Supprimer : Permet de supprimer le justificatif (Action Irréversible)</li>
</ul>
<p>Vous pouvez filtrer le tableau en cliquant sur l'icone d'entonoir sous le titre du tableau.</p>
<h3>Gestion des Assiduités</h3>
<p>
Faites
<span style="font-style: italic;">clic droit</span> sur une ligne du tableau pour afficher le menu
contextuel :
</p>
<ul>
<li>Détails : Affiche les détails de l'assiduité sélectionnée</li>
<li>Editer : Permet de modifier l'assiduité (moduleimpl, etat)</li>
<li>Supprimer : Permet de supprimer l'assiduité (Action Irréversible)</li>
</ul>
<p>Vous pouvez filtrer le tableau en cliquant sur l'icone d'entonoir sous le titre du tableau.</p>
</div>
</div>
{% endblock app_content %}
<script>
const etudid = {{ sco.etud.id }}
window.onload = () => {
loadAll();
}
</script>

View File

@ -0,0 +1,40 @@
<h2>Signalement différé des assiduités {{gr |safe}}</h2>
<div class="legende">
<h3>Explication de la saisie différée</h3>
<p>Si la colonne n'est pas valide elle sera affichée en rouge, passez votre curseur sur la colonne pour afficher
le message d'erreur</p>
<p>Sélectionner la date de début de la colonne mettra automatiquement la date de fin à la durée d'une séance
(préférence de département)</p>
<p>Modifier le moduleimpl alors que des assiduités sont déjà enregistrées pour la période changera leur
moduleimpl.</p>
<p>Il y a 4 boutons d'assiduités sur la colonne permettant de mettre l'assiduités à tous les étudiants</p>
<p>Le dernier des boutons retire l'assiduité.</p>
<p>Vous pouvez ajouter des colonnes en appuyant sur le bouton + </p>
<p>Vous pouvez supprimer une colonne en appuyant sur la croix qui se situe dans le coin haut droit de la colonne
</p>
</div>
<h3>{{sem | safe }}</h3>
{{diff | safe}}
<script>
const etudsDefDem = {{ defdem | safe }}
window.addEventListener('load', () => {
[...document.querySelectorAll('.tr[etudid]')].forEach((a) => {
try {
if (a.getAttribute("etudid") in etudsDefDem) {
defdem = etudsDefDem[a.getAttribute("etudid")] == "D" ? "dem" : "def";
a.classList.add(defdem);
}
} catch (_) { }
})
})
</script>
{% include "assiduites/widgets/alert.j2" %}
{% include "assiduites/widgets/prompt.j2" %}
{% include "assiduites/widgets/conflict.j2" %}
{% include "assiduites/widgets/toast.j2" %}

View File

@ -0,0 +1,138 @@
{# -*- mode: jinja-html -*- #}
{% include "assiduites/widgets/toast.j2" %}
{% include "assiduites/widgets/alert.j2" %}
{% include "assiduites/widgets/prompt.j2" %}
{% include "assiduites/widgets/conflict.j2" %}
<div id="page-assiduite-content">
{% block content %}
<h2>Signalement de l'assiduité de <span class="rouge">{{sco.etud.nomprenom}}</span></h2>
<div class="infos">
Date: <span id="datestr"></span>
<input type="date" name="tl_date" id="tl_date" value="{{ date }}">
</div>
{{timeline|safe}}
<div>
{{moduleimpl_select | safe }}
<button class="btn" onclick="fastJustify(getCurrentAssiduite(etudid))" id="justif-rapide">Justifier</button>
</div>
<div class="btn_group">
<button class="btn" onclick="setTimeLineTimes({{morning}},{{afternoon}})">Journée</button>
<button class="btn" onclick="setTimeLineTimes({{morning}},{{lunch}})">Matin</button>
<button class="btn" onclick="setTimeLineTimes({{lunch}},{{afternoon}})">Après-midi</button>
</div>
<div class="etud_holder">
<div id="etud_row_{{sco.etud.id}}">
<div class="index"></div>
</div>
</div>
<hr>
{{diff | safe}}
<div class="legende">
<h3>Explication de la timeline</h3>
<p>
Si la période indiquée par la timeline provoque un conflit d'assiduité pour un étudiant sa ligne deviendra
rouge.
<br>
Dans ce cas il faut résoudre manuellement le conflit : cliquez sur un des boutons d'assiduités pour ouvrir
le
résolveur de conflit.
<br>
Correspondance des couleurs :
</p>
<ul>
<li><span title="Vert" class="present demo"></span> &rightarrow; présence de l'étudiant lors de la période
</li>
<li><span title="Orange" class="retard demo"></span> &rightarrow; retard de l'étudiant lors de la période
</li>
<li><span title="Rouge" class="absent demo"></span> &rightarrow; absence de l'étudiant lors de la période
</li>
<li><span title="Hachure Bleue" class="justified demo"></span> &rightarrow; l'assiduité est justifiée par un
justificatif valide</li>
<li><span title="Hachure Rouge" class="invalid_justified demo"></span> &rightarrow; l'assiduité est
justifiée par un justificatif non valide / en attente de validation
</li>
</ul>
<p>Vous pouvez justifier rapidement une assiduité en saisisant l'assiduité puis en appuyant sur "Justifier"</p>
<h3>Explication de la saisie différée</h3>
<p>Si la colonne n'est pas valide elle sera affichée en rouge, passez votre curseur sur la colonne pour afficher
le message d'erreur</p>
<p>Sélectionner la date de début de la colonne mettra automatiquement la date de fin à la durée d'une séance
(préférence de département)</p>
<p>Modifier le moduleimpl alors que des assiduités sont déjà enregistrées pour la période changera leur
moduleimpl.</p>
<p>Il y a 4 boutons d'assiduités sur la colonne permettant de mettre l'assiduités à tous les étudiants</p>
<p>Le dernier des boutons retire l'assiduité.</p>
<p>Vous pouvez ajouter des colonnes en appuyant sur le bouton + </p>
<p>Vous pouvez supprimer une colonne en appuyant sur la croix qui se situe dans le coin haut droit de la colonne
</p>
</div>
<!-- Ajout d'un conteneur pour le loader -->
<div class="loader-container" id="loaderContainer">
<div class="loader"></div>
</div>
<script>
const etudid = {{ sco.etud.id }};
const nonWorkDays = [{{ nonworkdays| safe }}];
setupDate(() => {
if (updateDate()) {
actualizeEtud(etudid);
updateSelect();
onlyAbs();
}
});
setupTimeLine(() => {
updateJustifyBtn();
});
updateDate();
getSingleEtud({{ sco.etud.id }});
actualizeEtud({{ sco.etud.id }});
updateSelect()
updateJustifyBtn();
function setTimeLineTimes(a, b) {
setPeriodValues(a, b);
updateJustifyBtn();
}
window.forceModule = "{{ forcer_module }}"
window.forceModule = window.forceModule == "True" ? true : false
</script>
<style>
.justifie {
background-color: rgb(104, 104, 252);
color: whitesmoke;
}
fieldset {
outline: none;
border: none;
}
</style>
{% endblock %}
</div>

View File

@ -0,0 +1,126 @@
{% include "assiduites/widgets/toast.j2" %}
<section id="content">
<div class="no-display">
<span class="formsemestre_id">{{formsemestre_id}}</span>
<span id="formsemestre_date_debut">{{formsemestre_date_debut}}</span>
<span id="formsemestre_date_fin">{{formsemestre_date_fin}}</span>
</div>
<h2>
Saisie des assiduités {{gr_tit|safe}} {{sem}}
</h2>
{% if readonly == "true" %}
<h1 style="font-weight: bolder;color:crimson">La page est en lecture seule.</h1>
{% endif %}
<fieldset class="selectors">
<div>Groupes : {{grp|safe}}</div>
<div class="infos">
Date: <span id="datestr"></span>
<input type="date" name="tl_date" id="tl_date" value="{{ date }}" onchange="updateDate()">
</div>
</fieldset>
{% if readonly == "true" %}
<button id="validate_selectors" onclick="validateSelectors(this)">
Voir la saisie
</button>
{% else %}
<button id="validate_selectors" onclick="validateSelectors(this)">
Faire la saisie
</button>
{% endif %}
{{timeline|safe}}
{% if readonly == "false" %}
<div style="margin: 1vh 0;">
<div id="forcemodule" style="display: none; margin:10px 0px;">
Vous devez spécifier le module ! (voir réglage préférence du semestre)
</div>
<div>Module :{{moduleimpl_select|safe}}</div>
</div>
{% else %}
{% endif %}
<div class="etud_holder">
<p class="placeholder">
Veillez à choisir le groupe concerné par la saisie ainsi que la date de la saisie.
Après validation, il faudra recharger la page pour changer les informations de la saisie.
</p>
</div>
<div class="legende">
<h3>Explication diverses</h3>
<p>
Si la période indiquée par la timeline provoque un conflit d'assiduité pour un étudiant sa ligne deviendra
rouge.
<br>
Dans ce cas il faut résoudre manuellement le conflit : cliquez sur un des boutons d'assiduités pour ouvrir
le
résolveur de conflit.
<br>
Correspondance des couleurs :
</p>
<ul>
<li><span title="Vert" class="present demo"></span> &rightarrow; présence de l'étudiant lors de la période
</li>
<li><span title="Orange" class="retard demo"></span> &rightarrow; retard de l'étudiant lors de la période
</li>
<li><span title="Rouge" class="absent demo"></span> &rightarrow; absence de l'étudiant lors de la période
</li>
<li><span title="Hachure Bleue" class="justified demo"></span> &rightarrow; l'assiduité est justifiée par un
justificatif valide</li>
<li><span title="Hachure Rouge" class="invalid_justified demo"></span> &rightarrow; l'assiduité est
justifiée par un justificatif non valide / en attente de validation
</li>
</ul>
</div>
<!-- Ajout d'un conteneur pour le loader -->
<div class="loader-container" id="loaderContainer">
<div class="loader"></div>
</div>
{% include "assiduites/widgets/alert.j2" %}
{% include "assiduites/widgets/prompt.j2" %}
{% include "assiduites/widgets/conflict.j2" %}
<script>
const nonWorkDays = [{{ nonworkdays| safe }}];
const readOnly = {{ readonly }};
updateDate();
setupDate();
setupTimeLine();
window.forceModule = "{{ forcer_module }}"
window.forceModule = window.forceModule == "True" ? true : false
const etudsDefDem = {{ defdem | safe }}
if (window.forceModule) {
const btn = document.getElementById("validate_selectors");
const select = document.getElementById("moduleimpl_select");
if (!readOnly && select.value == "") {
document.getElementById('forcemodule').style.display = "block";
}
select?.addEventListener('change', (e) => {
if (e.target.value != "") {
document.getElementById('forcemodule').style.display = "none";
} else {
document.getElementById('forcemodule').style.display = "block";
}
});
}
</script>
</section>

View File

@ -0,0 +1,46 @@
{% extends "sco_page.j2" %}
{% block scripts %}
{{ super() }}
<script src="{{scu.STATIC_DIR}}/js/etud_info.js"></script>
{% endblock %}
{% block app_content %}
<h2>Visualisation de l'assiduité {{gr_tit|safe}}</h2>
<div class="stats-inputs">
<label class="stats-label"> Date de début <input type="date" name="stats_date_debut" id="stats_date_debut"
value="{{date_debut}}"></label>
<label class="stats-label"> Date de fin <input type="date" name="stats_date_fin" id="stats_date_fin"
value="{{date_fin}}"></label>
<button onclick="stats()">Changer</button>
<a style="margin-left:32px;" href="{{request.url}}&format=xlsx">{{scu.ICON_XLS|safe}}</a>
</div>
{{tableau | safe}}
<div class=""help">
Les comptes sont exprimés en {{ assi_metric }}.
</div>
<script>
const date_debut = "{{date_debut}}";
const date_fin = "{{date_fin}}";
const group_ids = "{{group_ids}}";
function stats() {
const deb = document.querySelector('#stats_date_debut').value;
const fin = document.querySelector('#stats_date_fin').value;
location.href = `VisualisationAssiduitesGroupe?group_ids=${group_ids}&date_debut=${deb}&date_fin=${fin}`;
}
window.addEventListener('load', () => {
document.querySelector('#stats_date_debut').value = date_debut;
document.querySelector('#stats_date_fin').value = date_fin;
})
</script>
{% endblock %}

View File

@ -0,0 +1,160 @@
{% block alertmodal %}
<div id="alertModal" class="alertmodal">
<!-- alertModal content -->
<div class="alertmodal-content">
<div class="alertmodal-header">
<span class="alertmodal-close">&times;</span>
<h2 class="alertmodal-title">alertModal Header</h2>
</div>
<div class="alertmodal-body">
<p>Some text in the alertModal Body</p>
<p>Some other text...</p>
</div>
<div class="alertmodal-footer">
<h3>alertModal Footer</h3>
</div>
</div>
</div>
<style>
/* The alertModal (background) */
.alertmodal {
display: none;
/* Hidden by default */
position: fixed;
/* Stay in place */
z-index: 850;
/* Sit on top */
padding-top: 100px;
/* Location of the box */
left: 0;
top: 0;
width: 100%;
/* Full width */
height: 100%;
/* Full height */
overflow: auto;
/* Enable scroll if needed */
background-color: rgb(0, 0, 0);
/* Fallback color */
background-color: rgba(0, 0, 0, 0.4);
/* Black w/ opacity */
}
/* alertModal Content */
.alertmodal-content {
border-radius: 8px;
overflow: hidden;
position: relative;
background-color: #fefefe;
margin: auto;
padding: 0;
border: 1px solid #888;
width: 45%;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
-webkit-animation-name: animatetop;
-webkit-animation-duration: 0.4s;
animation-name: animatetop;
animation-duration: 0.4s
}
/* Add Animation */
@-webkit-keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
@keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
/* The Close Button */
.alertmodal-close {
color: white;
position: absolute;
right: 10px;
font-size: 28px;
font-weight: bold;
}
.alertmodal-close:hover,
.alertmodal-close:focus {
color: #000;
text-decoration: none;
cursor: pointer;
}
.alertmodal-header {
padding: 2px 16px;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.alertmodal-body {
padding: 2px 16px;
}
.alertmodal-footer {
padding: 2px 16px;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.alertmodal.is-active {
display: block;
}
</style>
<script>
const alertmodal = document.getElementById("alertModal");
function openAlertModal(titre, contenu, footer, color = "crimson") {
alertmodal.classList.add('is-active');
alertmodal.querySelector('.alertmodal-title').textContent = titre;
alertmodal.querySelector('.alertmodal-body').innerHTML = ""
alertmodal.querySelector('.alertmodal-body').appendChild(contenu);
alertmodal.querySelector('.alertmodal-footer').textContent = footer;
const banners = Array.from(alertmodal.querySelectorAll('.alertmodal-footer,.alertmodal-header'))
banners.forEach((ban) => {
ban.style.backgroundColor = color;
})
alertmodal.addEventListener('click', (e) => {
if (e.target.id == alertmodal.id) {
alertmodal.classList.remove('is-active');
alertmodal.removeEventListener('click', this)
}
})
}
function closeAlertModal() {
alertmodal.classList.remove("is-active")
}
const alertClose = document.querySelector(".alertmodal-close");
alertClose.onclick = function () {
closeAlertModal()
}
</script>
{% endblock alertmodal %}

View File

@ -0,0 +1,462 @@
<script>
/**
* Transformation d'une date de début en position sur la timeline
* @param {String} start
* @returns {String} un déplacement par rapport à la gauche en %
*/
function getLeftPosition(start) {
const startTime = new moment.tz(start, TIMEZONE);
const startMins = (startTime.hours() - 8) * 60 + startTime.minutes();
return (startMins / (18 * 60 - 8 * 60)) * 100 + "%";
}
/**
* Ajustement de l'espacement vertical entre les assiduités superposées
* @param {HTMLElement} container le conteneur des assiduités
* @param {String} start la date début de l'assiduité à placer
* @param {String} end la date de fin de l'assiduité à placer
* @returns {String} La position en px
*/
function getTopPosition(container, start, end) {
const overlaps = (a, b) => {
return a.start < b.end && a.end > b.start;
};
const startTime = new moment.tz(start, TIMEZONE);
const endTime = new moment.tz(end, TIMEZONE);
const assiduiteDuration = { start: startTime, end: endTime };
let position = 0;
let hasOverlap = true;
while (hasOverlap) {
hasOverlap = false;
Array.from(container.children).some((el) => {
const elStart = new moment.tz(el.getAttribute("data-start"));
const elEnd = new moment.tz(el.getAttribute("data-end"));
const elDuration = { start: elStart, end: elEnd };
if (overlaps(assiduiteDuration, elDuration)) {
position += 25; // Pour ajuster l'espacement vertical entre les assiduités superposées
hasOverlap = true;
return true;
}
return false;
});
}
return position + "px";
}
/**
* Transformation d'un état en couleur
* @param {String} state l'état
* @returns {String} la couleur correspondant à l'état
*/
function getColor(state) {
switch (state) {
case "PRESENT":
return "#9CF1AF";
case "ABSENT":
return "#F1A69C";
case "RETARD":
return "#F1D99C";
default:
return "gray";
}
}
/**
* Calcule de la largeur de l'assiduité sur la timeline
* @param {String} start date iso de début
* @param {String} end date iso de fin
* @returns {String} la taille en %
*/
function getWidth(start, end) {
const startTime = new moment.tz(start, TIMEZONE);
const endTime = new moment.tz(end, TIMEZONE);
const duration = (endTime - startTime) / 1000 / 60;
const percent = (duration / (18 * 60 - 8 * 60)) * 100
if (percent > 100) {
console.log(start, end);
console.log(startTime, endTime)
}
return percent + "%";
}
class ConflitResolver {
constructor(assiduitesList, conflictPeriod, interval) {
this.list = assiduitesList;
this.conflictPeriod = conflictPeriod;
this.interval = interval;
this.selectedAssiduite = null;
this.element = undefined;
this.callbacks = {
delete: () => { },
split: () => { },
edit: () => { },
}
}
refresh(assiduitesList, periode) {
this.list = assiduitesList;
if (periode) {
this.conflictPeriod = periode;
}
this.render()
}
selectAssiduite() {
}
open() {
const html = `
<div id="myModal" class="modal">
<div class="modal-content">
<span class="close">&times;</span>
<h2>Veuillez régler le conflit pour poursuivre</h2>
<!-- Ajout de la frise chronologique -->
<div class="modal-timeline">
<div class="time-labels"></div>
<div class="assiduites-container"></div>
</div>
<div class="action-buttons">
<button id="finish" class="btnPrompt">Terminer la résolution</button>
<button id="delete" class="btnPrompt" disabled>Supprimer</button>
<button id="split" class="btnPrompt" disabled>Séparer</button>
<button id="edit" class="btnPrompt" disabled>Modifier l'état</button>
</div>
</div>
<div class="modal-assiduite-content">
<h2>Information de l'assiduité sélectionnée</h2>
<div class="infos">
<p>Assiduite id : <span id="modal-assiduite-id">A</span></p>
<p>Etat : <span id="modal-assiduite-etat">B</span></p>
<p>Date de début : <span id="modal-assiduite-deb">C</span></p>
<p>Date de fin: <span id="modal-assiduite-fin">D</span></p>
<p>Module : <span id="modal-assiduite-module">E</span></p>
<p><span id="modal-assiduite-user">F</span></p>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML("afterbegin", html);
this.element = document.getElementById('myModal');
this.deleteBtn = document.querySelector('#myModal #delete');
this.editBtn = document.querySelector('#myModal #edit');
this.splitBtn = document.querySelector('#myModal #split');
this.deleteBtn.addEventListener('click', () => { this.deleteAssiduiteModal() });
this.editBtn.addEventListener('click', () => { this.editAssiduiteModal() });
this.splitBtn.addEventListener('click', () => { this.splitAssiduiteModal() });
document.querySelector("#myModal #finish").addEventListener('click', () => { this.close() })
document.querySelector('#myModal .close').addEventListener('click', () => { this.close() })
this.render()
}
close() {
if (this.element) {
this.element.remove()
}
}
/**
* Sélection d'une assiduité sur la timeline
* @param {Assiduité} assiduite l'assiduité sélectionnée
*/
selectAssiduite(assiduite) {
// Désélectionner l'assiduité précédemment sélectionnée
if (this.selectedAssiduite) {
const prevSelectedEl = document.querySelector(
`.assiduite[data-id="${this.selectedAssiduite.assiduite_id}"]`
);
if (prevSelectedEl) {
prevSelectedEl.classList.remove("selected");
}
}
// Sélectionner la nouvelle assiduité
this.selectedAssiduite = assiduite;
const selectedEl = document.querySelector(
`.assiduite[data-id="${assiduite.assiduite_id}"]`
);
if (selectedEl) {
selectedEl.classList.add("selected");
}
//Mise à jour de la partie information du modal
const selectedModal = document.querySelector(".modal-assiduite-content");
selectedModal.classList.add("show");
document.getElementById("modal-assiduite-id").textContent =
assiduite.assiduite_id;
document.getElementById(
"modal-assiduite-user"
).textContent = `saisi le ${formatDateModal(
assiduite.entry_date,
"à"
)} \npar ${assiduite.user_id}`;
document.getElementById("modal-assiduite-module").textContent =
assiduite.moduleimpl_id;
document.getElementById("modal-assiduite-deb").textContent = formatDateModal(
assiduite.date_debut
);
document.getElementById("modal-assiduite-fin").textContent = formatDateModal(
assiduite.date_fin
);
document.getElementById("modal-assiduite-etat").textContent =
assiduite.etat.capitalize();
//Activation des boutons d'actions de conflit
this.deleteBtn.disabled = false;
this.splitBtn.disabled = false;
this.editBtn.disabled = false;
}
/**
* Suppression de l'assiduité sélectionnée
*/
deleteAssiduiteModal() {
if (!this.selectedAssiduite) return;
deleteAssiduite(this.selectedAssiduite.assiduite_id);
this.callbacks.delete(this.selectedAssiduite)
this.refresh(assiduites[this.selectedAssiduite.etudid]);
// Désélection de l'assiduité
this.resetSelection();
}
/**
* Division d'une assiduité
*/
splitAssiduiteModal() {
//Préparation du prompt
const htmlPrompt = `<legend>Entrez l'heure de séparation (HH:mm) :</legend>
<input type="time" id="promptTime" name="appt"
min="08:00" max="18:00" required>`;
const fieldSet = document.createElement("fieldset");
fieldSet.classList.add("fieldsplit");
fieldSet.innerHTML = htmlPrompt;
//Callback de division
const success = () => {
const separatorTime = document.getElementById("promptTime").value;
const dateString =
document.querySelector("#tl_date").value + `T${separatorTime}`;
const separtorDate = new moment.tz(dateString, TIMEZONE);
const assiduite_debut = new moment.tz(this.selectedAssiduite.date_debut, TIMEZONE);
const assiduite_fin = new moment.tz(this.selectedAssiduite.date_fin, TIMEZONE);
if (
separtorDate.isAfter(assiduite_debut) &&
separtorDate.isBefore(assiduite_fin)
) {
const assiduite_avant = {
etat: this.selectedAssiduite.etat,
date_debut: assiduite_debut.format(),
date_fin: separtorDate.format(),
};
const assiduite_apres = {
etat: this.selectedAssiduite.etat,
date_debut: separtorDate.format(),
date_fin: assiduite_fin.format(),
};
if (this.selectedAssiduite.moduleimpl_id) {
assiduite_apres["moduleimpl_id"] = this.selectedAssiduite.moduleimpl_id;
assiduite_avant["moduleimpl_id"] = this.selectedAssiduite.moduleimpl_id;
}
deleteAssiduite(this.selectedAssiduite.assiduite_id);
const path = getUrl() + `/api/assiduite/${this.selectedAssiduite.etudid}/create`;
sync_post(
path,
[assiduite_avant, assiduite_apres],
(data, status) => {
//success
},
(data, status) => {
//error
console.error(data, status);
errorAlert();
}
);
this.callbacks.split(this.selectedAssiduite)
this.refresh(assiduites[this.selectedAssiduite.etudid]);
this.resetSelection();
} else {
const att = document.createTextNode(
"L'heure de séparation doit être compris dans la période de l'assiduité sélectionnée."
);
openAlertModal("Attention", att, "", "#ecb52a");
}
};
openPromptModal("Séparation de l'assiduité sélectionnée", fieldSet, success, () => { }, "#23aa40");
}
/**
* Modification d'une assiduité conflictuelle
*/
editAssiduiteModal() {
if (!this.selectedAssiduite) return;
//Préparation du modal d'édition
const htmlPrompt = `<legend>Entrez l'état de l'assiduité :</legend>
<select name="promptSelect" id="promptSelect" required>
<option value="">Choissez l'état</option>
<option value="present">Présent</option>
<option value="retard">En Retard</option>
<option value="absent">Absent</option>
</select>`;
const fieldSet = document.createElement("fieldset");
fieldSet.classList.add("fieldsplit");
fieldSet.innerHTML = htmlPrompt;
//Callback d'action d'édition
const success = () => {
const newState = document.getElementById("promptSelect").value;
if (!["present", "absent", "retard"].includes(newState.toLowerCase())) {
const att = document.createTextNode(
"L'état doit être 'present', 'absent' ou 'retard'."
);
openAlertModal("Attention", att, "", "#ecb52a");
return;
}
// Actualiser l'affichage
editAssiduite(this.selectedAssiduite.assiduite_id, newState);
this.callbacks.edit(this.selectedAssiduite)
this.refresh(assiduites[this.selectedAssiduite.etudid]);
// Désélection de l'assiduité
this.resetSelection();
};
//Affichage du prompt
openPromptModal("Modification de l'état de l'assiduité sélectionnée", fieldSet, success, () => { }, "#23aa40");
}
/**
* Génération du modal
*/
render() {
const timeLabels = document.querySelector(".time-labels");
const assiduitesContainer = document.querySelector(".assiduites-container");
timeLabels.innerHTML = "";
assiduitesContainer.innerHTML = '<div class="assiduite-special"></div>';
// Ajout des labels d'heure sur la frise chronologique
// TODO permettre la modification des bornes (8 et 18)
for (let i = 8; i <= 18; i++) {
const timeLabel = document.createElement("div");
timeLabel.className = "time-label";
timeLabel.textContent = i < 10 ? `0${i}:00` : `${i}:00`;
timeLabels.appendChild(timeLabel);
}
//Placement de la période conflictuelle sur la timeline
const specialAssiduiteEl = document.querySelector(".assiduite-special");
specialAssiduiteEl.style.width = getWidth(
this.conflictPeriod.deb,
this.conflictPeriod.fin
);
specialAssiduiteEl.style.left = getLeftPosition(this.conflictPeriod.deb);
specialAssiduiteEl.style.top = "0";
specialAssiduiteEl.style.zIndex = "0"; // Place l'assiduité spéciale en arrière-plan
assiduitesContainer.appendChild(specialAssiduiteEl);
//Placement des assiduités sur la timeline
this.list.forEach((assiduite) => {
const period = {
deb: new moment.tz(assiduite.date_debut, TIMEZONE),
fin: new moment.tz(assiduite.date_fin, TIMEZONE),
};
if (!hasTimeConflict(period, this.interval)) {
return;
}
const el = document.createElement("div");
el.className = "assiduite";
el.style.backgroundColor = getColor(assiduite.etat);
el.style.width = getWidth(assiduite.date_debut, assiduite.date_fin);
el.style.left = getLeftPosition(assiduite.date_debut);
el.style.top = "10px";
el.setAttribute("data-id", assiduite.assiduite_id);
el.addEventListener("click", () => this.selectAssiduite(assiduite));
// Ajout des informations dans la visualisation d'une assiduité
const infoContainer = document.createElement("div");
infoContainer.className = "assiduite-info";
const idDiv = document.createElement("div");
idDiv.className = "assiduite-id";
idDiv.textContent = `ID: ${assiduite.assiduite_id}`;
infoContainer.appendChild(idDiv);
const periodDivDeb = document.createElement("div");
periodDivDeb.className = "assiduite-period";
periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`;
infoContainer.appendChild(periodDivDeb);
const periodDivFin = document.createElement("div");
periodDivFin.className = "assiduite-period";
periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`;
infoContainer.appendChild(periodDivFin);
const stateDiv = document.createElement("div");
stateDiv.className = "assiduite-state";
stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`;
infoContainer.appendChild(stateDiv);
const userIdDiv = document.createElement("div");
userIdDiv.className = "assiduite-user_id";
userIdDiv.textContent = `saisi le ${formatDateModal(
assiduite.entry_date,
"à"
)} \npar ${assiduite.user_id}`;
infoContainer.appendChild(userIdDiv);
el.appendChild(infoContainer);
assiduitesContainer.appendChild(el);
});
}
/**
* Remise à zéro de la sélection
* Désactivation des boutons d'actions de conflit
*/
resetSelection() {
this.selectedAssiduite = null;
this.deleteBtn.disabled = true;
this.splitBtn.disabled = true;
this.editBtn.disabled = true;
}
}
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,316 @@
<div class="assiduite-bubble">
</div>
<script>
const mt_start = {{ t_start }};
const mt_end = {{ t_end }};
/**
* Création de la minitiline d'un étudiant
* @param {Array[Assiduité]} assiduitesArray
* @returns {HTMLElement} l'élément correspondant à la mini timeline
*/
function createMiniTimeline(assiduitesArray, day = null) {
const array = [...assiduitesArray];
const dateiso = day == null ? document.getElementById("tl_date").value : day;
const timeline = document.createElement("div");
timeline.className = "mini-timeline";
if (isSingleEtud()) {
timeline.classList.add("single");
}
const timelineDate = moment(dateiso).startOf("day");
const dayStart = timelineDate.clone().add(mt_start, "hours");
const dayEnd = timelineDate.clone().add(mt_end, "hours");
const dayDuration = moment.duration(dayEnd.diff(dayStart)).asMinutes();
timeline.appendChild(setMiniTick(timelineDate, dayStart, dayDuration));
if (day == null) {
const tlTimes = getTimeLineTimes();
array.push({
date_debut: tlTimes.deb.format(),
date_fin: tlTimes.fin.format(),
etat: "CRENEAU",
});
}
array.forEach((assiduité) => {
let startDate = moment(assiduité.date_debut);
let endDate = moment(assiduité.date_fin);
if (startDate.isBefore(dayStart)) {
startDate = dayEnd.clone().startOf("day").add(mt_start, "hours");
}
if (endDate.isAfter(dayEnd)) {
endDate = dayEnd.clone().startOf("day").add(mt_end, "hours");
}
const block = document.createElement("div");
block.className = "mini-timeline-block";
const duration = moment.duration(endDate.diff(startDate)).asMinutes();
const startOffset = moment.duration(startDate.diff(dayStart)).asMinutes();
const leftPercentage = (startOffset / dayDuration) * 100;
const widthPercentage = (duration / dayDuration) * 100;
block.style.left = `${leftPercentage}%`;
block.style.width = `${widthPercentage}%`;
if (assiduité.etat != "CRENEAU") {
block.addEventListener("click", () => {
let deb = startDate.hours() + startDate.minutes() / 60;
let fin = endDate.hours() + endDate.minutes() / 60;
deb = Math.max(mt_start, deb);
fin = Math.min(mt_end, fin);
if (day == null) setPeriodValues(deb, fin);
if (isSingleEtud()) {
updateSelectedSelect(getCurrentAssiduiteModuleImplId());
updateJustifyBtn();
}
});
//ajouter affichage assiduites on over
setupAssiduiteBuble(block, assiduité);
}
const action = (justificatifs) => {
if (justificatifs.length > 0) {
let j = "invalid_justified";
justificatifs.forEach((ju) => {
if (ju.etat == "VALIDE") {
j = "justified";
}
});
block.classList.add(j);
}
};
if (assiduité.etudid) {
getJustificatifFromPeriod(
{
deb: new moment.tz(assiduité.date_debut, TIMEZONE),
fin: new moment.tz(assiduité.date_fin, TIMEZONE),
},
assiduité.etudid,
action
);
}
switch (assiduité.etat) {
case "PRESENT":
block.classList.add("present");
break;
case "RETARD":
block.classList.add("retard");
break;
case "ABSENT":
block.classList.add("absent");
break;
case "CRENEAU":
block.classList.add("creneau");
break;
default:
block.style.backgroundColor = "white";
}
timeline.appendChild(block);
});
return timeline;
}
/**
* Ajout de la visualisation des assiduités de la mini timeline
* @param {HTMLElement} el l'élément survollé
* @param {Assiduité} assiduite l'assiduité représentée par l'élément
*/
function setupAssiduiteBuble(el, assiduite) {
if (!assiduite) return;
el.addEventListener("mouseenter", (event) => {
const bubble = document.querySelector(".assiduite-bubble");
bubble.className = "assiduite-bubble";
bubble.classList.add("is-active", assiduite.etat.toLowerCase());
bubble.innerHTML = "";
const idDiv = document.createElement("div");
idDiv.className = "assiduite-id";
idDiv.textContent = `${getModuleImpl(assiduite)}`;
bubble.appendChild(idDiv);
const periodDivDeb = document.createElement("div");
periodDivDeb.className = "assiduite-period";
periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`;
bubble.appendChild(periodDivDeb);
const periodDivFin = document.createElement("div");
periodDivFin.className = "assiduite-period";
periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`;
bubble.appendChild(periodDivFin);
const stateDiv = document.createElement("div");
stateDiv.className = "assiduite-state";
stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`;
bubble.appendChild(stateDiv);
const userIdDiv = document.createElement("div");
userIdDiv.className = "assiduite-user_id";
userIdDiv.textContent = `saisi le ${formatDateModal(
assiduite.entry_date,
"à"
)}`;
if (assiduite.user_id != null) {
userIdDiv.textContent += `\npar ${assiduite.user_id}`
}
bubble.appendChild(userIdDiv);
bubble.style.left = `${event.clientX - bubble.offsetWidth / 2}px`;
bubble.style.top = `${event.clientY + 20}px`;
});
el.addEventListener("mouseout", () => {
const bubble = document.querySelector(".assiduite-bubble");
bubble.classList.remove("is-active");
});
}
function setMiniTick(timelineDate, dayStart, dayDuration) {
const endDate = timelineDate.clone().set({ 'hour': 13, 'minute': 0 });
const duration = moment.duration(endDate.diff(dayStart)).asMinutes();
const widthPercentage = (duration / dayDuration) * 100;
const tick = document.createElement('span');
tick.className = "mini_tick"
tick.textContent = "13h"
tick.style.left = `${widthPercentage}%`
return tick
}
</script>
<style>
.assiduite-bubble {
position: fixed;
display: none;
background-color: #f9f9f9;
border-radius: 5px;
padding: 8px;
border: 3px solid #ccc;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
font-size: 12px;
line-height: 1.4;
z-index: 500;
}
.assiduite-bubble.is-active {
display: block;
}
.assiduite-bubble::before {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent transparent #f9f9f9 transparent;
}
.assiduite-bubble::after {
content: "";
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-width: 5px;
border-style: solid;
border-color: transparent transparent #ccc transparent;
}
.assiduite-id,
.assiduite-period,
.assiduite-state,
.assiduite-user_id {
margin-bottom: 4px;
}
.assiduite-bubble.absent {
border-color: #F1A69C !important;
}
.assiduite-bubble.present {
border-color: #9CF1AF !important;
}
.assiduite-bubble.retard {
border-color: #F1D99C !important;
}
.mini-timeline {
height: 7px;
border: 1px solid black;
position: relative;
background-color: white;
}
.mini-timeline.single {
height: 9px;
}
.mini-timeline-block {
position: absolute;
height: 100%;
z-index: 1;
}
#page-assiduite-content .mini-timeline-block {
cursor: pointer;
}
.mini_tick {
position: absolute;
text-align: start;
top: -40px;
transform: translateX(-50%);
z-index: 50;
}
.mini_tick::after {
display: block;
content: "|";
position: absolute;
bottom: -2px;
z-index: 2;
}
.mini-timeline-block.creneau {
outline: 3px solid #7059FF;
pointer-events: none;
}
.mini-timeline-block.absent {
background-color: #F1A69C !important;
}
.mini-timeline-block.present {
background-color: #9CF1AF !important;
}
.mini-timeline-block.retard {
background-color: #F1D99C !important;
}
.mini-timeline-block.justified {
background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #7059FF 4px, #7059FF 8px);
}
.mini-timeline-block.invalid_justified {
background-image: repeating-linear-gradient(135deg, transparent, transparent 4px, #d61616 4px, #d61616 8px);
}
</style>

View File

@ -0,0 +1,135 @@
<label for="moduleimpl_select">
Module
<select id="moduleimpl_select" class="dynaSelect">
<option value="" selected> Non spécifié </option>
<option value="autre"> Autre </option>
</select>
</label>
<script>
function getEtudFormSemestres() {
let semestre = {};
sync_get(getUrl() + `/api/etudiant/etudid/${etudid}/formsemestres`, (data) => {
semestre = data;
});
return semestre;
}
function filterFormSemestres(semestres, dateIso) {
const date = new moment.tz(
dateIso,
TIMEZONE
);
semestres = semestres.filter((fm) => {
return date.isBetween(fm.date_debut_iso, fm.date_fin_iso, null, '[]')
})
return semestres;
}
function getFormSemestreProgramme(fm_id) {
let semestre = {};
sync_get(getUrl() + `/api/formsemestre/${fm_id}/programme`, (data) => {
semestre = data;
});
return semestre;
}
function getModulesImplByFormsemestre(semestres) {
const map = new Map();
semestres.forEach((fm) => {
const array = [];
const fm_p = getFormSemestreProgramme(fm.formsemestre_id);
["ressources", "saes", "modules"].forEach((r) => {
if (r in fm_p) {
fm_p[r].forEach((o) => {
array.push(getModuleInfos(o))
})
}
})
map.set(fm.titre_num, array)
})
return map;
}
function getModuleInfos(obj) {
return {
moduleimpl_id: obj.moduleimpl_id,
titre: obj.module.titre,
code: obj.module.code,
}
}
function populateSelect(sems, selected, query) {
const select = document.querySelector(query);
select.innerHTML = `<option value=""> Non spécifié </option><option value="autre"> Autre </option>`
sems.forEach((mods, label) => {
const optGrp = document.createElement('optgroup');
optGrp.label = label
mods.forEach((obj) => {
const opt = document.createElement('option');
opt.value = obj.moduleimpl_id;
opt.textContent = `${obj.code} ${obj.titre}`
if (obj.moduleimpl_id == selected) {
opt.setAttribute('selected', 'true');
}
optGrp.appendChild(opt);
})
select.appendChild(optGrp);
})
if (selected === "autre") {
select.querySelector('option[value="autre"]').setAttribute('selected', 'true');
}
}
function updateSelect(moduleimpl_id, query = "#moduleimpl_select", dateIso = null) {
let sem = getEtudFormSemestres()
if (dateIso == null) {
dateIso = document.querySelector("#tl_date").value
}
sem = filterFormSemestres(sem, dateIso)
const mod = getModulesImplByFormsemestre(sem)
populateSelect(mod, moduleimpl_id, query);
}
function updateSelectedSelect(moduleimpl_id, query = "#moduleimpl_select") {
const mod_id = moduleimpl_id != null ? moduleimpl_id : ""
document.querySelector(query).value = mod_id;
}
window.addEventListener("load", () => {
document.getElementById('moduleimpl_select').addEventListener('change', (el) => {
const assi = getCurrentAssiduite(etudid);
if (assi) {
editAssiduite(assi.assiduite_id, assi.etat);
}
})
const conflicts = getAssiduitesConflict(etudid);
if (conflicts.length > 0) {
updateSelectedSelect(conflicts[0].moduleimpl_id);
}
}, { once: true });
</script>
<style>
#moduleimpl_select {
width: 125px;
text-overflow: ellipsis;
}
</style>

View File

@ -0,0 +1,15 @@
<select name="moduleimpl_select" id="moduleimpl_select">
<option value="" {{selected}}> Non spécifié </option>
<option value="autre"> Autre </option>
{% for mod in modules %}
{% if mod.moduleimpl_id == moduleimpl_id %}
<option value="{{mod.moduleimpl_id}}" selected> {{mod.name}} </option>
{% else %}
<option value="{{mod.moduleimpl_id}}"> {{mod.name}} </option>
{% endif %}
{% endfor %}
</select>

View File

@ -0,0 +1,217 @@
{% block promptModal %}
<div id="promptModal" class="promptModal">
<!-- promptModal content -->
<div class="promptModal-content">
<div class="promptModal-header">
<span class="promptModal-close">&times;</span>
<h2 class="promptModal-title">promptModal Header</h2>
</div>
<div class="promptModal-body">
<p>Some text in the promptModal Body</p>
<p>Some other text...</p>
</div>
<div class="promptModal-footer">
<h3>promptModal Footer</h3>
</div>
</div>
</div>
<style>
/* The promptModal (background) */
.promptModal {
display: none;
/* Hidden by default */
position: fixed;
/* Stay in place */
z-index: 750;
/* Sit on top */
padding-top: 3vh;
/* Location of the box */
left: 0;
top: 0;
width: 100%;
/* Full width */
height: 100%;
/* Full height */
overflow: auto;
/* Enable scroll if needed */
background-color: rgb(0, 0, 0);
/* Fallback color */
background-color: rgba(0, 0, 0, 0.4);
/* Black w/ opacity */
}
/* promptModal Content */
.promptModal-content {
border-radius: 8px;
overflow: hidden;
position: relative;
background-color: #fefefe;
margin: auto;
padding: 0;
border: 1px solid #888;
width: 45%;
box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
-webkit-animation-name: animatetop;
-webkit-animation-duration: 0.4s;
animation-name: animatetop;
animation-duration: 0.4s
}
/* Add Animation */
@-webkit-keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
@keyframes animatetop {
from {
top: -300px;
opacity: 0
}
to {
top: 0;
opacity: 1
}
}
/* The Close Button */
.promptModal-close {
color: white;
position: absolute;
right: 10px;
font-size: 28px;
font-weight: bold;
}
.promptModal-close:hover,
.promptModal-close:focus {
color: #000;
text-decoration: none;
cursor: pointer;
}
.promptModal-header {
padding: 2px 16px;
color: white;
display: flex;
justify-content: center;
align-items: center;
}
.promptModal-body {
padding: 2px 16px;
}
.promptModal-footer {
padding: 2px 16px;
color: white;
display: flex;
justify-content: space-evenly;
align-items: center;
}
.promptModal.is-active {
display: block;
}
.btnPrompt {
display: inline-block;
padding: 6px 12px;
font-size: 14px;
font-weight: 500;
text-align: center;
text-decoration: none;
color: #ffffff;
background-color: #6c757d;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.2);
}
.btnPrompt:hover {
background-color: #5a6268;
}
.btnPrompt:active {
transform: translateY(1px);
}
.btnPrompt:disabled {
color: black;
opacity: 0.8;
background-color: whitesmoke;
background-image: repeating-linear-gradient(135deg, transparent, transparent 5px, rgba(81, 81, 81, 0.337) 5px, rgba(81, 81, 81, 0.337) 10px)
}
</style>
<script>
const promptModal = document.getElementById("promptModal");
function openPromptModal(titre, contenu, success, cancel = () => { }, color = "crimson") {
promptModal.classList.add('is-active');
promptModal.querySelector('.promptModal-title').textContent = titre;
promptModal.querySelector('.promptModal-body').innerHTML = ""
promptModal.querySelector('.promptModal-body').appendChild(contenu);
promptModal.querySelector('.promptModal-footer').innerHTML = ""
promptModalButtonAction(success, cancel).forEach((btnPrompt) => {
promptModal.querySelector('.promptModal-footer').appendChild(btnPrompt)
})
const banners = Array.from(promptModal.querySelectorAll('.promptModal-footer,.promptModal-header'))
banners.forEach((ban) => {
ban.style.backgroundColor = color;
})
promptModal.addEventListener('click', (e) => {
if (e.target.id == promptModal.id) {
promptModal.classList.remove('is-active');
promptModal.removeEventListener('click', this)
}
})
}
function promptModalButtonAction(success, cancel) {
const succBtn = document.createElement('button')
succBtn.classList.add("btnPrompt")
succBtn.textContent = "Valider"
succBtn.addEventListener('click', () => {
const retour = success();
if (retour == null || retour == false || retour == undefined) {
closePromptModal();
}
})
const cancelBtn = document.createElement('button')
cancelBtn.classList.add("btnPrompt")
cancelBtn.textContent = "Annuler"
cancelBtn.addEventListener('click', () => {
cancel();
closePromptModal();
})
return [succBtn, cancelBtn]
}
function closePromptModal() {
promptModal.classList.remove("is-active")
}
const promptClose = document.querySelector(".promptModal-close");
promptClose.onclick = function () {
closePromptModal()
}
</script>
{% endblock promptModal %}

View File

@ -0,0 +1,261 @@
<table id="assiduiteTable">
<thead>
<tr>
<th>
<div>
<span>Début</span>
<a class="icon order" onclick="order('date_debut', assiduiteCallBack, this)"></a>
</div>
</th>
<th>
<div>
<span>Fin</span>
<a class="icon order" onclick="order('date_fin', assiduiteCallBack, this)"></a>
</div>
</th>
<th>
<div>
<span>État</span>
<a class="icon order" onclick="order('etat', assiduiteCallBack, this)"></a>
</div>
</th>
<th>
<div>
<span>Module</span>
<a class="icon order" onclick="order('moduleimpl_id', assiduiteCallBack, this)"></a>
</div>
</th>
<th>
<div>
<span>Justifiée</span>
<a class="icon order" onclick="order('est_just', assiduiteCallBack, this)"></a>
</div>
</th>
</tr>
</thead>
<tbody id="tableBodyAssiduites">
</tbody>
</table>
<div id="paginationContainerAssiduites" class="pagination-container">
</div>
<div style="display: none;" id="cache-module">
{% include "assiduites/widgets/moduleimpl_dynamic_selector.j2" %}
</div>
<script>
const paginationContainerAssiduites = document.getElementById("paginationContainerAssiduites");
let currentPageAssiduites = 1;
let orderAssiduites = true;
let filterAssiduites = {
columns: [
"entry_date", "date_debut", "date_fin", "etat", "moduleimpl_id", "est_just"
],
filters: {}
}
const tableBodyAssiduites = document.getElementById("tableBodyAssiduites");
function assiduiteCallBack(assi) {
assi = filterArray(assi, filterAssiduites.filters)
renderTableAssiduites(currentPageAssiduites, assi);
renderPaginationButtons(assi);
try { stats() } catch (_) { }
}
function renderTableAssiduites(page, assiduités) {
generateTableHead(filterAssiduites.columns, true)
tableBodyAssiduites.innerHTML = "";
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
assiduités.slice(start, end).forEach((assiduite) => {
const row = document.createElement("tr");
row.setAttribute('type', "assiduite");
row.setAttribute('obj_id', assiduite.assiduite_id);
const etat = assiduite.etat.toLowerCase();
row.classList.add(`l-${etat}`);
filterAssiduites.columns.forEach((k) => {
const td = document.createElement('td');
if (k.indexOf('date') != -1) {
td.textContent = moment.tz(assiduite[k], TIMEZONE).format(`DD/MM/Y HH:mm`)
} else if (k.indexOf("module") != -1) {
td.textContent = getModuleImpl(assiduite);
} else if (k.indexOf('est_just') != -1) {
td.textContent = assiduite[k] ? "Oui" : "Non"
} else {
td.textContent = assiduite[k].capitalize()
}
row.appendChild(td)
})
row.addEventListener("contextmenu", openContext);
tableBodyAssiduites.appendChild(row);
});
updateActivePaginationButton();
}
function detailAssiduites(assiduite_id) {
const path = getUrl() + `/api/assiduite/${assiduite_id}`;
async_get(
path,
(data) => {
const user = getUser(data);
const module = getModuleImpl(data);
const date_debut = moment.tz(data.date_debut, TIMEZONE).format("DD/MM/YYYY HH:mm");
const date_fin = moment.tz(data.date_fin, TIMEZONE).format("DD/MM/YYYY HH:mm");
const entry_date = moment.tz(data.entry_date, TIMEZONE).format("DD/MM/YYYY HH:mm");
const etat = data.etat.capitalize();
const desc = data.desc == null ? "" : data.desc;
const id = data.assiduite_id;
const est_just = data.est_just ? "Oui" : "Non";
const html = `
<div class="obj-detail">
<div class="obj-dates">
<div id="date_debut" class="obj-part">
<span class="obj-title">Date de début</span>
<span class="obj-content">${date_debut}</span>
</div>
<div id="date_fin" class="obj-part">
<span class="obj-title">Date de fin</span>
<span class="obj-content">${date_fin}</span>
</div>
<div id="entry_date" class="obj-part">
<span class="obj-title">Date de saisie</span>
<span class="obj-content">${entry_date}</span>
</div>
</div>
<div class="obj-mod">
<div id="module" class="obj-part">
<span class="obj-title">Module</span>
<span class="obj-content">${module}</span>
</div>
<div id="etat" class="obj-part">
<span class="obj-title">Etat</span>
<span class="obj-content">${etat}</span>
</div>
<div id="user" class="obj-part">
<span class="obj-title">Créer par</span>
<span class="obj-content">${user}</span>
</div>
</div>
<div class="obj-rest">
<div id="est_just" class="obj-part">
<span class="obj-title">Justifié</span>
<span class="obj-content">${est_just}</span>
</div>
<div id="desc" class="obj-part">
<span class="obj-title">Description</span>
<p class="obj-content">${desc}</p>
</div>
<div id="id" class="obj-part">
<span class="obj-title">Identifiant de l'assiduité</span>
<span class="obj-content">${id}</span>
</div>
</div>
</div>
`
const el = document.createElement('div');
el.innerHTML = html;
openAlertModal("Détails", el.firstElementChild, null, "green")
}
)
}
function editionAssiduites(assiduite_id) {
const path = getUrl() + `/api/assiduite/${assiduite_id}`;
async_get(
path,
(data) => {
let module = data.moduleimpl_id;
if (module == null && "external_data" in data && "module" in data.external_data) {
module = data.external_data.module.toLowerCase();
}
const etat = data.etat;
let desc = data.desc == null ? "" : data.desc;
const html = `
<div class="assi-edit">
<div class="assi-edit-part">
<legend>État de l'assiduité</legend>
<select name="etat" id="etat">
<option value="present">Présent</option>
<option value="retard">En Retard</option>
<option value="absent">Absent</option>
</select>
</div>
<div class="assi-edit-part">
<legend>Module</legend>
<select name="module" id="module">
</select>
</div>
<div class="assi-edit-part">
<legend>Description</legend>
<textarea name="desc" id="desc" cols="50" rows="10" maxlength="500"></textarea>
</div>
</div>
`
const el = document.createElement('div')
el.innerHTML = html;
const assiEdit = el.firstElementChild;
assiEdit.querySelector('#etat').value = etat.toLowerCase();
assiEdit.querySelector('#desc').value = desc != null ? desc : "";
updateSelect(module, '#moduleimpl_select', "2022-09-04")
assiEdit.querySelector('#module').replaceWith(document.querySelector('#moduleimpl_select').cloneNode(true));
openPromptModal("Modification de l'assiduité", assiEdit, () => {
const prompt = document.querySelector('.assi-edit');
const etat = prompt.querySelector('#etat').value;
const desc = prompt.querySelector('#desc').value;
let module = prompt.querySelector('#moduleimpl_select').value;
let edit = {
"etat": etat,
"desc": desc,
"external_data": data.external_data
}
edit = setModuleImplId(edit, module);
fullEditAssiduites(data.assiduite_id, edit, () => {
try { getAllAssiduitesFromEtud(etudid, assiduiteCallBack) } catch (_) { }
})
}, () => { }, "green");
}
);
}
function fullEditAssiduites(assiduite_id, obj, call = () => { }) {
const path = getUrl() + `/api/assiduite/${assiduite_id}/edit`;
async_post(
path,
obj,
call,
(data, status) => {
//error
console.error(data, status);
errorAlert();
}
);
}
</script>

View File

@ -0,0 +1,824 @@
<ul id="contextMenu" class="context-menu">
<li id="detailOption">Détails</li>
<li id="editOption">Éditer</li>
<li id="deleteOption">Supprimer</li>
</ul>
{% include "assiduites/widgets/alert.j2" %}
{% include "assiduites/widgets/prompt.j2" %}
<script>
const itemsPerPage = 10;
const contextMenu = document.getElementById("contextMenu");
const editOption = document.getElementById("editOption");
const detailOption = document.getElementById("detailOption");
const deleteOption = document.getElementById("deleteOption");
let selectedRow;
document.addEventListener("click", () => {
contextMenu.style.display = "none";
});
editOption.addEventListener("click", () => {
if (selectedRow) {
const type = selectedRow.getAttribute('type');
const obj_id = selectedRow.getAttribute('obj_id');
if (type == "assiduite") {
editionAssiduites(obj_id);
} else {
editionJustificatifs(obj_id);
}
}
});
detailOption.addEventListener("click", () => {
if (selectedRow) {
const type = selectedRow.getAttribute('type');
const obj_id = selectedRow.getAttribute('obj_id');
if (type == "assiduite") {
detailAssiduites(obj_id);
} else {
detailJustificatifs(obj_id);
}
}
});
deleteOption.addEventListener("click", () => {
if (selectedRow) {
const type = selectedRow.getAttribute('type');
const obj_id = selectedRow.getAttribute('obj_id');
if (type == "assiduite") {
deleteAssiduite(obj_id);
} else {
deleteJustificatif(obj_id);
}
loadAll();
}
});
function filterArray(array, f) {
return array.filter((el) => {
let t = Object.keys(f).every((k) => {
if (k == "etat") {
return f.etat.includes(el.etat.toLowerCase())
};
if (k == "est_just") {
if (f.est_just != "") {
return `${el.est_just}` == f.est_just;
}
}
if (k.indexOf('date') != -1) {
const assi_time = moment.tz(el[k], TIMEZONE);
const filter_time = f[k].time;
switch (f[k].pref) {
case "0":
return assi_time.isSame(filter_time, 'minute');
case "-1":
return assi_time.isBefore(filter_time, 'minutes');
case "1":
return assi_time.isAfter(filter_time, 'minutes');
}
}
if (k == "moduleimpl_id") {
const m = el[k] == undefined || el[k] == null ? "null" : el[k];
if (f.moduleimpl_id != '') {
return m == f.moduleimpl_id;
}
}
return true;
})
return t;
})
}
function generateTableHead(columns, assi = true) {
const table = assi ? "#assiduiteTable" : "#justificatifTable"
const call = assi ? [assiduiteCallBack, true] : [justificatifCallBack, false]
const tr = document.querySelector(`${table} thead tr`);
tr.innerHTML = ""
columns.forEach((c) => {
const th = document.createElement('th');
const div = document.createElement('div');
const span = document.createElement('span');
span.textContent = columnTranslator(c);
const a = document.createElement('a');
a.classList.add('icon', "order");
a.onclick = () => { order(c, call[0], a, call[1]) }
div.appendChild(span)
div.appendChild(a)
th.appendChild(div);
tr.appendChild(th);
})
}
function renderPaginationButtons(array, assi = true) {
const totalPages = Math.ceil(array.length / itemsPerPage);
if (totalPages <= 1) {
if (assi) {
paginationContainerAssiduites.innerHTML = ""
} else {
paginationContainerJustificatifs.innerHTML = ""
}
return;
}
if (assi) {
paginationContainerAssiduites.innerHTML = "<span class='liste_pagination'><button class='pagination_moins'>&lt;</button><select id='paginationAssi'></select><button class='pagination_plus'>&gt;</button></span>"
paginationContainerAssiduites.querySelector('#paginationAssi')?.addEventListener('change', (e) => {
currentPageAssiduites = e.target.value;
assiduiteCallBack(array);
})
paginationContainerAssiduites.querySelector('.pagination_moins').addEventListener('click', () => {
if (currentPageAssiduites > 1) {
currentPageAssiduites--;
paginationContainerAssiduites.querySelector('#paginationAssi').value = currentPageAssiduites
assiduiteCallBack(array);
}
})
paginationContainerAssiduites.querySelector('.pagination_plus').addEventListener('click', () => {
if (currentPageAssiduites < totalPages) {
currentPageAssiduites++;
paginationContainerAssiduites.querySelector('#paginationAssi').value = currentPageAssiduites
assiduiteCallBack(array);
}
})
} else {
paginationContainerJustificatifs.innerHTML = "<span class='liste_pagination'><button class='pagination_moins'>&lt;</button><select id='paginationJusti'></select><button class='pagination_plus'>&gt;</button></span>"
paginationContainerJustificatifs.querySelector('#paginationJusti')?.addEventListener('change', (e) => {
currentPageJustificatifs = e.target.value;
justificatifCallBack(array);
})
paginationContainerJustificatifs.querySelector('.pagination_moins').addEventListener('click', () => {
if (currentPageJustificatifs > 1) {
currentPageJustificatifs--;
paginationContainerJustificatifs.querySelector('#paginationJusti').value = currentPageAssiduites
justificatifCallBack(array);
}
})
paginationContainerJustificatifs.querySelector('.pagination_plus').addEventListener('click', () => {
if (currentPageJustificatifs < totalPages) {
currentPageJustificatifs++;
paginationContainerJustificatifs.querySelector('#paginationJusti').value = currentPageAssiduites
justificatifCallBack(array);
}
})
}
for (let i = 1; i <= totalPages; i++) {
const paginationButton = document.createElement("option");
paginationButton.textContent = i;
paginationButton.value = i;
if (assi) {
paginationContainerAssiduites.querySelector('#paginationAssi').appendChild(paginationButton)
} else {
paginationContainerJustificatifs.querySelector('#paginationJusti').appendChild(paginationButton)
}
}
updateActivePaginationButton(assi);
}
function updateActivePaginationButton(assi = true) {
if (assi) {
const paginationButtons =
paginationContainerAssiduites.querySelectorAll("#paginationContainerAssiduites .pagination-button");
paginationButtons.forEach((button) => {
if (parseInt(button.textContent) === currentPageAssiduites) {
button.classList.add("active");
} else {
button.classList.remove("active");
}
});
} else {
const paginationButtons =
paginationContainerJustificatifs.querySelectorAll("#paginationContainerJustificatifs .pagination-button");
paginationButtons.forEach((button) => {
if (parseInt(button.textContent) === currentPageJustificatifs) {
button.classList.add("active");
} else {
button.classList.remove("active");
}
});
}
}
function loadAll() {
try { getAllAssiduitesFromEtud(etudid, assiduiteCallBack) } catch (_) { }
try { getAllJustificatifsFromEtud(etudid, justificatifCallBack) } catch (_) { }
}
function order(keyword, callback = () => { }, el, assi = true) {
const call = (array, ordered) => {
const sorted = array.sort((a, b) => {
let keyValueA = a[keyword];
let keyValueB = b[keyword];
if (keyword.indexOf("date") != -1) {
keyValueA = moment.tz(keyValueA, TIMEZONE)
keyValueB = moment.tz(keyValueB, TIMEZONE)
}
if (keyword.indexOf("module") != -1) {
keyValueA = getModuleImpl(a);
keyValueB = getModuleImpl(b);
}
let orderDertermined = keyValueA > keyValueB;
if (!ordered) {
orderDertermined = keyValueA < keyValueB;
}
return orderDertermined
});
callback(sorted);
};
if (assi) {
orderAssiduites = !orderAssiduites;
getAllAssiduitesFromEtud(etudid, (a) => { call(a, orderAssiduites) })
} else {
orderJustificatifs = !orderJustificatifs;
getAllJustificatifsFromEtud(etudid, (a) => { call(a, orderJustificatifs) })
}
}
function filter(assi = true) {
if (assi) {
let html = `
<div class="filter-body">
<h3>Affichage des colonnes:</h3>
<div class="filter-head">
<label>
Date de saisie
<input class="chk" type="checkbox" name="entry_date" id="entry_date">
</label>
<label>
Date de Début
<input class="chk" type="checkbox" name="date_debut" id="date_debut" checked>
</label>
<label>
Date de Fin
<input class="chk" type="checkbox" name="date_fin" id="date_fin" checked>
</label>
<label>
Etat
<input class="chk" type="checkbox" name="etat" id="etat" checked>
</label>
<label>
Module
<input class="chk" type="checkbox" name="moduleimpl_id" id="moduleimpl_id" checked>
</label>
<label>
Justifiée
<input class="chk" type="checkbox" name="est_just" id="est_just" checked>
</label>
</div>
<hr>
<h3>Filtrage des colonnes:</h3>
<span class="filter-line">
<span class="filter-title" for="entry_date">Date de saisie</span>
<select name="entry_date_pref" id="entry_date_pref">
<option value="-1">Avant</option>
<option value="0">Égal</option>
<option value="1">Après</option>
</select>
<input type="datetime-local" name="entry_date_time" id="entry_date_time">
</span>
<span class="filter-line">
<span class="filter-title" for="date_debut">Date de début</span>
<select name="date_debut_pref" id="date_debut_pref">
<option value="-1">Avant</option>
<option value="0">Égal</option>
<option value="1">Après</option>
</select>
<input type="datetime-local" name="date_debut_time" id="date_debut_time">
</span>
<span class="filter-line">
<span class="filter-title" for="date_fin">Date de fin</span>
<select name="date_fin_pref" id="date_fin_pref">
<option value="-1">Avant</option>
<option value="0">Égal</option>
<option value="1">Après</option>
</select>
<input type="datetime-local" name="date_fin_time" id="date_fin_time">
</span>
<span class="filter-line">
<span class="filter-title" for="etat">Etat</span>
<input checked type="checkbox" name="etat_present" id="etat_present" class="rbtn present" value="present">
<input checked type="checkbox" name="etat_retard" id="etat_retard" class="rbtn retard" value="retard">
<input checked type="checkbox" name="etat_absent" id="etat_absent" class="rbtn absent" value="absent">
</span>
<span class="filter-line">
<span class="filter-title" for="moduleimpl_id">Module</span>
<select id="moduleimpl_id">
<option value="">Pas de filtre</option>
</select>
</span>
<span class="filter-line">
<span class="filter-title" for="est_just">Est Justifiée</span>
<select id="est_just">
<option value="">Pas de filtre</option>
<option value="true">Oui</option>
<option value="false">Non</option>
</select>
</span>
</div>
`;
const span = document.createElement('span');
span.innerHTML = html
html = span.firstElementChild
const filterHead = html.querySelector('.filter-head');
filterHead.innerHTML = ""
let cols = ["entry_date", "date_debut", "date_fin", "etat", "moduleimpl_id", "est_just"];
cols.forEach((k) => {
const label = document.createElement('label')
label.classList.add('f-label')
const s = document.createElement('span');
s.textContent = columnTranslator(k);
const input = document.createElement('input');
input.classList.add('chk')
input.type = "checkbox"
input.name = k
input.id = k;
input.checked = filterAssiduites.columns.includes(k)
label.appendChild(s)
label.appendChild(input)
filterHead.appendChild(label)
})
const sl = html.querySelector('.filter-line #moduleimpl_id');
let opts = []
Object.keys(moduleimpls).forEach((k) => {
const opt = document.createElement('option');
opt.value = k == null ? "null" : k;
opt.textContent = moduleimpls[k];
opts.push(opt);
})
opts = opts.sort((a, b) => {
return a.value < b.value
})
sl.append(...opts);
// Mise à jour des filtres
Object.keys(filterAssiduites.filters).forEach((key) => {
const l = html.querySelector(`.filter-title[for="${key}"]`).parentElement;
if (key.indexOf('date') != -1) {
l.querySelector(`#${key}_pref`).value = filterAssiduites.filters[key].pref;
l.querySelector(`#${key}_time`).value = filterAssiduites.filters[key].time.format("YYYY-MM-DDTHH:mm");
} else if (key.indexOf('etat') != -1) {
l.querySelectorAll('input').forEach((e) => {
e.checked = filterAssiduites.filters[key].includes(e.value)
})
} else if (key.indexOf("module") != -1) {
l.querySelector('#moduleimpl_id').value = filterAssiduites.filters[key];
} else if (key.indexOf("est_just") != -1) {
l.querySelector('#est_just').value = filterAssiduites.filters[key];
}
})
openPromptModal("Filtrage des assiduités", html, () => {
const columns = [...document.querySelectorAll('.chk')]
.map((el) => { if (el.checked) return el.id })
.filter((el) => el)
filterAssiduites.columns = columns
filterAssiduites.filters = {}
//reste des filtres
const lines = [...document.querySelectorAll('.filter-line')];
lines.forEach((l) => {
const key = l.querySelector('.filter-title').getAttribute('for');
if (key.indexOf('date') != -1) {
const pref = l.querySelector(`#${key}_pref`).value;
const time = l.querySelector(`#${key}_time`).value;
if (l.querySelector(`#${key}_time`).value != "") {
filterAssiduites.filters[key] = {
pref: pref,
time: new moment.tz(time, TIMEZONE)
}
}
} else if (key.indexOf('etat') != -1) {
filterAssiduites.filters[key] = [...l.querySelectorAll("input:checked")].map((e) => e.value);
} else if (key.indexOf("module") != -1) {
filterAssiduites.filters[key] = l.querySelector('#moduleimpl_id').value;
} else if (key.indexOf("est_just") != -1) {
filterAssiduites.filters[key] = l.querySelector('#est_just').value;
}
})
getAllAssiduitesFromEtud(etudid, assiduiteCallBack)
}, () => { }, "#7059FF");
} else {
let html = `
<div class="filter-body">
<h3>Affichage des colonnes:</h3>
<div class="filter-head">
<label>
Date de saisie
<input class="chk" type="checkbox" name="entry_date" id="entry_date">
</label>
<label>
Date de Début
<input class="chk" type="checkbox" name="date_debut" id="date_debut" checked>
</label>
<label>
Date de Fin
<input class="chk" type="checkbox" name="date_fin" id="date_fin" checked>
</label>
<label>
Etat
<input class="chk" type="checkbox" name="etat" id="etat" checked>
</label>
<label>
Raison
<input class="chk" type="checkbox" name="raison" id="raison" checked>
</label>
<label>
Fichier
<input class="chk" type="checkbox" name="fichier" id="fichier" checked>
</label>
</div>
<hr>
<h3>Filtrage des colonnes:</h3>
<span class="filter-line">
<span class="filter-title" for="entry_date">Date de saisie</span>
<select name="entry_date_pref" id="entry_date_pref">
<option value="-1">Avant</option>
<option value="0">Égal</option>
<option value="1">Après</option>
</select>
<input type="datetime-local" name="entry_date_time" id="entry_date_time">
</span>
<span class="filter-line">
<span class="filter-title" for="date_debut">Date de début</span>
<select name="date_debut_pref" id="date_debut_pref">
<option value="-1">Avant</option>
<option value="0">Égal</option>
<option value="1">Après</option>
</select>
<input type="datetime-local" name="date_debut_time" id="date_debut_time">
</span>
<span class="filter-line">
<span class="filter-title" for="date_fin">Date de fin</span>
<select name="date_fin_pref" id="date_fin_pref">
<option value="-1">Avant</option>
<option value="0">Égal</option>
<option value="1">Après</option>
</select>
<input type="datetime-local" name="date_fin_time" id="date_fin_time">
</span>
<span class="filter-line">
<span class="filter-title" for="etat">Etat</span>
<label>
Valide
<input checked type="checkbox" name="etat_valide" id="etat_valide" class="" value="valide">
</label>
<label>
Non Valide
<input checked type="checkbox" name="etat_valide" id="etat_valide" class="" value="non_valide">
</label>
<label>
En Attente
<input checked type="checkbox" name="etat_valide" id="etat_valide" class="" value="attente">
</label>
<label>
Modifié
<input checked type="checkbox" name="etat_valide" id="etat_valide" class="" value="modifie">
</label>
</span>
</div>
`;
const span = document.createElement('span');
span.innerHTML = html
html = span.firstElementChild
const filterHead = html.querySelector('.filter-head');
filterHead.innerHTML = ""
let cols = ["entry_date", "date_debut", "date_fin", "etat", "raison", "fichier"];
cols.forEach((k) => {
const label = document.createElement('label')
label.classList.add('f-label')
const s = document.createElement('span');
s.textContent = columnTranslator(k);
const input = document.createElement('input');
input.classList.add('chk')
input.type = "checkbox"
input.name = k
input.id = k;
input.checked = filterJustificatifs.columns.includes(k)
label.appendChild(s)
label.appendChild(input)
filterHead.appendChild(label)
})
// Mise à jour des filtres
Object.keys(filterJustificatifs.filters).forEach((key) => {
const l = html.querySelector(`.filter-title[for="${key}"]`).parentElement;
if (key.indexOf('date') != -1) {
l.querySelector(`#${key}_pref`).value = filterJustificatifs.filters[key].pref;
l.querySelector(`#${key}_time`).value = filterJustificatifs.filters[key].time.format("YYYY-MM-DDTHH:mm");
} else if (key.indexOf('etat') != -1) {
l.querySelectorAll('input').forEach((e) => {
e.checked = filterJustificatifs.filters[key].includes(e.value)
})
}
})
openPromptModal("Filtrage des Justificatifs", html, () => {
const columns = [...document.querySelectorAll('.chk')]
.map((el) => { if (el.checked) return el.id })
.filter((el) => el)
filterJustificatifs.columns = columns
filterJustificatifs.filters = {}
//reste des filtres
const lines = [...document.querySelectorAll('.filter-line')];
lines.forEach((l) => {
const key = l.querySelector('.filter-title').getAttribute('for');
if (key.indexOf('date') != -1) {
const pref = l.querySelector(`#${key}_pref`).value;
const time = l.querySelector(`#${key}_time`).value;
if (l.querySelector(`#${key}_time`).value != "") {
filterJustificatifs.filters[key] = {
pref: pref,
time: new moment.tz(time, TIMEZONE)
}
}
} else if (key.indexOf('etat') != -1) {
filterJustificatifs.filters[key] = [...l.querySelectorAll("input:checked")].map((e) => e.value);
}
})
getAllJustificatifsFromEtud(etudid, justificatifCallBack)
}, () => { }, "#7059FF");
}
}
function columnTranslator(colName) {
switch (colName) {
case "date_debut":
return "Début";
case "entry_date":
return "Saisie le";
case "date_fin":
return "Fin";
case "etat":
return "État";
case "moduleimpl_id":
return "Module";
case "est_just":
return "Justifiée";
case "raison":
return "Raison";
case "fichier":
return "Fichier";
case "etudid":
return "Etudiant";
}
}
function openContext(e) {
e.preventDefault();
selectedRow = e.target.parentElement;
contextMenu.style.top = `${e.clientY - contextMenu.offsetHeight}px`;
contextMenu.style.left = `${e.clientX}px`;
contextMenu.style.display = "block";
}
</script>
<style>
.pageContent {
width: 100%;
max-width: var(--sco-content-max-width);
display: flex;
flex-direction: column;
flex-wrap: wrap;
}
table {
border-collapse: collapse;
text-align: left;
margin: 20px 0;
}
th,
td {
border: 1px solid #dddddd;
padding: 8px;
}
th {
background-color: #f2f2f2;
}
tr:hover {
filter: brightness(1.2)
}
.context-menu {
display: none;
position: fixed;
list-style-type: none;
padding: 10px 0;
background-color: #f9f9f9;
border: 1px solid #ccc;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
cursor: pointer;
z-index: 45;
}
.context-menu li {
padding: 8px 16px;
background-color: #f9f9f9;
}
.context-menu li:hover {
filter: brightness(0.7);
}
#deleteOption {
background-color: #F1A69C;
}
.l-present {
background-color: #9CF1AF;
}
.l-absent,
.l-invalid {
background-color: #F1A69C;
}
.l-valid {
background-color: #8f7eff;
}
.l-retard {
background-color: #F1D99C;
}
/* Ajoutez des styles pour le conteneur de pagination et les boutons */
.pagination-container {
display: flex;
justify-content: center;
margin: 20px 0;
}
.pagination-button {
padding: 10px;
border: 1px solid #ccc;
cursor: pointer;
background-color: #f9f9f9;
margin: 0 5px;
text-decoration: none;
color: #000;
}
.pagination-button:hover {
background-color: #ddd;
}
.pagination-button.active {
background-color: #007bff;
color: #fff;
border-color: #007bff;
}
th>div {
display: flex;
justify-content: space-between;
align-items: center;
}
.filter-head {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.filter-line {
display: flex;
justify-content: start;
align-items: center;
margin: 15px;
}
.filter-line>* {
margin-right: 5px;
}
.rbtn {
width: 35px;
height: 35px;
margin: 0 5px !important;
}
.f-label {
margin: 0 5px;
}
.chk {
margin-left: 2px !important;
}
.filter-body label {
display: flex;
justify-content: center;
align-items: center;
padding: 0;
margin: 0;
}
.obj-title {
text-decoration: underline #bbb;
font-weight: bold;
}
.obj-part {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 33%;
padding: 5px;
border: 1px solid #bbb;
}
.obj-dates,
.obj-mod,
.obj-rest {
display: flex;
justify-content: space-evenly;
margin: 2px;
}
.liste_pagination {
display: flex;
justify-content: space-evenly;
align-items: center;
gap: 5px;
}
</style>

View File

@ -0,0 +1,486 @@
<table id="justificatifTable">
<thead>
<tr>
<th>
<div>
<span>Début</span>
<a class="icon order" onclick="order('date_debut', justificatifCallBack, this, false)"></a>
</div>
</th>
<th>
<div>
<span>Fin</span>
<a class="icon order" onclick="order('date_fin', justificatifCallBack, this, false)"></a>
</div>
</th>
<th>
<div>
<span>État</span>
<a class="icon order" onclick="order('etat', justificatifCallBack, this, false)"></a>
</div>
</th>
<th>
<div>
<span>Raison</span>
<a class="icon order" onclick="order('raison', justificatifCallBack, this, false)"></a>
</div>
</th>
<th>
<div>
<span>Fichier</span>
<a class="icon order" onclick="order('fichier', justificatifCallBack, this, false)"></a>
</div>
</th>
</tr>
</thead>
<tbody id="tableBodyJustificatifs">
</tbody>
</table>
<div id="paginationContainerJustificatifs" class="pagination-container">
</div>
<script>
const paginationContainerJustificatifs = document.getElementById("paginationContainerJustificatifs");
let currentPageJustificatifs = 1;
let orderJustificatifs = true;
let filterJustificatifs = {
columns: [
"entry_date", "date_debut", "date_fin", "etat", "raison", "fichier"
],
filters: {}
}
const tableBodyJustificatifs = document.getElementById("tableBodyJustificatifs");
function justificatifCallBack(justi) {
justi = filterArray(justi, filterJustificatifs.filters)
renderTableJustificatifs(currentPageJustificatifs, justi);
renderPaginationButtons(justi, false);
}
function getEtudiant(id) {
if (id in etuds) {
return etuds[id];
}
getSingleEtud(id);
return etuds[id];
}
function renderTableJustificatifs(page, justificatifs) {
generateTableHead(filterJustificatifs.columns, false)
tableBodyJustificatifs.innerHTML = "";
const start = (page - 1) * itemsPerPage;
const end = start + itemsPerPage;
justificatifs.slice(start, end).forEach((justificatif) => {
const row = document.createElement("tr");
row.setAttribute('type', "justificatif");
row.setAttribute('obj_id', justificatif.justif_id);
const etat = justificatif.etat.toLowerCase();
if (etat == "valide") {
row.classList.add(`l-valid`);
} else {
row.classList.add(`l-invalid`);
}
filterJustificatifs.columns.forEach((k) => {
const td = document.createElement('td');
if (k.indexOf('date') != -1) {
td.textContent = moment.tz(justificatif[k], TIMEZONE).format(`DD/MM/Y HH:mm`)
} else if (k.indexOf('fichier') != -1) {
td.textContent = justificatif.fichier ? "Oui" : "Non";
} else if (k.indexOf('etudid') != -1) {
const e = getEtudiant(justificatif.etudid);
td.textContent = `${e.prenom.capitalize()} ${e.nom.toUpperCase()}`;
}
else {
if (justificatif[k] != null) {
td.textContent = `${justificatif[k]}`.capitalize()
}
else {
td.textContent = "";
}
}
row.appendChild(td)
})
row.addEventListener("contextmenu", openContext);
tableBodyJustificatifs.appendChild(row);
});
updateActivePaginationButton(false);
}
function detailJustificatifs(justi_id) {
const path = getUrl() + `/api/justificatif/${justi_id}`;
async_get(
path,
(data) => {
const user = getUser(data);
const date_debut = moment.tz(data.date_debut, TIMEZONE).format("DD/MM/YYYY HH:mm");
const date_fin = moment.tz(data.date_fin, TIMEZONE).format("DD/MM/YYYY HH:mm");
const entry_date = moment.tz(data.entry_date, TIMEZONE).format("DD/MM/YYYY HH:mm");
const etat = data.etat.capitalize();
const desc = data.raison == null ? "" : data.raison;
const id = data.justif_id;
const fichier = data.fichier != null ? "Oui" : "Non";
let filenames = []
let totalFiles = 0;
if (fichier) {
sync_get(path + "/list", (data2) => {
filenames = data2.filenames;
totalFiles = data2.total;
})
}
const html = `
<div class="obj-detail">
<div class="obj-dates">
<div id="date_debut" class="obj-part">
<span class="obj-title">Date de début</span>
<span class="obj-content">${date_debut}</span>
</div>
<div id="date_fin" class="obj-part">
<span class="obj-title">Date de fin</span>
<span class="obj-content">${date_fin}</span>
</div>
<div id="entry_date" class="obj-part">
<span class="obj-title">Date de saisie</span>
<span class="obj-content">${entry_date}</span>
</div>
</div>
<div class="obj-mod">
<div id="module" class="obj-part">
<span class="obj-title">Raison</span>
<span class="obj-content">${desc}</span>
</div>
<div id="etat" class="obj-part">
<span class="obj-title">Etat</span>
<span class="obj-content">${etat}</span>
</div>
<div id="user" class="obj-part">
<span class="obj-title">Créer par</span>
<span class="obj-content">${user}</span>
</div>
</div>
<div class="obj-rest">
<div id="est_just" class="obj-part obj-66">
<span class="obj-title">Fichier(s)</span>
<div class="obj-content" id="fich-content"></div>
</div>
<div id="id" class="obj-part">
<span class="obj-title">Identifiant du justificatif</span>
<span class="obj-content">${id}</span>
</div>
</div>
</div>
`
const el = document.createElement('div');
el.innerHTML = html;
const fichContent = el.querySelector('#fich-content');
const s = document.createElement('span')
s.textContent = `${totalFiles} fichier(s) dont ${filenames.length} visible(s)`
fichContent.appendChild(s)
filenames.forEach((name) => {
const a = document.createElement('a');
a.textContent = name
a.classList.add("fich-file")
a.onclick = () => { downloadFile(id, name) };
fichContent.appendChild(a);
})
openAlertModal("Détails", el.firstElementChild, null, "green")
}
)
}
function downloadFile(id, name) {
const path = getUrl() + `/api/justificatif/${id}/export/${name}`;
fetch(path, {
method: "POST"
})
// This returns a promise inside of which we are checking for errors from the server.
// The catch promise at the end of the call does not getting called when the server returns an error.
// More information about the error catching can be found here: https://www.tjvantoll.com/2015/09/13/fetch-and-errors/.
.then((result) => {
if (!result.ok) {
throw Error(result.statusText);
}
// We are reading the *Content-Disposition* header for getting the original filename given from the server
const header = result.headers.get('Content-Disposition');
const parts = header.split(';');
filename = parts[1].split('=')[1].replaceAll("\"", "");
return result.blob();
})
// We use the download property for triggering the download of the file from our browser.
// More information about the following code can be found here: https://stackoverflow.com/questions/32545632/how-can-i-download-a-file-using-window-fetch.
// The filename from the first promise is used as name of the file.
.then((blob) => {
if (blob != null) {
var url = window.URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
}
})
// The catch is getting called only for client-side errors.
// For example the throw in the first then-promise, which is the error that came from the server.
.catch((err) => {
console.log(err);
});
}
function editionJustificatifs(justif_id) {
const path = getUrl() + `/api/justificatif/${justif_id}`;
async_get(
path,
(data) => {
const html = `
<div class="assi-edit">
<div class="justi-row">
<div class="justi-label">
<legend for="justi_date_debut">Date de début</legend>
<input type="datetime-local" name="justi_date_debut" id="justi_date_debut">
</div>
<div class="justi-label">
<legend for="justi_date_fin">Date de fin</legend>
<input type="datetime-local" name="justi_date_fin" id="justi_date_fin">
</div>
</div>
<div class="justi-row">
<div class="justi-label">
<legend for="justi_etat">Etat du justificatif</legend>
<select name="justi_etat" id="justi_etat">
<option value="attente" selected>En Attente de validation</option>
<option value="non_valide">Non Valide</option>
<option value="modifie">Modifié</option>
<option value="valide">Valide</option>
</select>
</div>
</div>
<div class="justi-row">
<div class="justi-label">
<legend for="justi_raison">Raison</legend>
<textarea name="justi_raison" id="justi_raison" cols="50" rows="10" maxlength="500"></textarea>
</div>
</div>
<div class="justi-row">
<div class="justi-sect">
</div>
<div class="justi-label">
<legend for="justi_fich">Importer un fichier</legend>
<input type="file" name="justi_fich" id="justi_fich" multiple>
</div>
</div>
</div>
`
const desc = data.raison == null ? "" : data.raison;
const fichier = data.fichier != null ? "Oui" : "Non";
const el = document.createElement('div')
el.innerHTML = html;
const assiEdit = el.firstElementChild;
assiEdit.querySelector('#justi_etat').value = data.etat.toLowerCase();
assiEdit.querySelector('#justi_raison').value = desc;
const d_deb = moment.tz(data.date_debut, TIMEZONE).format("YYYY-MM-DDTHH:mm")
const d_fin = moment.tz(data.date_fin, TIMEZONE).format("YYYY-MM-DDTHH:mm")
console.warn(d_deb, d_fin, data.date_debut, data.date_fin)
assiEdit.querySelector('#justi_date_debut').value = d_deb
assiEdit.querySelector('#justi_date_fin').value = d_fin
const fichContent = assiEdit.querySelector('.justi-sect');
let filenames = []
let totalFiles = 0;
if (data.fichier) {
sync_get(path + "/list", (data2) => {
filenames = data2.filenames;
totalFiles = data2.total;
})
let html = "<legend>Fichier(s)</legend>"
html += `<span>${totalFiles} fichier(s) dont ${filenames.length} visible(s)</span>`
fichContent.insertAdjacentHTML('beforeend', html)
}
filenames.forEach((name) => {
const a = document.createElement('a');
a.textContent = name
a.classList.add("fich-file")
a.onclick = () => { downloadFile(justif_id, name) };
const input = document.createElement('input')
input.type = "checkbox"
input.name = "destroyFile";
input.classList.add('icon')
const span = document.createElement('span');
span.classList.add('file-line')
span.appendChild(input)
span.appendChild(a)
fichContent.appendChild(span);
})
openPromptModal("Modification du justificatif", assiEdit, () => {
const prompt = document.querySelector('.assi-edit');
let date_debut = prompt.querySelector('#justi_date_debut').value;
let date_fin = prompt.querySelector('#justi_date_fin').value;
if (date_debut == "" || date_fin == "") {
openAlertModal("Dates erronées", document.createTextNode('Les dates sont invalides'));
return true
}
date_debut = moment.tz(date_debut, TIMEZONE)
date_fin = moment.tz(date_fin, TIMEZONE)
if (date_debut >= date_fin) {
openAlertModal("Dates erronées", document.createTextNode('La date de fin doit être après la date de début'));
return true
}
const edit = {
date_debut: date_debut.format(),
date_fin: date_fin.format(),
raison: prompt.querySelector('#justi_raison').value,
etat: prompt.querySelector('#justi_etat').value,
}
const toRemoveFiles = [...prompt.querySelectorAll('[name="destroyFile"]:checked')]
if (toRemoveFiles.length > 0) {
removeFiles(justif_id, toRemoveFiles);
}
const in_files = prompt.querySelector('#justi_fich');
if (in_files.files.length > 0) {
importNewFiles(justif_id, in_files);
}
fullEditJustificatifs(data.justif_id, edit, () => {
loadAll();
})
}, () => { }, "green");
}
);
}
function fullEditJustificatifs(justif_id, obj, call = () => { }) {
const path = getUrl() + `/api/justificatif/${justif_id}/edit`;
async_post(
path,
obj,
call,
(data, status) => {
//error
console.error(data, status);
errorAlert();
}
);
}
function removeFiles(justif_id, files = []) {
const path = getUrl() + `/api/justificatif/${justif_id}/remove`;
files = files.map((el) => {
return el.parentElement.querySelector('a').textContent;
});
console.log(justif_id, files);
sync_post(
path,
{
"remove": "list",
"filenames": files,
},
);
}
function importNewFiles(justif_id, in_files) {
const path = getUrl() + `/api/justificatif/${justif_id}/import`;
const requests = []
Array.from(in_files.files).forEach((f) => {
const fd = new FormData();
fd.append('file', f);
requests.push(
$.ajax(
{
url: path,
type: 'POST',
data: fd,
dateType: 'json',
contentType: false,
processData: false,
success: () => { },
}
)
)
});
$.when(
requests
).done(() => {
})
}
</script>
<style>
.fich-file {
cursor: pointer;
margin: 2px;
}
#fich-content {
display: flex;
flex-wrap: wrap;
flex-direction: column;
align-items: center;
}
.obj-66 {
width: 66%;
}
.file-line {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 5px;
}
</style>

View File

@ -0,0 +1,313 @@
<div class="timeline-container">
<div class="period" style="left: 0%; width: 20%">
<div class="period-handle left"></div>
<div class="period-handle right"></div>
<div class="period-time">Time</div>
</div>
</div>
<script>
const timelineContainer = document.querySelector(".timeline-container");
const periodTimeLine = document.querySelector(".period");
const t_start = {{ t_start }};
const t_end = {{ t_end }};
const tick_time = 60 / {{ tick_time }};
const tick_delay = 1 / tick_time;
const period_default = {{ periode_defaut }};
function createTicks() {
let i = t_start;
while (i <= t_end) {
const hourTick = document.createElement("div");
hourTick.classList.add("tick", "hour");
hourTick.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`;
timelineContainer.appendChild(hourTick);
const tickLabel = document.createElement("div");
tickLabel.classList.add("tick-label");
tickLabel.style.left = `${((i - t_start) / (t_end - t_start)) * 100}%`;
tickLabel.textContent = numberToTime(i);
timelineContainer.appendChild(tickLabel);
if (i < t_end) {
let j = Math.floor(i + 1);
while (i < j) {
i += tick_delay;
if (i <= t_end) {
const quarterTick = document.createElement("div");
quarterTick.classList.add("tick", "quarter");
quarterTick.style.left = `${computePercentage(i, t_start)}%`;
timelineContainer.appendChild(quarterTick);
}
}
i = j;
} else {
i++;
}
}
}
function numberToTime(num) {
const integer = Math.floor(num);
const decimal = Math.round((num % 1) * 60);
let dec = `:${decimal}`;
if (decimal < 10) {
dec = `:0${decimal}`;
}
let int = `${integer}`;
if (integer < 10) {
int = `0${integer}`;
}
return int + dec;
}
function snapToQuarter(value) {
return Math.round(value * tick_time) / tick_time;
}
function updatePeriodTimeLabel() {
const values = getPeriodValues();
const deb = numberToTime(values[0])
const fin = numberToTime(values[1])
const text = `${deb} - ${fin}`
periodTimeLine.querySelector('.period-time').textContent = text;
}
function setupTimeLine(callback) {
const func_call = callback ? callback : () => { };
timelineContainer.addEventListener("mousedown", (event) => {
const startX = event.clientX;
if (event.target === periodTimeLine) {
const startLeft = parseFloat(periodTimeLine.style.left);
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const containerWidth = timelineContainer.clientWidth;
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, parseFloat(periodTimeLine.style.width));
updatePeriodTimeLabel();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener(
"mouseup",
() => {
generateAllEtudRow();
snapHandlesToQuarters();
document.removeEventListener("mousemove", onMouseMove);
func_call();
},
{ once: true }
);
} else if (event.target.classList.contains("period-handle")) {
const startWidth = parseFloat(periodTimeLine.style.width);
const startLeft = parseFloat(periodTimeLine.style.left);
const isLeftHandle = event.target.classList.contains("left");
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - startX;
const containerWidth = timelineContainer.clientWidth;
const newWidth =
startWidth + ((isLeftHandle ? -deltaX : deltaX) / containerWidth) * 100;
if (isLeftHandle) {
const newLeft = startLeft + (deltaX / containerWidth) * 100;
adjustPeriodPosition(newLeft, newWidth);
} else {
adjustPeriodPosition(parseFloat(periodTimeLine.style.left), newWidth);
}
updatePeriodTimeLabel();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener(
"mouseup",
() => {
snapHandlesToQuarters();
generateAllEtudRow();
document.removeEventListener("mousemove", onMouseMove);
func_call();
},
{ once: true }
);
}
});
}
function adjustPeriodPosition(newLeft, newWidth) {
const snappedLeft = snapToQuarter(newLeft);
const snappedWidth = snapToQuarter(newWidth);
const minLeft = 0;
const maxLeft = 100 - snappedWidth;
const clampedLeft = Math.min(Math.max(snappedLeft, minLeft), maxLeft);
periodTimeLine.style.left = `${clampedLeft}%`;
periodTimeLine.style.width = `${snappedWidth}%`;
}
function getPeriodValues() {
const leftPercentage = parseFloat(periodTimeLine.style.left);
const widthPercentage = parseFloat(periodTimeLine.style.width);
const startHour = (leftPercentage / 100) * (t_end - t_start) + t_start;
const endHour = ((leftPercentage + widthPercentage) / 100) * (t_end - t_start) + t_start;
const startValue = snapToQuarter(startHour);
const endValue = snapToQuarter(endHour);
const computedValues = [Math.max(startValue, t_start), Math.min(t_end, endValue)];
if (computedValues[0] > t_end || computedValues[1] < t_start) {
return [t_start, min(t_end, t_start + period_default)];
}
if (computedValues[1] - computedValues[0] <= tick_delay && computedValues[1] < t_end - tick_delay) {
computedValues[1] += tick_delay;
}
return computedValues;
}
function setPeriodValues(deb, fin) {
deb = snapToQuarter(deb);
fin = snapToQuarter(fin);
let leftPercentage = (deb - t_start) / (t_end - t_start) * 100;
let widthPercentage = (fin - deb) / (t_end - t_start) * 100;
periodTimeLine.style.left = `${leftPercentage}%`;
periodTimeLine.style.width = `${widthPercentage}%`;
snapHandlesToQuarters();
generateAllEtudRow();
updatePeriodTimeLabel()
}
function snapHandlesToQuarters() {
const periodValues = getPeriodValues();
let lef = Math.min(computePercentage(periodValues[0], t_start), computePercentage(t_end, tick_delay));
if (lef < 0) {
lef = 0;
}
const left = `${lef}%`;
let wid = Math.max(computePercentage(periodValues[1], periodValues[0]), computePercentage(tick_delay, 0));
if (wid > 100) {
wid = 100;
}
const width = `${wid}%`
periodTimeLine.style.left = left;
periodTimeLine.style.width = width;
updatePeriodTimeLabel()
}
function computePercentage(a, b) {
return ((a - b) / (t_end - t_start)) * 100;
}
createTicks();
setPeriodValues(t_start, t_start + period_default);
</script>
<style>
.timeline-container {
width: 75%;
margin-left: 25px;
background-color: white;
border-radius: 15px;
position: relative;
height: 40px;
margin-bottom: 25px;
}
/* ... */
.tick {
position: absolute;
bottom: 0;
width: 1px;
background-color: rgba(0, 0, 0, 0.5);
}
.tick.hour {
height: 100%;
}
.tick.quarter {
height: 50%;
}
.tick-label {
position: absolute;
bottom: 0;
font-size: 12px;
text-align: center;
transform: translateY(100%) translateX(-50%);
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.period {
position: absolute;
height: 100%;
background-color: rgba(0, 183, 255, 0.5);
border-radius: 15px;
}
.period-handle {
position: absolute;
top: 0;
bottom: 0;
width: 8px;
border-radius: 0 4px 4px 0;
cursor: col-resize;
}
.period-handle.right {
right: 0;
border-radius: 4px 0 0 4px;
}
.period .period-time {
display: none;
position: absolute;
left: calc(50% - var(--w)/2 - 5px);
justify-content: center;
align-content: center;
top: calc(-60% - 10px);
--w: 10em;
width: var(--w);
}
.period:hover .period-time {
display: flex;
background-color: rgba(0, 183, 255, 1);
border-radius: 15px;
padding: 5px;
}
</style>

View File

@ -0,0 +1,116 @@
<div class="toast-holder">
</div>
<style>
.toast-holder {
position: fixed;
right: 1vw;
top: 5vh;
height: 80vh;
width: 20vw;
display: flex;
flex-direction: column;
flex-wrap: wrap;
transition: all 0.3s ease-in-out;
pointer-events: none;
}
.toast {
margin: 0.5vh 0;
display: flex;
width: 100%;
height: fit-content;
justify-content: flex-start;
align-items: center;
border-radius: 10px;
padding: 7px;
z-index: 250;
transition: all 0.3s ease-in-out;
}
.fadeIn {
animation: fadeIn 0.5s ease-in;
}
.fadeOut {
animation: fadeOut 0.5s ease-in;
}
.toast-content {
color: whitesmoke;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes fadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style>
<script>
function generateToast(content, color = "#12d3a5", ttl = 5) {
const toast = document.createElement('div')
toast.classList.add('toast', 'fadeIn')
const toastContent = document.createElement('div')
toastContent.classList.add('toast-content')
toastContent.appendChild(content)
toast.style.backgroundColor = color;
setTimeout(() => { toast.classList.replace('fadeIn', 'fadeOut') }, Math.max(0, ttl * 1000 - 500))
setTimeout(() => { toast.remove() }, Math.max(0, ttl * 1000))
toast.appendChild(toastContent)
return toast
}
function pushToast(toast) {
document
.querySelector(".toast-holder")
.appendChild(
toast
);
}
function getToastColorFromEtat(etat) {
let color;
switch (etat.toUpperCase()) {
case "PRESENT":
color = "#6bdb83";
break;
case "ABSENT":
color = "#F1A69C";
break;
case "RETARD":
color = "#f0c865";
break;
default:
color = "#AAA";
break;
}
return color;
}
</script>

View File

@ -9,41 +9,41 @@
{% block app_content %}
<div class="sco_help">
<h2>Calcul automatique des décisions de jury du BUT</h2>
<ul>
<li>N'enregistre jamais de décisions de l'année scolaire précédente, même
si on a des RCUE "à cheval" sur deux années.
</li>
<li><b>Attention: peut modifier des décisions déjà enregistrées</b>, si la
validation de droit est calculée.
Ce calcul <b>n'utilise que les notes, et pas les décisions manuelles déjà saisies.</b>
<br>
Par exemple, vous aviez saisi <b>ATJ</b> ou <b>RAT</b>
pour un étudiant dont les moyennes d'UE dépassent 10 mais qui pour une
raison particulière ne valide pas son année. Le calcul automatique peut
remplacer ce <b>RAT</b> par un <b>ADM</b>, ScoDoc considérant que les
conditions sont satisfaites. On peut éviter cela en laissant une note de
l'étudiant en ATTente.
</li>
<li>N'enregistre que les décisions <b>validantes de droit: ADM ou CMP</b>.
</li>
<li>N'enregistre pas de décision si l'étudiant a une ou plusieurs notes en ATTente.
</li>
<li>L'assiduité n'est <b>pas</b> prise en compte. </li>
</ul>
<p>
En conséquence, saisir ensuite <b>manuellement les décisions manquantes</b>,
notamment sur les UEs en dessous de 10.
</p>
<div class="warning">
<h2>Calcul automatique des décisions de jury du BUT</h2>
<ul>
<li>Ne jamais lancer ce calcul avant que toutes les notes ne soient saisies !
(verrouiller le semestre ensuite)
</li>
<li>Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure !</li>
</div>
<li>N'enregistre jamais de décisions de l'année scolaire précédente, même
si on a des RCUE "à cheval" sur deux années.
</li>
<li><b>Attention: peut modifier des décisions déjà enregistrées</b>, si la
validation de droit est calculée.
Ce calcul <b>n'utilise que les notes, et pas les décisions manuelles déjà saisies.</b>
<br>
Par exemple, vous aviez saisi <b>ATJ</b> ou <b>RAT</b>
pour un étudiant dont les moyennes d'UE dépassent 10 mais qui pour une
raison particulière ne valide pas son année. Le calcul automatique peut
remplacer ce <b>RAT</b> par un <b>ADM</b>, ScoDoc considérant que les
conditions sont satisfaites. On peut éviter cela en laissant une note de
l'étudiant en ATTente.
</li>
<li>N'enregistre que les décisions <b>validantes de droit: ADM ou CMP</b>.
</li>
<li>N'enregistre pas de décision si l'étudiant a une ou plusieurs notes en ATTente.
</li>
<li>L'assiduité n'est <b>pas</b> prise en compte. </li>
</ul>
<p>
En conséquence, saisir ensuite <b>manuellement les décisions manquantes</b>,
notamment sur les UEs en dessous de 10.
</p>
<div class="warning">
<ul>
<li>Ne jamais lancer ce calcul avant que toutes les notes ne soient saisies !
(verrouiller le semestre ensuite)
</li>
<li>Il est nécessaire de relire soigneusement les décisions à l'issue de cette procédure !</li>
</div>
</div>

View File

@ -0,0 +1,84 @@
{% extends "base.j2" %}
{% import 'bootstrap/wtf.html' as wtf %}
{% block styles %}
{{super()}}
<style>
div.lien_perso {
border-left: 3px solid blue;
margin-bottom: 16px;
}
div.lien_perso>div:first-child {
font-weight: bold;
font-size: 120%;
margin-left: 8px;
}
div.lien_perso .form-group, div.lien_perso .checkbox {
margin-left: 32px;
}
div.url > .form-group {
margin-bottom: 0px;
}
div.validation-buttons {
margin-top: 24px;
}
</style>
{% endblock %}
{% block app_content %}
<h1>{{title}}</h1>
<div class="help">
<p>Les liens définis ici seront affichés dans le menu <b>Liens</b> de tous
les semestres de tous les départements.</p>
<p>Si on coche "ajouter arguments", une query string est ajoutée par ScoDoc
à la fin du lien, pour passer des informations sur le contexte:</p>
<ul>
<li><tt>dept</tt> : acronyme du département
<li><tt>formsemestre_id</tt> : id du formsemestre affiché
<li><tt>moduleimpl_id</tt> : id du moduleimpl affiché (si page module)
<li><tt>evaluation_id</tt> : id de l'évaluation affichée (si page d'évaluation)
<li><tt>etudid</tt> : id de l'étudiant (si un étudiant est sélectionné)
<li><tt>user_name</tt> : login scodoc de l'utilisateur
<li><tt>cas_id</tt> : login CAS de l'utilisateur
<ul>
</div>
<div class="row">
<div class="col-md-8">
<form class="form form-horizontal form-personalized-links" method="post" enctype="multipart/form-data" role="form">
{{ form.hidden_tag() }}
{{ wtf.form_errors(form, hiddens="only") }}
{% for idx, link in form.links_by_id.items() %}
<div class="lien_perso">
<div>Lien personnalisé {{idx}}</div>
{{ wtf.form_field( form["link_"+idx|string] ) }}
<div class="url">{{ wtf.form_field( form["link_url_"+idx|string] ) }}</div>
{{ wtf.form_field( form["link_with_args_"+idx|string] ) }}
</div>
{% endfor %}
<div class="lien_perso">
<div>Nouveau lien personnalisé</div>
{{ wtf.form_field( form["link_new"] ) }}
<div class="url">{{ wtf.form_field( form["link_url_new"] ) }}</div>
{{ wtf.form_field( form["link_with_args_new"] ) }}
</div>
<div class="form-group validation-buttons">
{{ wtf.form_field(form.submit) }}
{{ wtf.form_field(form.cancel) }}
</div>
</form>
</div>
</div>
{% endblock %}

View File

@ -24,6 +24,20 @@
<h1>Configuration générale</h1>
<div class="sco_help greenboldtext">Les paramètres donnés ici s'appliquent à tout ScoDoc (tous les départements).</div>
<h2>ScoDoc</h2>
<form id="configuration_form_scodoc" class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate>
{{ form_scodoc.hidden_tag() }}
<div class="row">
<div class="col-md-8">
{{ wtf.quick_form(form_scodoc) }}
</div>
</div>
<div style="margin-top: 16px;">
<a class="stdlink" href="{{url_for('scodoc.config_personalized_links')}}">Éditer des liens personnalisés</a>
</div>
</form>
<section>
<h2>Calcul des "bonus" définis par l'établissement</h2>
<form id="configuration_form" class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate>
@ -43,41 +57,37 @@
<div class="sco_help">Ces images peuvent être intégrées dans les documents
générés par ScoDoc: bulletins, PV, etc.
</div>
<p><a class="stdlink" href="{{url_for('scodoc.configure_logos')}}">configuration des images et logos</a>
<p><a class="stdlink" href="{{url_for('scodoc.configure_logos')}}">Configuration des images et logos</a>
</p>
</section>
<section>
<h2>Exports Apogée</h2>
<p><a class="stdlink" href="{{url_for('scodoc.config_codes_decisions')}}">configuration des codes de décision</a>
<p><a class="stdlink" href="{{url_for('scodoc.config_codes_decisions')}}">Configuration des codes de décision</a>
</p>
</section>
<section>
<h2>Assiduités</h2>
<p><a class="stdlink" href="{{url_for('scodoc.config_assiduites')}}">Configuration du suivi de l'assiduité</a>
</p>
</section>
<h2>Utilisateurs et CAS</h2>
<section>
<div>
🏰 <a class="stdlink" href="{{url_for('scodoc.config_cas')}}">Configuration du service CAS</a>
🏰 <a class="stdlink" href="{{url_for('scodoc.config_cas')}}">Configuration du service CAS</a>
</div>
<div style="margin-top: 16px;">
🧑🏾‍🤝‍🧑🏼 <a class="stdlink" href="{{ url_for('auth.cas_users_import_config') }}">
Configurer les comptes utilisateurs pour le CAS</a>
🧑🏾‍🤝‍🧑🏼 <a class="stdlink" href="{{ url_for('auth.cas_users_import_config') }}">
Configurer les comptes utilisateurs pour le CAS</a>
</div>
<div style="margin-top: 16px;">
🛟 <a class="stdlink" href="{{url_for('auth.reset_standard_roles_permissions')}}">Remettre
les permissions des rôles standards à leurs valeurs par défaut</a>
(efface les modifications apportées aux rôles)
🛟 <a class="stdlink" href="{{url_for('auth.reset_standard_roles_permissions')}}">Remettre
les permissions des rôles standards à leurs valeurs par défaut</a>
(efface les modifications apportées aux rôles)
</div>
</section>
<h2>ScoDoc</h2>
<form id="configuration_form_scodoc" class="sco-form" action="" method="post" enctype="multipart/form-data" novalidate>
{{ form_scodoc.hidden_tag() }}
<div class="row">
<div class="col-md-8">
{{ wtf.quick_form(form_scodoc) }}
</div>
</div>
</form>
{% endblock %}
{% block scripts %}

View File

@ -4,8 +4,8 @@
{% if not validations %}
<p>Aucune validation de jury enregistrée pour <b>{{etud.html_link_fiche()|safe}}</b>
sur <b>l'année {{annee}}</b>
de la formation <em>{{ formation.html() }}</em>
sur <b>l'année {{annee}}</b>
de la formation <em>{{ formation.html() }}</em>
</p>
<div style="margin-top: 16px;">
@ -16,7 +16,7 @@ de la formation <em>{{ formation.html() }}</em>
<h2>Effacer les décisions de jury pour l'année {{annee}} de {{etud.html_link_fiche()|safe}} ?</h2>
<p class="help">Affectera toutes les décisions concernant l'année {{annee}} de la formation,
quelle que soit leur origine.</p>
quelle que soit leur origine.</p>
<p>Les décisions concernées sont:</p>
<ul>
@ -38,28 +38,28 @@ quelle que soit leur origine.</p>
{% endif %}
<div class="sco_box">
<div class="sco_box_title">Autres actions:</div>
<ul>
<li><a class="stdlink" href="{{
<div class="sco_box_title">Autres actions:</div>
<ul>
<li><a class="stdlink" href="{{
url_for('notes.jury_delete_manual',
scodoc_dept=g.scodoc_dept,
etudid=etud.id
)
}}">effacer les décisions une à une</a>
</li>
{% if formsemestre_origine is not none %}
</li>
{% if formsemestre_origine is not none %}
<li><a class="stdlink" href="{{
url_for('notes.formsemestre_jury_but_erase',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_origine.id,
etudid=etud.id, only_one_sem=1)
}}">
effacer seulement les décisions émises par le semestre
{{formsemestre_origine.titre_formation(with_sem_idx=1)|safe}}
(efface aussi la décision annuelle)
effacer seulement les décisions émises par le semestre
{{formsemestre_origine.titre_formation(with_sem_idx=1)|safe}}
(efface aussi la décision annuelle)
</a>
</li>
{% endif %}
</ul>
{% endif %}
</ul>
</div>

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