Compare commits

..

143 Commits

Author SHA1 Message Date
4ae484061e Exports Apogée: modernise code. Exporte RCUE du BUT: implements #925 2024-06-09 15:18:03 +02:00
320cfbebc8 WIP: modernisation code jurys 2024-06-07 17:58:02 +02:00
e0208d0650 9.6.973 2024-06-06 23:43:20 +02:00
f18fbe284a Merge pull request 'Assiduité : ajout_assiduite_etud : fix bug moduleimpl' (#927) from iziram/ScoDoc:hotfix into master
Reviewed-on: #927
2024-06-06 23:42:31 +02:00
Iziram
6acf72c0c9 Assiduité : ajout_assiduite_etud : fix bug moduleimpl 2024-06-06 18:48:18 +02:00
b675a57678 9.6.972 2024-06-05 18:06:39 +02:00
ecd753fae7 Merge pull request 'Correction BUG + Ticket #913' (#924) from iziram/ScoDoc:hotfix into master
Reviewed-on: #924
2024-06-05 15:37:44 +02:00
Iziram
c55e02497e Assiduité : saisie_assiduites_hebdo : liste assi conflits (sco-drop) closes #913 2024-06-05 14:06:41 +02:00
Iziram
7c7697631e Assiduité : correction bug moduleimpls edit_assiduite_etud 2024-06-05 13:43:44 +02:00
0fe60aaa64 Merge pull request 'Assiduité : fix urgent bug' (#922) from iziram/ScoDoc:hotfix into master
Reviewed-on: #922
2024-06-05 12:26:46 +02:00
Iziram
09f697941d Assiduité : ajout_assiduite_etud: changement msg erreur semestre non dispo 2024-06-05 11:21:53 +02:00
Iziram
cefef8a89a Assiduité : fix urgent bug 2024-06-05 10:52:02 +02:00
b7cbfbae67 Merge pull request 'Assiduité : ajout_justificatif_etud : bug non affichage btn enregistrer / annuler' (#920) from iziram/ScoDoc:hotfix into master
Reviewed-on: #920
2024-06-05 09:52:37 +02:00
Iziram
0fee2e612b Assiduité : ajout_justificatif_etud : bug non affichage btn enregistrer / annuler 2024-06-05 09:27:18 +02:00
1167c13787 Fix scodoc7 http non GET/POST requests 2024-06-04 23:15:50 +02:00
963c09976b Merge pull request 'Corrections de bugs / tickets Assidiutés' (#916) from iziram/ScoDoc:assiduites_fixes into master
Reviewed-on: #916
2024-06-04 23:09:45 +02:00
Iziram
dbb5ac8946 Assiduité : calendrier_assiduite_etud : prio absence non_just + cleanup 2024-06-04 16:26:55 +02:00
Iziram
93bb9d598e Assiduité : edit_assiduite_etud : modification dates/heures assiduités (formulaires) 2024-06-04 16:01:05 +02:00
Iziram
9801cf7936 Assiduité : modif suppression assiduités (mettre tout le monde) + avertissement 2024-06-04 15:21:28 +02:00
Iziram
db44e8e5f4 Assiduité : fusion tableau_action détails/modification closes #918 #768 2024-06-03 15:50:35 +02:00
Iziram
ffdaf2a19a Assiduité : simplification accès assiduite-bubble 2024-06-03 08:36:13 +02:00
Iziram
ba77b155c5 Assiduité : mettre tout le monde uniquement création closes #917 2024-06-03 08:20:25 +02:00
fed84559fc Merge branch 'master' into assiduites_fixes 2024-06-03 08:01:21 +02:00
02a5b00ecf Changement réf. comp. équivalent: SD <> STID. 2024-06-02 12:05:01 +02:00
dcdf6a8012 Assiduite: supprime lien saisie différée + lien choix semaine 2024-06-02 10:05:15 +02:00
912a213dcd Rafraichissement image lors changement photo etud. Pres. trombi. Photos pour demos. 2024-06-01 14:28:42 +02:00
3575e89dc0 check invalid etudid 2024-06-01 14:27:02 +02:00
Iziram
675eccd6b6 Assiduité : limitation ajout_assiduites_etud sur semestre + visu_assi_group lien ajout closes #908 2024-05-31 11:10:04 +02:00
Iziram
07a8658672 Assiduité: conflit_resolver bug affichage #904 2024-05-31 10:29:54 +02:00
Iziram
80bd02114e Assiduité : erreurs dates hors semestres #907 2024-05-31 10:13:22 +02:00
Iziram
65a4b31fbd sco_evaluations : titre module dans bulle info calendrier eval closes #906 2024-05-31 09:54:36 +02:00
Iziram
7bdae70d38 sco_groups_view : btn -> a.stdlink closes #898 2024-05-31 09:48:45 +02:00
21c0625147 formsemestre_report_counts: ajout champ 'boursier' 2024-05-30 16:05:45 +02:00
e18c1d8fd0 Merge branch 'iziram-sco_gen_cal' 2024-05-30 13:31:07 +02:00
5867d0f430 typo 2024-05-30 13:30:37 +02:00
9897ccc659 Numéros pages sur bulletins BUT. Closes #652 2024-05-30 12:08:41 +02:00
Iziram
7575959bd4 sco_gen_cal : calendrier_choix_date + implementation dans Assiduité closes #914 2024-05-30 10:52:13 +02:00
Iziram
2aafbad9e2 sco_gen_cal : hightlight + week_index + jour date 2024-05-30 09:49:33 +02:00
50f2cd7a0f Assiduité: Liens et message temporaire 2024-05-29 19:09:06 +02:00
fd8fbb9e02 Merge branch 'iziram-saisie_hebdo' 2024-05-29 18:42:06 +02:00
Iziram
ebcef76950 Assiduité : signal_assiduites_hebdo : choix heures init defaut closes #911 2024-05-29 17:30:07 +02:00
Iziram
13349776af Assiduité : signal_assiduites_hebdo : bulle info assi closes #912 2024-05-29 17:25:57 +02:00
Iziram
f275286b71 Assiduité : liens saisie hebdo 2024-05-29 16:29:34 +02:00
Iziram
f4f6c13d79 Assiduité : signal_assiduites_hebdo : v2 sans mobile 2024-05-29 15:59:19 +02:00
e7f23efe65 Affichage poids sur tableau de bord module: normalisation par evaluation_type. Closes #886 2024-05-29 12:12:31 +02:00
e44d3fd5dc Améliore visualisation coefficients sur tableau bord module. Closes #886. 2024-05-29 11:55:28 +02:00
fac36fa11c Merge branch 'master' into saisie_hebdo 2024-05-29 10:56:55 +02:00
9289535359 Ajout Identite.nom_prenom() 2024-05-29 10:48:34 +02:00
Iziram
d73b925006 Assiduité : signal_assiduites_hedbo : v1 OK 2024-05-28 20:07:25 +02:00
6749ca70d6 Fix prise en compte evals session 2 avec poids ne couvrant pas toutes les UEs (#811) 2024-05-28 13:51:27 +02:00
Iziram
dea403b03d Assiduité : signal_assiduites_hebdo : verif heure matin < aprem 2024-05-28 09:51:40 +02:00
Iziram
ab9543c310 [WIP] Assiduité : signal_assiduites_hebdo : choix horaires 2024-05-27 23:26:13 +02:00
Iziram
f94998f66b [WIP] Assiduité : corrections saisie_assiduites_hebdo 2024-05-27 22:33:01 +02:00
Iziram
eb88a8ca83 [WIP] Assiduité : saisie_assiduites_hebdo 2024-05-27 17:59:34 +02:00
7042650fd9 Merge branch 'lyanis-report' 2024-05-26 22:57:47 +02:00
2745ffd687 Bug report: corrections mineures 2024-05-26 22:57:04 +02:00
9a882ea41d Merge branch 'report' of https://scodoc.org/git/lyanis/ScoDoc into lyanis-report 2024-05-26 20:14:58 +02:00
ea6003e812 Modif message page saisie différée pour 9.6.967 2024-05-26 17:03:25 +02:00
5c6935337e Merge branch 'iziram-modif_assi' 2024-05-25 18:13:16 +02:00
60998d2e20 Assiduite: bg bouton delete + dialog confirm 2024-05-25 18:12:44 +02:00
29b877d9ed Script API pour enregistrer tous les résultats. 2024-05-25 13:03:51 +02:00
Iziram
6834c19015 Assiduité : modif assiduites_bubble 2024-05-24 16:51:44 +02:00
Iziram
f47fc4ba46 Assiduité : signal_assiduites_group : modif bouton mettre tout le monde "aucun" 2024-05-24 16:27:17 +02:00
5894c6f952 search_etud_by_name: case insensitive. 2024-05-24 15:37:07 +02:00
af1d1884c7 Template/wtf form pour bug report
Ajout d'un template pour gérer le formulaire et utilisation de WTF form pour la validation des données.
2024-05-24 13:01:56 +02:00
Iziram
881bf82000 data-tooltip + enableToolTip sur la sidebar 2024-05-24 10:37:11 +02:00
Iziram
2ed4516a97 Assiduité : fusion liste_etud bilan_etud 2024-05-24 10:26:47 +02:00
Iziram
75ce1ccd31 Assiduité : signal_assiduite_group : sauvegarde auto timeline 2024-05-24 09:56:05 +02:00
Iziram
f8d5f6ea11 Assiduité : suppression code non utilisé 2024-05-24 09:40:44 +02:00
Iziram
70995fbd7e Assiduité : suppression préférence periode_defaut 2024-05-24 09:36:42 +02:00
dc095765f2 Retrait décorateur inutile
Le décorateur `@scodoc7func` n'est pas utile pour cette vue, il est retiré.
2024-05-23 16:21:12 +02:00
Iziram
1cec3fa703 Assiduité : signal_assiduite_group : bouton jour suivant / précédent 2024-05-23 09:40:44 +02:00
Iziram
032454aefd Assiduité : signal_assiduites_group : bouton pour remonter la page 2024-05-23 09:23:45 +02:00
Iziram
e3344cf424 Assiduité : signal_assiduites_group : bouton matin/aprem 2024-05-23 09:17:56 +02:00
Iziram
d7acff9d35 Assiduité : reorganisation lien assi page sem + bulle avertissement saisie diff 2024-05-23 09:07:50 +02:00
Iziram
decdf59e20 Assiduité : renommage Saisie journalière -> saisie assiduité 2024-05-23 08:59:10 +02:00
Iziram
42fc08a3a3 Assiduité : suppression page visu_assiduites_group (signal_assiduites_group readonly) 2024-05-23 08:56:12 +02:00
Iziram
f3770fb5c7 Assiduité : avertissement fusion saisie jour - saisie diff 2024-05-23 08:52:08 +02:00
63b28a3277 Ajout d'un formulaire de rapport de bug
- Formulaire permettant de saisir un rapport de bug et de l'envoyer sur une nouvelle API scodoc.org
- Modification du lien de la page d'accueil pour pointer vers le formulaire de rapport de bug au lieu de simplement dump
- Après un échange avec l'API scodoc.org (pour l'upload de dump et la création de ticket), on tente de récuperer le champ json "message" pour l'afficher à l'utilisateur
2024-05-23 00:15:32 +02:00
bb23cdcea7 PV jury: restreint cursus à la formation actuelle. Fix #622. 2024-05-22 19:18:57 +02:00
3ca5636454 Filigranne PDF: légère modif position. 2024-05-22 13:00:44 +02:00
42882154d5 JS initialisation datatables + id sur GenTable. Fix #880. 2024-05-22 00:06:30 +02:00
489acb26d2 Texte additionnel sur pieds de pages PDF. Closes #653. 2024-05-21 21:14:50 +02:00
8ee373db7d Warning si evals rattrapage non conformes en BUT.. Closes #811. 2024-05-21 20:43:45 +02:00
8e56dc2418 Formulaire évaluation: interdit de définir des évaluations non normales immédiates 2024-05-21 20:37:40 +02:00
b3331bd886 Adapte test unitaire pour nouveau search_etuds_infos_from_exp. 2024-05-21 20:24:16 +02:00
89afb672af Support pour plusieurs évaluations de rattrapage en classique et BUT. Avance sur #811. 2024-05-21 20:23:10 +02:00
8f25284038 Code formatting 2024-05-20 23:31:03 +02:00
f29002a57d Tableau évaluations: ajout colonne type 2024-05-20 23:29:25 +02:00
69780b3f24 Evaluations de session 2: moyenne sur plusieurs, en prennant en compte les poids en BUT. Modif vérification conformite (bug #811). WIP: reste à vérifier ratrapages. 2024-05-20 23:28:39 +02:00
fbff151be0 recherche étudiant: modernise code 2024-05-20 16:11:44 +02:00
3b436fa0f3 Enhance ScoValueError messages (lié à 87aaf12d27) 2024-05-20 10:46:36 +02:00
8847a1f008 Fix warning set_ue_poids_dict. Add type_abbrev() method. 2024-05-20 10:01:39 +02:00
ac882e9ccd Fix: cache poids evals (invalidation manquante) 2024-05-19 22:57:21 +02:00
000e016985 Enhance critical_error handling. 2024-05-19 22:53:54 +02:00
22d90215a0 Effacer décisions de jury des formations classiques: closes #884 2024-05-19 15:38:30 +02:00
043985bff6 cosmetic: calendrier evaluations 2024-05-17 15:23:29 +02:00
d20ada1797 Merge branch 'gen_cal' of https://scodoc.org/git/iziram/ScoDoc into gen_cal 2024-05-17 12:02:23 +02:00
Iziram
778fecabb6 sco_gen_cal : correction affichage semaine/année courante 2024-05-15 14:16:11 +02:00
Iziram
fa6f83722e sco_gen_cal : ajout style semaine courante 2024-05-15 13:35:44 +02:00
baa0412071 Merge pull request 'Mise à jour du README' (#881) from lyanis/ScoDoc:readme into master
Reviewed-on: https://scodoc.org/git/ScoDoc/ScoDoc/pulls/881
2024-05-13 18:23:34 +02:00
d51a47b71a Fix: formulaire creation étudiant (date naissance vide) 2024-05-13 17:31:54 +02:00
f21ef41de6 README: Mise en forme des blocs de code 2024-05-13 14:54:52 +02:00
2d673e7a5d Mise à jour du README 2024-05-13 11:16:10 +02:00
3e43495831 Fix: bulletins pdf, notes évaluations sans notes. 2024-05-07 18:17:13 +02:00
Iziram
a4db8c4ff8 utilisation sco_gen_cal pour calendrier evaluations #875 2024-05-07 16:47:08 +02:00
Iziram
1ac35d04c2 Assiduité : utilisation sco_gen_cal closes #877 2024-05-07 16:45:03 +02:00
Iziram
687ac3cf13 Assiduité : Généralisation du Calendrier WIP 2024-05-06 17:29:21 +02:00
18b1f00586 version 9.6.964 2024-04-24 20:58:02 +02:00
Iziram
6b985620e9 Assiduité : signal_assiduites_diff : plage depuis args url closes #741 2024-04-24 20:55:21 +02:00
Iziram
4d234ba353 Assiduité : désactiver saisie présence closes #793 2024-04-24 20:55:21 +02:00
Iziram
5d45fcf656 Assiduité : signal_assiduites_group : bug fix suppr assi autre 2024-04-24 20:55:21 +02:00
Iziram
0a5919b788 Assiduité : pseudo-fix (catch + http error) #872 2024-04-24 20:55:21 +02:00
Iziram
09f4525e66 Assiduité : maj couleurs minitimeline + legende 2024-04-24 20:55:21 +02:00
0bc57807de release build script using gitea again 2024-04-24 20:37:03 +02:00
87aaf12d27 Protect against Reflected XSS on home page (and other exception-handling pages) 2024-04-23 18:28:00 +02:00
c8ab9b9b6c Invalidation cache lors d'une erreur sur association UE/Niveau. Peut-être cause de #874. 2024-04-15 18:06:26 +02:00
ad7b48e110 Calendrier évaluations: fix #875 2024-04-15 17:53:02 +02:00
f2ce16f161 Archive PV: gzip large files 2024-04-15 03:21:32 +02:00
1ddf9b6ab8 Fix: création utilisateur si un seul département 2024-04-12 15:50:53 +02:00
0a2e39cae1 Ajoute aide sur édition parcours UEs 2024-04-12 01:10:42 +02:00
a194b4b6e0 Edition parcours UE: si tous cochés, tronc commun 2024-04-12 01:05:02 +02:00
cbe85dfb7d anonymize_users: ignore admin 2024-04-12 01:04:27 +02:00
beba69bfe4 Améliore/met à jour tests unitaires API 2024-04-11 06:00:00 +02:00
41fec29452 Bulletin BUT: ne mentionne pas les évaluations rattrapage/session2 sans notes. (c'est déjà le cas en classic) 2024-04-11 01:45:25 +02:00
9bd05ea241 Modify SCO_URL in all js: no trailing slash. 2024-04-11 01:44:17 +02:00
58b831513d Améliore traitement des erreurs lors de la génération des PDF 2024-04-10 15:29:30 +02:00
b861aba6a3 Tableaux génériques: possibilité de déclarer un colonne seulement pour excel. Assiduité: ajout etudid et NIP a visu_assi_group: closes #873. 2024-04-10 15:09:32 +02:00
c2443c361f Améliore page activation module entreprises. Implements #634 2024-04-09 00:36:46 +02:00
ab4731bd43 Suppression des anciennes fonctions ScoDoc7 donnant les URLs de base. 2024-04-08 18:57:00 +02:00
c17bc8b61b Fix: liste semestres avec code 2024-04-08 16:26:38 +02:00
e44a5ee55d Corrige templates formsemestre 2024-04-07 19:52:22 +02:00
a747ed22e2 Ajoute équivalences pour ref. comp. QLIO 2024-04-07 19:51:34 +02:00
5d0a932634 Bulletins BUT: utilisation de l'abbréviation du titre module si présente. 2024-04-06 12:33:07 +02:00
2b150cf521 Modif config Jina2. Refonte ScoData, fournit par défaut à tous les templates. 2024-04-06 12:16:53 +02:00
5a5ddcacd7 Associer une formation BUT à un nouveau référentiel 'équivalent'. 2024-04-05 23:41:34 +02:00
3f6e65b9da Elimine @cached_property sur Identite, pourrait provoquer incohérences temporaires en multithread 2024-04-05 11:00:01 +02:00
5eba6170a5 Fix: typo bloquant affichage formations avec UEs sans semestre_idx 2024-04-05 10:11:34 +02:00
bd9bf87112 Enrichissement du tableau des formations (coche 'détails') 2024-04-05 00:23:29 +02:00
a0e2af481f Fonction expérimentale pour changer le ref. de compétences d'une formation 2024-04-05 00:22:14 +02:00
42e8f97441 Fix: missing exception 2024-04-04 11:23:26 +02:00
8ec0171ca0 Script préparation démos: renommage de tous les étudiants 2024-04-03 19:02:40 +02:00
6dfab2d843 Fix typo affichage heures 2024-04-03 18:47:44 +02:00
191 changed files with 7399 additions and 4119 deletions

152
README.md
View File

@ -2,7 +2,7 @@
(c) Emmanuel Viennet 1999 - 2024 (voir LICENCE.txt). (c) Emmanuel Viennet 1999 - 2024 (voir LICENCE.txt).
Installation: voir instructions à jour sur <https://scodoc.org/GuideInstallDebian11> Installation: voir instructions à jour sur <https://scodoc.org/GuideInstallDebian12>
Documentation utilisateur: <https://scodoc.org> Documentation utilisateur: <https://scodoc.org>
@ -23,7 +23,7 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes).
### Lignes de commandes ### Lignes de commandes
Voir [https://scodoc.org/GuideConfig](le guide de configuration). Voir [le guide de configuration](https://scodoc.org/GuideConfig).
## Organisation des fichiers ## Organisation des fichiers
@ -41,45 +41,41 @@ Ils ne doivent pas être modifiés à la main, sauf certains fichiers de configu
Le répertoire `/opt/scodoc-data` doit être régulièrement sauvegardé. Le répertoire `/opt/scodoc-data` doit être régulièrement sauvegardé.
Principaux contenus: Principaux contenus:
```
/opt/scodoc-data /opt/scodoc-data
/opt/scodoc-data/log # Fichiers de log ScoDoc /opt/scodoc-data/log # Fichiers de log ScoDoc
/opt/scodoc-data/config # Fichiers de configuration /opt/scodoc-data/config # Fichiers de configuration
.../config/logos # Logos de l'établissement .../config/logos # Logos de l'établissement
.../config/depts # un fichier par département .../config/depts # un fichier par département
/opt/scodoc-data/photos # Photos des étudiants /opt/scodoc-data/photos # Photos des étudiants
/opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants /opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants
```
## Pour les développeurs ## Pour les développeurs
### Installation du code ### Installation du code
Installer ScoDoc 9 normalement ([voir la doc](https://scodoc.org/GuideInstallDebian11)). Installer ScoDoc 9 normalement ([voir la doc](https://scodoc.org/GuideInstallDebian12)).
Puis remplacer `/opt/scodoc` par un clone du git. Puis remplacer `/opt/scodoc` par un clone du git.
```bash
sudo su
mv /opt/scodoc /opt/off-scodoc # ou ce que vous voulez
apt-get install git # si besoin
git clone https://scodoc.org/git/ScoDoc/ScoDoc.git /opt/scodoc
# (ou bien utiliser votre clone gitea si vous l'avez déjà créé !)
sudo su # Donner ce répertoire à l'utilisateur scodoc:
mv /opt/scodoc /opt/off-scodoc # ou ce que vous voulez chown -R scodoc:scodoc /opt/scodoc
apt-get install git # si besoin ```
cd /opt
git clone https://scodoc.org/git/viennet/ScoDoc.git
# (ou bien utiliser votre clone gitea si vous l'avez déjà créé !)
# Renommer le répertoire:
mv ScoDoc scodoc
# Et donner ce répertoire à l'utilisateur scodoc:
chown -R scodoc.scodoc /opt/scodoc
Il faut ensuite installer l'environnement et le fichier de configuration: Il faut ensuite installer l'environnement et le fichier de configuration:
```bash
# Le plus simple est de piquer le virtualenv configuré par l'installeur: # Le plus simple est de piquer le virtualenv configuré par l'installeur:
mv /opt/off-scodoc/venv /opt/scodoc mv /opt/off-scodoc/venv /opt/scodoc
```
Et la config: Et la config:
```bash
ln -s /opt/scodoc-data/.env /opt/scodoc ln -s /opt/scodoc-data/.env /opt/scodoc
```
Cette dernière commande utilise le `.env` crée lors de l'install, ce qui Cette dernière commande utilise le `.env` crée lors de l'install, ce qui
n'est pas toujours le plus judicieux: vous pouvez modifier son contenu, par n'est pas toujours le plus judicieux: vous pouvez modifier son contenu, par
exemple pour travailler en mode "développement" avec `FLASK_ENV=development`. exemple pour travailler en mode "développement" avec `FLASK_ENV=development`.
@ -88,11 +84,11 @@ exemple pour travailler en mode "développement" avec `FLASK_ENV=development`.
Les tests unitaires utilisent normalement la base postgresql `SCODOC_TEST`. Les tests unitaires utilisent normalement la base postgresql `SCODOC_TEST`.
Avant le premier lancement, créer cette base ainsi: Avant le premier lancement, créer cette base ainsi:
```bash
./tools/create_database.sh SCODOC_TEST ./tools/create_database.sh SCODOC_TEST
export FLASK_ENV=test export FLASK_ENV=test
flask db upgrade flask db upgrade
```
Cette commande n'est nécessaire que la première fois (le contenu de la base Cette commande n'est nécessaire que la première fois (le contenu de la base
est effacé au début de chaque test, mais son schéma reste) et aussi si des est effacé au début de chaque test, mais son schéma reste) et aussi si des
migrations (changements de schéma) ont eu lieu dans le code. migrations (changements de schéma) ont eu lieu dans le code.
@ -100,17 +96,17 @@ migrations (changements de schéma) ont eu lieu dans le code.
Certains tests ont besoin d'un département déjà créé, qui n'est pas créé par les Certains tests ont besoin d'un département déjà créé, qui n'est pas créé par les
scripts de tests: scripts de tests:
Lancer au préalable: Lancer au préalable:
```bash
flask delete-dept -fy TEST00 && flask create-dept TEST00 flask delete-dept -fy TEST00 && flask create-dept TEST00
```
Puis dérouler les tests unitaires: Puis dérouler les tests unitaires:
```bash
pytest tests/unit pytest tests/unit
```
Ou avec couverture (`pip install pytest-cov`) Ou avec couverture (`pip install pytest-cov`)
```bash
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/* pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
```
#### Utilisation des tests unitaires pour initialiser la base de dev #### Utilisation des tests unitaires pour initialiser la base de dev
On peut aussi utiliser les tests unitaires pour mettre la base de données de On peut aussi utiliser les tests unitaires pour mettre la base de données de
@ -119,43 +115,43 @@ développement dans un état connu, par exemple pour éviter de recréer à la m
Il suffit de positionner une variable d'environnement indiquant la BD utilisée Il suffit de positionner une variable d'environnement indiquant la BD utilisée
par les tests: par les tests:
```bash
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
```
(si elle n'existe pas, voir plus loin pour la créer) puis de les lancer (si elle n'existe pas, voir plus loin pour la créer) puis de les lancer
normalement, par exemple: normalement, par exemple:
```bash
pytest tests/unit/test_sco_basic.py pytest tests/unit/test_sco_basic.py
```
Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins) un Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins) un
utilisateur: utilisateur:
```bash
flask user-password admin flask user-password admin
```
**Attention:** les tests unitaires **effacent** complètement le contenu de la **Attention:** les tests unitaires **effacent** complètement le contenu de la
base de données (tous les départements, et les utilisateurs) avant de commencer ! base de données (tous les départements, et les utilisateurs) avant de commencer !
#### Modification du schéma de la base #### Modification du schéma de la base
On utilise SQLAlchemy avec Alembic et Flask-Migrate. On utilise SQLAlchemy avec Alembic et Flask-Migrate.
```bash
flask db migrate -m "message explicatif....." flask db migrate -m "message explicatif....."
flask db upgrade flask db upgrade
```
Ne pas oublier de d'ajouter le script de migration à git (`git add migrations/...`). Ne pas oublier de d'ajouter le script de migration à git (`git add migrations/...`).
**Mémo**: séquence re-création d'une base (vérifiez votre `.env` **Mémo**: séquence re-création d'une base (vérifiez votre `.env`
ou variables d'environnement pour interroger la bonne base !). ou variables d'environnement pour interroger la bonne base !).
```bash
dropdb SCODOC_DEV
tools/create_database.sh SCODOC_DEV # créé base SQL
flask db upgrade # créé les tables à partir des migrations
flask sco-db-init # ajoute au besoin les constantes (fait en migration 0)
dropdb SCODOC_DEV # puis imports:
tools/create_database.sh SCODOC_DEV # créé base SQL flask import-scodoc7-users
flask db upgrade # créé les tables à partir des migrations flask import-scodoc7-dept STID SCOSTID
flask sco-db-init # ajoute au besoin les constantes (fait en migration 0) ```
# puis imports:
flask import-scodoc7-users
flask import-scodoc7-dept STID SCOSTID
Si la base utilisée pour les dev n'est plus en phase avec les scripts de Si la base utilisée pour les dev n'est plus en phase avec les scripts de
migration, utiliser les commandes `flask db history`et `flask db stamp`pour se migration, utiliser les commandes `flask db history`et `flask db stamp`pour se
positionner à la bonne étape. positionner à la bonne étape.
@ -163,23 +159,23 @@ positionner à la bonne étape.
### Profiling ### Profiling
Sur une machine de DEV, lancer Sur une machine de DEV, lancer
```bash
flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data
```
le fichier `.prof` sera alors écrit dans `/opt/scodoc-data` (on peut aussi utiliser `/tmp`). le fichier `.prof` sera alors écrit dans `/opt/scodoc-data` (on peut aussi utiliser `/tmp`).
Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bien: Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bien:
```bash
pip install snakeviz pip install snakeviz
```
puis puis
```bash
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
```
## Paquet Debian 12 ## Paquet Debian 12
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
important est `postinst`qui se charge de configurer le système (install ou important est `postinst` qui se charge de configurer le système (install ou
upgrade de scodoc9). upgrade de scodoc9).
La préparation d'une release se fait à l'aide du script La préparation d'une release se fait à l'aide du script

View File

@ -315,12 +315,6 @@ def create_app(config_class=DevConfig):
app.register_error_handler(503, postgresql_server_error) app.register_error_handler(503, postgresql_server_error)
app.register_error_handler(APIInvalidParams, handle_invalid_usage) app.register_error_handler(APIInvalidParams, handle_invalid_usage)
# Add some globals
# previously in Flask-Bootstrap:
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
field, HiddenField
)
from app.auth import bp as auth_bp from app.auth import bp as auth_bp
app.register_blueprint(auth_bp, url_prefix="/auth") app.register_blueprint(auth_bp, url_prefix="/auth")
@ -338,8 +332,15 @@ def create_app(config_class=DevConfig):
from app.api import api_bp from app.api import api_bp
from app.api import api_web_bp from app.api import api_web_bp
# Jinja2 configuration
# Enable autoescaping of all templates, including .j2 # Enable autoescaping of all templates, including .j2
app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True) app.jinja_env.autoescape = select_autoescape(default_for_string=True, default=True)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
# previously in Flask-Bootstrap:
app.jinja_env.globals["bootstrap_is_hidden_field"] = lambda field: isinstance(
field, HiddenField
)
# https://scodoc.fr/ScoDoc # https://scodoc.fr/ScoDoc
app.register_blueprint(scodoc_bp) app.register_blueprint(scodoc_bp)
@ -636,14 +637,12 @@ def critical_error(msg):
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
log(f"\n*** CRITICAL ERROR: {msg}") log(f"\n*** CRITICAL ERROR: {msg}")
send_scodoc_alarm(f"CRITICAL ERROR: {msg}", msg) subject = f"CRITICAL ERROR: {msg}".strip()[:68]
send_scodoc_alarm(subject, msg)
clear_scodoc_cache() clear_scodoc_cache()
raise ScoValueError( raise ScoValueError(
f""" f"""
Une erreur est survenue. Une erreur est survenue, veuillez -essayer.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
{msg} {msg}
""" """

View File

@ -3,14 +3,15 @@
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# See LICENSE # See LICENSE
############################################################################## ##############################################################################
"""ScoDoc 9 API : Assiduités """ScoDoc 9 API : Assiduités"""
"""
from datetime import datetime from datetime import datetime
from flask import g, request from flask import g, request
from flask_json import as_json from flask_json import as_json
from flask_login import current_user, login_required from flask_login import current_user, login_required
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
from sqlalchemy.orm.exc import ObjectDeletedError
from app import db, log, set_sco_dept from app import db, log, set_sco_dept
import app.scodoc.sco_assiduites as scass import app.scodoc.sco_assiduites as scass
@ -858,7 +859,10 @@ def assiduite_edit(assiduite_id: int):
msg=f"assiduite: modif {assiduite_unique}", msg=f"assiduite: modif {assiduite_unique}",
) )
db.session.commit() db.session.commit()
scass.simple_invalidate_cache(assiduite_unique.to_dict()) try:
scass.simple_invalidate_cache(assiduite_unique.to_dict())
except ObjectDeletedError:
return json_error(404, "Assiduité supprimée / inexistante")
return {"OK": True} return {"OK": True}

View File

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

View File

@ -37,7 +37,17 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
] ]
# Choix des parcours # Choix des parcours
ue_pids = [p.id for p in ue.parcours] ue_pids = [p.id for p in ue.parcours]
H.append("""<form id="choix_parcours">""") H.append(
"""
<div class="help">
Cocher tous les parcours dans lesquels cette UE est utilisée,
même si vous n'offrez pas ce parcours dans votre département.
Sans quoi, les UEs de Tronc Commun ne seront pas reconnues.
Ne cocher aucun parcours est équivalent à tous les cocher.
</div>
<form id="choix_parcours" style="margin-top: 12px;">
"""
)
ects_differents = { ects_differents = {
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours

View File

@ -9,12 +9,14 @@
import collections import collections
import datetime import datetime
import pandas as pd
import numpy as np import numpy as np
from flask import g, has_request_context, url_for from flask import g, has_request_context, url_for
from app import db from app import db
from app.comp.moy_mod import ModuleImplResults
from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_but import ResultatsSemestreBUT
from app.models import Evaluation, FormSemestre, Identite from app.models import Evaluation, FormSemestre, Identite, ModuleImpl
from app.models.groups import GroupDescr from app.models.groups import GroupDescr
from app.models.ues import UniteEns from app.models.ues import UniteEns
from app.scodoc import sco_bulletins, sco_utils as scu from app.scodoc import sco_bulletins, sco_utils as scu
@ -229,7 +231,7 @@ class BulletinBUT:
if res.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit if res.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit
d[modimpl.module.code] = { d[modimpl.module.code] = {
"id": modimpl.id, "id": modimpl.id,
"titre": modimpl.module.titre, "titre": modimpl.module.titre_str(),
"code_apogee": modimpl.module.code_apogee, "code_apogee": modimpl.module.code_apogee,
"url": ( "url": (
url_for( url_for(
@ -249,59 +251,88 @@ class BulletinBUT:
# "moy": fmt_note(moyennes_etuds.mean()), # "moy": fmt_note(moyennes_etuds.mean()),
}, },
"evaluations": ( "evaluations": (
[ self.etud_list_modimpl_evaluations(
self.etud_eval_results(etud, e) etud, modimpl, modimpl_results, version
for e in modimpl.evaluations )
if (e.visibulletin or version == "long")
and (e.id in modimpl_results.evaluations_etat)
and (
modimpl_results.evaluations_etat[e.id].is_complete
or self.prefs["bul_show_all_evals"]
)
]
if version != "short" if version != "short"
else [] else []
), ),
} }
return d return d
def etud_eval_results(self, etud, e: Evaluation) -> dict: def etud_list_modimpl_evaluations(
self,
etud: Identite,
modimpl: ModuleImpl,
modimpl_results: ModuleImplResults,
version: str,
) -> list[dict]:
"""Liste des résultats aux évaluations de ce modimpl à montrer pour cet étudiant"""
evaluation: Evaluation
eval_results = []
for evaluation in modimpl.evaluations:
if (
(evaluation.visibulletin or version == "long")
and (evaluation.id in modimpl_results.evaluations_etat)
and (
modimpl_results.evaluations_etat[evaluation.id].is_complete
or self.prefs["bul_show_all_evals"]
)
):
eval_notes = self.res.modimpls_results[modimpl.id].evals_notes[
evaluation.id
]
if (evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE) or (
not np.isnan(eval_notes[etud.id])
):
eval_results.append(
self.etud_eval_results(etud, evaluation, eval_notes)
)
return eval_results
def etud_eval_results(
self, etud: Identite, evaluation: Evaluation, eval_notes: pd.DataFrame
) -> dict:
"dict resultats d'un étudiant à une évaluation" "dict resultats d'un étudiant à une évaluation"
# eval_notes est une pd.Series avec toutes les notes des étudiants inscrits # eval_notes est une pd.Series avec toutes les notes des étudiants inscrits
eval_notes = self.res.modimpls_results[e.moduleimpl_id].evals_notes[e.id]
notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna() notes_ok = eval_notes.where(eval_notes > scu.NOTES_ABSENCE).dropna()
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id] modimpls_evals_poids = self.res.modimpls_evals_poids[evaluation.moduleimpl_id]
try: try:
etud_ues_ids = self.res.etud_ues_ids(etud.id) etud_ues_ids = self.res.etud_ues_ids(etud.id)
poids = { poids = {
ue.acronyme: modimpls_evals_poids[ue.id][e.id] ue.acronyme: modimpls_evals_poids[ue.id][evaluation.id]
for ue in self.res.ues for ue in self.res.ues
if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids) if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
} }
except KeyError: except KeyError:
poids = collections.defaultdict(lambda: 0.0) poids = collections.defaultdict(lambda: 0.0)
d = { d = {
"id": e.id, "id": evaluation.id,
"coef": ( "coef": (
fmt_note(e.coefficient) fmt_note(evaluation.coefficient)
if e.evaluation_type == Evaluation.EVALUATION_NORMALE if evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE
else None else None
), ),
"date_debut": e.date_debut.isoformat() if e.date_debut else None, "date_debut": (
"date_fin": e.date_fin.isoformat() if e.date_fin else None, evaluation.date_debut.isoformat() if evaluation.date_debut else None
"description": e.description, ),
"evaluation_type": e.evaluation_type, "date_fin": (
evaluation.date_fin.isoformat() if evaluation.date_fin else None
),
"description": evaluation.description,
"evaluation_type": evaluation.evaluation_type,
"note": ( "note": (
{ {
"value": fmt_note( "value": fmt_note(
eval_notes[etud.id], eval_notes[etud.id],
note_max=e.note_max, note_max=evaluation.note_max,
), ),
"min": fmt_note(notes_ok.min(), note_max=e.note_max), "min": fmt_note(notes_ok.min(), note_max=evaluation.note_max),
"max": fmt_note(notes_ok.max(), note_max=e.note_max), "max": fmt_note(notes_ok.max(), note_max=evaluation.note_max),
"moy": fmt_note(notes_ok.mean(), note_max=e.note_max), "moy": fmt_note(notes_ok.mean(), note_max=evaluation.note_max),
} }
if not e.is_blocked() if not evaluation.is_blocked()
else {} else {}
), ),
"poids": poids, "poids": poids,
@ -309,17 +340,25 @@ class BulletinBUT:
url_for( url_for(
"notes.evaluation_listenotes", "notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
evaluation_id=e.id, evaluation_id=evaluation.id,
) )
if has_request_context() if has_request_context()
else "na" else "na"
), ),
# deprecated (supprimer avant #sco9.7) # deprecated (supprimer avant #sco9.7)
"date": e.date_debut.isoformat() if e.date_debut else None, "date": (
"heure_debut": ( evaluation.date_debut.isoformat() if evaluation.date_debut else None
e.date_debut.time().isoformat("minutes") if e.date_debut else None ),
"heure_debut": (
evaluation.date_debut.time().isoformat("minutes")
if evaluation.date_debut
else None
),
"heure_fin": (
evaluation.date_fin.time().isoformat("minutes")
if evaluation.date_fin
else None
), ),
"heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None,
} }
return d return d
@ -540,9 +579,9 @@ class BulletinBUT:
d.update(infos) d.update(infos)
# --- Rangs # --- Rangs
d[ d["rang_nt"] = (
"rang_nt" f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}" )
d["rang_txt"] = "Rang " + d["rang_nt"] d["rang_txt"] = "Rang " + d["rang_nt"]
d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"])) d.update(sco_bulletins.make_context_dict(self.res.formsemestre, d["etud"]))

View File

@ -124,7 +124,9 @@ def _build_bulletin_but_infos(
formsemestre, bulletins_sem.res formsemestre, bulletins_sem.res
) )
if warn_html: if warn_html:
raise ScoValueError("<b>Formation mal configurée pour le BUT</b>" + warn_html) raise ScoValueError(
"<b>Formation mal configurée pour le BUT</b>" + warn_html, safe=True
)
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau( ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud refcomp, etud
) )

View File

@ -73,6 +73,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
html_class="notes_bulletin", html_class="notes_bulletin",
html_class_ignore_default=True, html_class_ignore_default=True,
html_with_td_classes=True, html_with_td_classes=True,
table_id="bul-table",
) )
table_objects = table.gen(fmt=fmt) table_objects = table.gen(fmt=fmt)
objects += table_objects objects += table_objects
@ -427,12 +428,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
else "*" else "*"
) )
note_value = e["note"].get("value", "")
t = { t = {
"titre": f"{e['description'] or ''}", "titre": f"{e['description'] or ''}",
"moyenne": e["note"]["value"], "moyenne": note_value,
"_moyenne_pdf": Paragraph( "_moyenne_pdf": Paragraph(f"""<para align=right>{note_value}</para>"""),
f"""<para align=right>{e["note"]["value"]}</para>"""
),
"coef": coef, "coef": coef,
"_coef_pdf": Paragraph( "_coef_pdf": Paragraph(
f"""<para align=right fontSize={self.small_fontsize}><i>{ f"""<para align=right fontSize={self.small_fontsize}><i>{

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

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

View File

@ -44,7 +44,7 @@ from app.scodoc import sco_cursus_dut
class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic): class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic):
"""Pour compat ScoDoc 7: à revoir pour le BUT""" """Pour compat ScoDoc 7: à revoir pour le BUT"""
def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT): def __init__(self, etud: Identite, formsemestre_id: int, res: ResultatsSemestreBUT):
super().__init__(etud, formsemestre_id, res) super().__init__(etud, formsemestre_id, res)
# Ajustements pour le BUT # Ajustements pour le BUT
self.can_compensate_with_prev = False # jamais de compensation à la mode DUT self.can_compensate_with_prev = False # jamais de compensation à la mode DUT

View File

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

View File

@ -27,6 +27,7 @@
"""caches pour tables APC """caches pour tables APC
""" """
from flask import g
from app.scodoc import sco_cache from app.scodoc import sco_cache
@ -47,3 +48,27 @@ class EvaluationsPoidsCache(sco_cache.ScoDocCache):
""" """
prefix = "EPC" prefix = "EPC"
@classmethod
def invalidate_all(cls):
"delete all cached evaluations poids (in current dept)"
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
moduleimpl_ids = [
mi.id
for mi in ModuleImpl.query.join(FormSemestre).filter_by(
dept_id=g.scodoc_dept_id
)
]
cls.delete_many(moduleimpl_ids)
@classmethod
def invalidate_sem(cls, formsemestre_id):
"delete cached evaluations poids for this formsemestre from cache"
from app.models.moduleimpls import ModuleImpl
moduleimpl_ids = [
mi.id for mi in ModuleImpl.query.filter_by(formsemestre_id=formsemestre_id)
]
cls.delete_many(moduleimpl_ids)

View File

@ -45,7 +45,6 @@ from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType from app.scodoc.sco_utils import ModuleType
@ -113,6 +112,8 @@ class ModuleImplResults:
""" """
self.evals_etudids_sans_note = {} self.evals_etudids_sans_note = {}
"""dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions.""" """dict: evaluation_id : set des etudids non notés dans cette eval, sans les démissions."""
self.evals_type = {}
"""Type de chaque eval { evaluation.id : evaluation.evaluation_type }"""
self.load_notes(etudids, etudids_actifs) self.load_notes(etudids, etudids_actifs)
self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index) self.etuds_use_session2 = pd.Series(False, index=self.evals_notes.index)
"""1 bool par etud, indique si sa moyenne de module vient de la session2""" """1 bool par etud, indique si sa moyenne de module vient de la session2"""
@ -164,7 +165,10 @@ class ModuleImplResults:
self.evaluations_completes = [] self.evaluations_completes = []
self.evaluations_completes_dict = {} self.evaluations_completes_dict = {}
self.etudids_attente = set() # empty self.etudids_attente = set() # empty
self.evals_type = {}
evaluation: Evaluation
for evaluation in moduleimpl.evaluations: for evaluation in moduleimpl.evaluations:
self.evals_type[evaluation.id] = evaluation.evaluation_type
eval_df = self._load_evaluation_notes(evaluation) eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi # is_complete ssi
# tous les inscrits (non dem) au module ont une note # tous les inscrits (non dem) au module ont une note
@ -270,6 +274,24 @@ class ModuleImplResults:
* self.evaluations_completes * self.evaluations_completes
).reshape(-1, 1) ).reshape(-1, 1)
def get_evaluations_special_coefs(
self, modimpl: ModuleImpl, evaluation_type=Evaluation.EVALUATION_SESSION2
) -> np.array:
"""Coefficients des évaluations de session 2 ou rattrapage.
Les évals de session 2 et rattrapage sont réputées "complètes": elles sont toujours
prises en compte mais seules les notes numériques et ABS sont utilisées.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
(e.coefficient if e.evaluation_type == evaluation_type else 0.0)
for e in modimpl.evaluations
],
dtype=float,
)
).reshape(-1, 1)
# was _list_notes_evals_titles # was _list_notes_evals_titles
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]: def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"Liste des évaluations complètes" "Liste des évaluations complètes"
@ -296,32 +318,26 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items() for (etudid, x) in self.evals_notes[evaluation_id].items()
} }
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None: def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas. """Les évaluations de rattrapage de ce module.
Rattrapage: la moyenne du module est la meilleure note entre moyenne Rattrapage: la moyenne du module est la meilleure note entre moyenne
des autres évals et la note eval rattrapage. des autres évals et la moyenne des notes de rattrapage.
""" """
eval_list = [ return [
e e
for e in moduleimpl.evaluations for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
] ]
if eval_list:
return eval_list[0]
return None
def get_evaluation_session2(self, moduleimpl: ModuleImpl) -> Evaluation | None: def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""L'évaluation de deuxième session de ce module, ou None s'il n'en a pas. """Les évaluations de deuxième session de ce module, ou None s'il n'en a pas.
Session 2: remplace la note de moyenne des autres évals. La moyenne des notes de Session 2 remplace la note de moyenne des autres évals.
""" """
eval_list = [ return [
e e
for e in moduleimpl.evaluations for e in moduleimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_SESSION2 if e.evaluation_type == Evaluation.EVALUATION_SESSION2
] ]
if eval_list:
return eval_list[0]
return None
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]: def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations bonus de ce module, ou liste vide s'il n'en a pas.""" """Les évaluations bonus de ce module, ou liste vide s'il n'en a pas."""
@ -344,12 +360,13 @@ class ModuleImplResultsAPC(ModuleImplResults):
"Calcul des moyennes de modules à la mode BUT" "Calcul des moyennes de modules à la mode BUT"
def compute_module_moy( def compute_module_moy(
self, self, evals_poids_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
evals_poids_df: pd.DataFrame,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""Calcule les moyennes des étudiants dans ce module """Calcule les moyennes des étudiants dans ce module
Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs Argument:
evals_poids: DataFrame, colonnes: UEs, lignes: EVALs
modimpl_coefs_df: DataFrame, colonnes: modimpl_id, lignes: ue_id
Résultat: DataFrame, colonnes UE, lignes etud Résultat: DataFrame, colonnes UE, lignes etud
= la note de l'étudiant dans chaque UE pour ce module. = la note de l'étudiant dans chaque UE pour ce module.
@ -370,6 +387,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
return pd.DataFrame(index=[], columns=evals_poids_df.columns) return pd.DataFrame(index=[], columns=evals_poids_df.columns)
if nb_ues == 0: if nb_ues == 0:
return pd.DataFrame(index=self.evals_notes.index, columns=[]) return pd.DataFrame(index=self.evals_notes.index, columns=[])
# coefs des évals complètes normales (pas rattr., session 2 ni bonus):
evals_coefs = self.get_evaluations_coefs(modimpl) evals_coefs = self.get_evaluations_coefs(modimpl)
evals_poids = evals_poids_df.values * evals_coefs evals_poids = evals_poids_df.values * evals_coefs
# -> evals_poids shape : (nb_evals, nb_ues) # -> evals_poids shape : (nb_evals, nb_ues)
@ -398,6 +416,47 @@ class ModuleImplResultsAPC(ModuleImplResults):
) / np.sum(evals_poids_etuds, axis=1) ) / np.sum(evals_poids_etuds, axis=1)
# etuds_moy_module shape: nb_etuds x nb_ues # etuds_moy_module shape: nb_etuds x nb_ues
evals_session2 = self.get_evaluations_session2(modimpl)
evals_rat = self.get_evaluations_rattrapage(modimpl)
if evals_session2:
# Session2 : quand elle existe, remplace la note de module
# Calcul moyenne notes session2 et remplace (si la note session 2 existe)
etuds_moy_module_s2 = self._compute_moy_special(
modimpl,
evals_notes_stacked,
evals_poids_df,
Evaluation.EVALUATION_SESSION2,
)
# Vrai si toutes les UEs avec coef non nul ont bien une note de session 2 calculée:
mod_coefs = modimpl_coefs_df[modimpl.id]
etuds_use_session2 = np.all(
np.isfinite(etuds_moy_module_s2[:, mod_coefs != 0]), axis=1
)
etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis],
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
elif evals_rat:
etuds_moy_module_rat = self._compute_moy_special(
modimpl,
evals_notes_stacked,
evals_poids_df,
Evaluation.EVALUATION_RATTRAPAGE,
)
etuds_ue_use_rattrapage = (
etuds_moy_module_rat > etuds_moy_module
) # etud x UE
etuds_moy_module = np.where(
etuds_ue_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
np.any(etuds_ue_use_rattrapage, axis=1), index=self.evals_notes.index
)
# Application des évaluations bonus: # Application des évaluations bonus:
etuds_moy_module = self.apply_bonus( etuds_moy_module = self.apply_bonus(
etuds_moy_module, etuds_moy_module,
@ -405,47 +464,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
evals_poids_df, evals_poids_df,
evals_notes_stacked, evals_notes_stacked,
) )
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
etuds_moy_module = np.where(
etuds_use_session2[:, np.newaxis],
np.tile(
(notes_session2 / (eval_session2.note_max / 20.0))[:, np.newaxis],
nb_ues,
),
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
else:
# Rattrapage: remplace la note de module ssi elle est supérieure
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# "Étend" le rattrapage sur les UE: la note de rattrapage est la même
# pour toutes les UE mais ne remplace que là où elle est supérieure
notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1)
# prend le max
etuds_use_rattrapage = notes_rat_ues > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat_ues, etuds_moy_module
)
# Serie indiquant que l'étudiant utilise une note de rattrapage sur l'une des UE:
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index
)
self.etuds_moy_module = pd.DataFrame( self.etuds_moy_module = pd.DataFrame(
etuds_moy_module, etuds_moy_module,
index=self.evals_notes.index, index=self.evals_notes.index,
@ -453,6 +471,34 @@ class ModuleImplResultsAPC(ModuleImplResults):
) )
return self.etuds_moy_module return self.etuds_moy_module
def _compute_moy_special(
self,
modimpl: ModuleImpl,
evals_notes_stacked: np.array,
evals_poids_df: pd.DataFrame,
evaluation_type: int,
) -> np.array:
"""Calcul moyenne APC sur évals rattrapage ou session2"""
nb_etuds = self.evals_notes.shape[0]
nb_ues = evals_poids_df.shape[1]
evals_coefs_s2 = self.get_evaluations_special_coefs(
modimpl, evaluation_type=evaluation_type
)
evals_poids_s2 = evals_poids_df.values * evals_coefs_s2
poids_stacked_s2 = np.stack(
[evals_poids_s2] * nb_etuds
) # nb_etuds, nb_evals, nb_ues
evals_poids_etuds_s2 = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked_s2,
0,
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module_s2 = np.sum(
evals_poids_etuds_s2 * evals_notes_stacked, axis=1
) / np.sum(evals_poids_etuds_s2, axis=1)
return etuds_moy_module_s2
def apply_bonus( def apply_bonus(
self, self,
etuds_moy_module: pd.DataFrame, etuds_moy_module: pd.DataFrame,
@ -525,6 +571,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
return evals_poids, ues return evals_poids, ues
# appelé par ModuleImpl.check_apc_conformity()
def moduleimpl_is_conforme( def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool: ) -> bool:
@ -546,12 +593,12 @@ def moduleimpl_is_conforme(
if len(modimpl_coefs_df) != nb_ues: if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour... # il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre() sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent") return app.critical_error("moduleimpl_is_conforme: err 1")
if moduleimpl.id not in modimpl_coefs_df: if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ? # soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre() sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer") return app.critical_error("moduleimpl_is_conforme: err 2")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0 module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids)) return all((modimpl_coefs_df[moduleimpl.id] != 0).eq(module_evals_poids))
@ -593,46 +640,43 @@ class ModuleImplResultsClassic(ModuleImplResults):
evals_coefs_etuds * evals_notes_20, axis=1 evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1) ) / np.sum(evals_coefs_etuds, axis=1)
evals_session2 = self.get_evaluations_session2(modimpl)
evals_rat = self.get_evaluations_rattrapage(modimpl)
if evals_session2:
# Session2 : quand elle existe, remplace la note de module
# Calcule la moyenne des évaluations de session2
etuds_moy_module_s2 = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_SESSION2
)
etuds_use_session2 = np.isfinite(etuds_moy_module_s2)
etuds_moy_module = np.where(
etuds_use_session2,
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
elif evals_rat:
# Rattrapage: remplace la note de module ssi elle est supérieure
# Calcule la moyenne des évaluations de rattrapage
etuds_moy_module_rat = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_RATTRAPAGE
)
etuds_use_rattrapage = etuds_moy_module_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, etuds_moy_module_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
# Application des évaluations bonus: # Application des évaluations bonus:
etuds_moy_module = self.apply_bonus( etuds_moy_module = self.apply_bonus(
etuds_moy_module, etuds_moy_module,
modimpl, modimpl,
evals_notes_20, evals_notes_20,
) )
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
notes_session2 = self.evals_notes[eval_session2.id].values
# n'utilise que les notes valides (pas ATT, EXC, ABS, NaN)
etuds_use_session2 = notes_session2 > scu.NOTES_ABSENCE
etuds_moy_module = np.where(
etuds_use_session2,
notes_session2 / (eval_session2.note_max / 20.0),
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
else:
# Rattrapage: remplace la note de module ssi elle est supérieure
eval_rat = self.get_evaluation_rattrapage(modimpl)
if eval_rat:
notes_rat = self.evals_notes[eval_rat.id].values
# remplace les notes invalides (ATT, EXC...) par des NaN
notes_rat = np.where(
notes_rat > scu.NOTES_ABSENCE,
notes_rat / (eval_rat.note_max / 20.0),
np.nan,
)
# prend le max
etuds_use_rattrapage = notes_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat, etuds_moy_module
)
self.etuds_use_rattrapage = pd.Series(
etuds_use_rattrapage, index=self.evals_notes.index
)
self.etuds_moy_module = pd.Series( self.etuds_moy_module = pd.Series(
etuds_moy_module, etuds_moy_module,
index=self.evals_notes.index, index=self.evals_notes.index,
@ -640,6 +684,28 @@ class ModuleImplResultsClassic(ModuleImplResults):
return self.etuds_moy_module return self.etuds_moy_module
def _compute_moy_special(
self, modimpl: ModuleImpl, evals_notes_20: np.array, evaluation_type: int
) -> np.array:
"""Calcul moyenne sur évals rattrapage ou session2"""
# n'utilise que les notes valides et ABS (0).
# Même calcul que pour les évals normales, mais avec seulement les
# coefs des évals de session 2 ou rattrapage:
nb_etuds = self.evals_notes.shape[0]
evals_coefs = self.get_evaluations_special_coefs(
modimpl, evaluation_type=evaluation_type
).reshape(-1)
coefs_stacked = np.stack([evals_coefs] * nb_etuds)
# zéro partout sauf si une note ou ABS:
evals_coefs_etuds = np.where(
self.evals_notes.values > scu.NOTES_NEUTRALISE, coefs_stacked, 0
)
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum(
evals_coefs_etuds * evals_notes_20, axis=1
) / np.sum(evals_coefs_etuds, axis=1)
return etuds_moy_module # array 1d (nb_etuds)
def apply_bonus( def apply_bonus(
self, self,
etuds_moy_module: np.ndarray, etuds_moy_module: np.ndarray,

View File

@ -183,7 +183,9 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray:
return modimpls_notes.swapaxes(0, 1) return modimpls_notes.swapaxes(0, 1)
def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: def notes_sem_load_cube(
formsemestre: FormSemestre, modimpl_coefs_df: pd.DataFrame
) -> tuple:
"""Construit le "cube" (tenseur) des notes du semestre. """Construit le "cube" (tenseur) des notes du semestre.
Charge toutes les notes (sql), calcule les moyennes des modules Charge toutes les notes (sql), calcule les moyennes des modules
et assemble le cube. et assemble le cube.
@ -207,8 +209,8 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
etudids, etudids_actifs = formsemestre.etudids_actifs() etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted: for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs) mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id) evals_poids = modimpl.get_evaluations_poids()
etuds_moy_module = mod_results.compute_module_moy(evals_poids) etuds_moy_module = mod_results.compute_module_moy(evals_poids, modimpl_coefs_df)
modimpls_results[modimpl.id] = mod_results modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module) modimpls_notes.append(etuds_moy_module)

View File

@ -59,16 +59,17 @@ class ResultatsSemestreBUT(NotesTableCompat):
def compute(self): def compute(self):
"Charge les notes et inscriptions et calcule les moyennes d'UE et gen." "Charge les notes et inscriptions et calcule les moyennes d'UE et gen."
self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
self.formsemestre, modimpls=self.formsemestre.modimpls_sorted
)
( (
self.sem_cube, self.sem_cube,
self.modimpls_evals_poids, self.modimpls_evals_poids,
self.modimpls_results, self.modimpls_results,
) = moy_ue.notes_sem_load_cube(self.formsemestre) ) = moy_ue.notes_sem_load_cube(self.formsemestre, self.modimpl_coefs_df)
self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre)
self.ues_inscr_parcours_df = self.load_ues_inscr_parcours() self.ues_inscr_parcours_df = self.load_ues_inscr_parcours()
self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs(
self.formsemestre, modimpls=self.formsemestre.modimpls_sorted
)
# l'idx de la colonne du mod modimpl.id est # l'idx de la colonne du mod modimpl.id est
# modimpl_coefs_df.columns.get_loc(modimpl.id) # modimpl_coefs_df.columns.get_loc(modimpl.id)
# idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id) # idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id)

View File

@ -242,7 +242,8 @@ class ResultatsSemestreClassic(NotesTableCompat):
) )
}">saisir le coefficient de cette UE avant de continuer</a></p> }">saisir le coefficient de cette UE avant de continuer</a></p>
</div> </div>
""" """,
safe=True,
) )

View File

@ -518,7 +518,8 @@ class ResultatsSemestre(ResultatsCache):
Corrigez ou faite corriger le programme Corrigez ou faite corriger le programme
<a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept, <a href="{url_for('notes.ue_table', scodoc_dept=g.scodoc_dept,
formation_id=ue_capitalized.formation_id)}">via cette page</a>. formation_id=ue_capitalized.formation_id)}">via cette page</a>.
""" """,
safe=True,
) )
else: else:
# Coefs de l'UE capitalisée en formation classique: # Coefs de l'UE capitalisée en formation classique:

View File

@ -9,9 +9,9 @@ import datetime
from threading import Thread from threading import Thread
from flask import current_app, g from flask import current_app, g
from flask_mail import Message from flask_mail import BadHeaderError, Message
from app import mail from app import log, mail
from app.models.departements import Departement from app.models.departements import Departement
from app.models.config import ScoDocSiteConfig from app.models.config import ScoDocSiteConfig
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
@ -20,7 +20,15 @@ from app.scodoc import sco_preferences
def send_async_email(app, msg): def send_async_email(app, msg):
"Send an email, async" "Send an email, async"
with app.app_context(): with app.app_context():
mail.send(msg) try:
mail.send(msg)
except BadHeaderError:
log(
f"""send_async_email: BadHeaderError
msg={msg}
"""
)
raise
def send_email( def send_email(

View File

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

View File

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

View File

@ -338,9 +338,11 @@ def add_entreprise():
if form.validate_on_submit(): if form.validate_on_submit():
entreprise = Entreprise( entreprise = Entreprise(
nom=form.nom_entreprise.data.strip(), nom=form.nom_entreprise.data.strip(),
siret=form.siret.data.strip() siret=(
if form.siret.data.strip() form.siret.data.strip()
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}", # siret provisoire if form.siret.data.strip()
else f"{SIRET_PROVISOIRE_START}{datetime.now().strftime('%d%m%y%H%M%S')}"
), # siret provisoire
siret_provisoire=False if form.siret.data.strip() else True, siret_provisoire=False if form.siret.data.strip() else True,
association=form.association.data, association=form.association.data,
adresse=form.adresse.data.strip(), adresse=form.adresse.data.strip(),
@ -352,7 +354,7 @@ def add_entreprise():
db.session.add(entreprise) db.session.add(entreprise)
db.session.commit() db.session.commit()
db.session.refresh(entreprise) db.session.refresh(entreprise)
except: except Exception:
db.session.rollback() db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.") flash("Une erreur est survenue veuillez réessayer.")
return render_template( return render_template(
@ -804,9 +806,9 @@ def add_offre(entreprise_id):
missions=form.missions.data.strip(), missions=form.missions.data.strip(),
duree=form.duree.data.strip(), duree=form.duree.data.strip(),
expiration_date=form.expiration_date.data, expiration_date=form.expiration_date.data,
correspondant_id=form.correspondant.data correspondant_id=(
if form.correspondant.data != "" form.correspondant.data if form.correspondant.data != "" else None
else None, ),
) )
db.session.add(offre) db.session.add(offre)
db.session.commit() db.session.commit()
@ -1328,9 +1330,11 @@ def add_contact(entreprise_id):
).first_or_404(description=f"entreprise {entreprise_id} inconnue") ).first_or_404(description=f"entreprise {entreprise_id} inconnue")
form = ContactCreationForm( form = ContactCreationForm(
date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}", date=f"{datetime.now().strftime('%Y-%m-%dT%H:%M')}",
utilisateur=f"{current_user.nom} {current_user.prenom} ({current_user.user_name})" utilisateur=(
if current_user.nom and current_user.prenom f"{current_user.nom} {current_user.prenom} ({current_user.user_name})"
else "", if current_user.nom and current_user.prenom
else ""
),
) )
if request.method == "POST" and form.cancel.data: if request.method == "POST" and form.cancel.data:
return redirect(url_for("entreprises.contacts", entreprise_id=entreprise_id)) return redirect(url_for("entreprises.contacts", entreprise_id=entreprise_id))
@ -1496,9 +1500,9 @@ def add_stage_apprentissage(entreprise_id):
date_debut=form.date_debut.data, date_debut=form.date_debut.data,
date_fin=form.date_fin.data, date_fin=form.date_fin.data,
formation_text=formation.formsemestre.titre if formation else None, formation_text=formation.formsemestre.titre if formation else None,
formation_scodoc=formation.formsemestre.formsemestre_id formation_scodoc=(
if formation formation.formsemestre.formsemestre_id if formation else None
else None, ),
notes=form.notes.data.strip(), notes=form.notes.data.strip(),
) )
db.session.add(stage_apprentissage) db.session.add(stage_apprentissage)
@ -1802,7 +1806,7 @@ def import_donnees():
db.session.add(entreprise) db.session.add(entreprise)
db.session.commit() db.session.commit()
db.session.refresh(entreprise) db.session.refresh(entreprise)
except: except Exception:
db.session.rollback() db.session.rollback()
flash("Une erreur est survenue veuillez réessayer.") flash("Une erreur est survenue veuillez réessayer.")
return render_template( return render_template(

View File

@ -62,6 +62,11 @@ class AjoutAssiOrJustForm(FlaskForm):
if field: if field:
field.errors.append(err_msg) field.errors.append(err_msg)
def disable_all(self):
"Disable all fields"
for field in self:
field.render_kw = {"disabled": True}
date_debut = StringField( date_debut = StringField(
"Date de début", "Date de début",
validators=[validators.Length(max=10)], validators=[validators.Length(max=10)],
@ -175,36 +180,3 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
validators=[DataRequired(message="This field is required.")], validators=[DataRequired(message="This field is required.")],
) )
fichiers = MultipleFileField(label="Ajouter des fichiers") fichiers = MultipleFileField(label="Ajouter des fichiers")
class ChoixDateForm(FlaskForm):
"""
Formulaire de choix de date
(utilisé par la page de choix de date
si la date courante n'est pas dans le semestre)
"""
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)
self.ok = True
self.error_messages: list[str] = [] # used to report our errors
def set_error(self, err_msg, field=None):
"Set error message both in form and field"
self.ok = False
self.error_messages.append(err_msg)
if field:
field.errors.append(err_msg)
date = StringField(
"Date",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "date",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -0,0 +1,122 @@
""" """
from flask_wtf import FlaskForm
from wtforms import (
StringField,
SelectField,
RadioField,
TextAreaField,
validators,
SubmitField,
)
from app.scodoc.sco_utils import EtatAssiduite
class EditAssiForm(FlaskForm):
"""
Formulaire de modification d'une assiduité
"""
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)
self.ok = True
self.error_messages: list[str] = [] # used to report our errors
def set_error(self, err_msg, field=None):
"Set error message both in form and field"
self.ok = False
self.error_messages.append(err_msg)
if field:
field.errors.append(err_msg)
def disable_all(self):
"Disable all fields"
for field in self:
field.render_kw = {"disabled": True}
assi_etat = RadioField(
"État:",
choices=[
(EtatAssiduite.ABSENT.value, EtatAssiduite.ABSENT.version_lisible()),
(EtatAssiduite.RETARD.value, EtatAssiduite.RETARD.version_lisible()),
(EtatAssiduite.PRESENT.value, EtatAssiduite.PRESENT.version_lisible()),
],
default="absent",
validators=[
validators.DataRequired("spécifiez le type d'évènement à signaler"),
],
)
modimpl = SelectField(
"Module",
choices={}, # will be populated dynamically
)
description = TextAreaField(
"Description",
render_kw={
"id": "description",
"cols": 75,
"rows": 4,
"maxlength": 500,
},
)
date_debut = StringField(
"Date de début",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "assi_date_debut",
},
)
heure_debut = StringField(
"Heure début",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_debut",
},
)
heure_fin = StringField(
"Heure fin",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_fin",
},
)
date_fin = StringField(
"Date de fin",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "assi_date_fin",
},
)
entry_date = StringField(
"Date de dépôt ou saisie",
validators=[validators.Length(max=10)],
render_kw={
"class": "datepicker",
"size": 10,
"id": "entry_date",
},
)
entry_time = StringField(
"Heure dépôt",
default="",
validators=[validators.Length(max=5)],
render_kw={
"class": "timepicker",
"size": 5,
"id": "assi_heure_fin",
},
)
submit = SubmitField("Enregistrer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

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

View File

@ -54,7 +54,6 @@ class BonusConfigurationForm(FlaskForm):
class ScoDocConfigurationForm(FlaskForm): class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée" "Panneau de configuration avancée"
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
disable_passerelle = BooleanField( # disable car par défaut activée disable_passerelle = BooleanField( # disable car par défaut activée
"""cacher les fonctions liées à une passerelle de publication des résultats vers les étudiants ("œil"). N'affecte pas l'API, juste la présentation.""" """cacher les fonctions liées à une passerelle de publication des résultats vers les étudiants ("œil"). N'affecte pas l'API, juste la présentation."""
) )
@ -127,13 +126,6 @@ def configuration():
flash("Fonction bonus inchangée.") flash("Fonction bonus inchangée.")
return redirect(url_for("scodoc.index")) return redirect(url_for("scodoc.index"))
elif form_scodoc.submit_scodoc.data and form_scodoc.validate(): elif form_scodoc.submit_scodoc.data and form_scodoc.validate():
if ScoDocSiteConfig.enable_entreprises(
enabled=form_scodoc.data["enable_entreprises"]
):
flash(
"Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
)
if ScoDocSiteConfig.disable_passerelle( if ScoDocSiteConfig.disable_passerelle(
disabled=form_scodoc.data["disable_passerelle"] disabled=form_scodoc.data["disable_passerelle"]
): ):
@ -182,6 +174,7 @@ def configuration():
return render_template( return render_template(
"configuration.j2", "configuration.j2",
is_entreprises_enabled=ScoDocSiteConfig.is_entreprises_enabled(),
form_bonus=form_bonus, form_bonus=form_bonus,
form_scodoc=form_scodoc, form_scodoc=form_scodoc,
scu=scu, scu=scu,

View File

@ -0,0 +1,66 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# ScoDoc
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""
Formulaire création de ticket de bug
"""
from flask_wtf import FlaskForm
from wtforms import SubmitField, validators
from wtforms.fields.simple import StringField, TextAreaField, BooleanField
from app.scodoc import sco_preferences
class CreateBugReport(FlaskForm):
"""Formulaire permettant la création d'un ticket de bug"""
title = StringField(
label="Titre du ticket",
validators=[
validators.DataRequired("titre du ticket requis"),
],
)
message = TextAreaField(
label="Message",
id="ticket_message",
validators=[
validators.DataRequired("message du ticket requis"),
],
)
etab = StringField(label="Etablissement")
include_dump = BooleanField(
"""Inclure une copie anonymisée de la base de données ?
Ces données faciliteront le traitement du problème et resteront strictement confidentielles.
""",
default=False,
)
submit = SubmitField("Envoyer")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
def __init__(self, *args, **kwargs):
super(CreateBugReport, self).__init__(*args, **kwargs)
self.etab.data = sco_preferences.get_preference("InstituteName") or ""

View File

@ -353,12 +353,22 @@ class Assiduite(ScoDocModel):
elif self.external_data is not None and "module" in self.external_data: elif self.external_data is not None and "module" in self.external_data:
return ( return (
"Tout module" "Autre module (pas dans la liste)"
if self.external_data["module"] == "Autre" if self.external_data["module"] == "Autre"
else self.external_data["module"] else self.external_data["module"]
) )
return "Non spécifié" if traduire else None return "Module non spécifié" if traduire else None
def get_moduleimpl_id(self) -> int | str | None:
"""
Retourne le ModuleImpl associé à l'assiduité
"""
if self.moduleimpl_id is not None:
return self.moduleimpl_id
if self.external_data is not None and "module" in self.external_data:
return self.external_data["module"]
return None
def get_saisie(self) -> str: def get_saisie(self) -> str:
""" """
@ -395,6 +405,14 @@ class Assiduite(ScoDocModel):
if force: if force:
raise ScoValueError("Module non renseigné") raise ScoValueError("Module non renseigné")
@classmethod
def get_assiduite(cls, assiduite_id: int) -> "Assiduite":
"""Assiduité ou 404, cherche uniquement dans le département courant"""
query = Assiduite.query.filter_by(id=assiduite_id)
if g.scodoc_dept:
query = query.join(Identite).filter_by(dept_id=g.scodoc_dept_id)
return query.first_or_404()
class Justificatif(ScoDocModel): class Justificatif(ScoDocModel):
""" """
@ -685,10 +703,14 @@ def is_period_conflicting(
date_fin: datetime, date_fin: datetime,
collection: Query, collection: Query,
collection_cls: Assiduite | Justificatif, collection_cls: Assiduite | Justificatif,
obj_id: int = -1,
) -> bool: ) -> bool:
""" """
Vérifie si une date n'entre pas en collision Vérifie si une date n'entre pas en collision
avec les justificatifs ou assiduites déjà présentes avec les justificatifs ou assiduites déjà présentes
On peut donner un objet_id pour exclure un objet de la vérification
(utile pour les modifications)
""" """
# On s'assure que les dates soient avec TimeZone # On s'assure que les dates soient avec TimeZone
@ -696,7 +718,9 @@ def is_period_conflicting(
date_fin = localize_datetime(date_fin) date_fin = localize_datetime(date_fin)
count: int = collection.filter( count: int = collection.filter(
collection_cls.date_debut < date_fin, collection_cls.date_fin > date_debut collection_cls.date_debut < date_fin,
collection_cls.date_fin > date_debut,
collection_cls.id != obj_id,
).count() ).count()
return count > 0 return count > 0

View File

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

View File

@ -113,6 +113,12 @@ class ApcValidationRCUE(db.Model):
"formsemestre_id": self.formsemestre_id, "formsemestre_id": self.formsemestre_id,
} }
def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée associés à cette validation RCUE.
Prend les codes des deux UEs
"""
return self.ue1.get_codes_apogee_rcue() | self.ue2.get_codes_apogee_rcue()
class ApcValidationAnnee(db.Model): class ApcValidationAnnee(db.Model):
"""Validation des années du BUT""" """Validation des années du BUT"""

View File

@ -199,6 +199,11 @@ class Identite(models.ScoDocModel):
@classmethod @classmethod
def get_etud(cls, etudid: int) -> "Identite": def get_etud(cls, etudid: int) -> "Identite":
"""Etudiant ou 404, cherche uniquement dans le département courant""" """Etudiant ou 404, cherche uniquement dans le département courant"""
if not isinstance(etudid, int):
try:
etudid = int(etudid)
except (TypeError, ValueError):
abort(404, "etudid invalide")
if g.scodoc_dept: if g.scodoc_dept:
return cls.query.filter_by( return cls.query.filter_by(
id=etudid, dept_id=g.scodoc_dept_id id=etudid, dept_id=g.scodoc_dept_id
@ -297,11 +302,12 @@ class Identite(models.ScoDocModel):
else: else:
return self.nom return self.nom
@cached_property @property
def nomprenom(self, reverse=False) -> str: def nomprenom(self, reverse=False) -> str:
"""Civilité/nom/prenom pour affichages: "M. Pierre Dupont" """DEPRECATED
Civilité/prénom/nom pour affichages: "M. Pierre Dupont"
Si reverse, "Dupont Pierre", sans civilité. Si reverse, "Dupont Pierre", sans civilité.
Prend l'identité courant et non celle de l'état civile si elles diffèrent. Prend l'identité courante et non celle de l'état civil si elles diffèrent.
""" """
nom = self.nom_usuel or self.nom nom = self.nom_usuel or self.nom
prenom = self.prenom_str prenom = self.prenom_str
@ -309,6 +315,12 @@ class Identite(models.ScoDocModel):
return f"{nom} {prenom}".strip() return f"{nom} {prenom}".strip()
return f"{self.civilite_str} {prenom} {nom}".strip() return f"{self.civilite_str} {prenom} {nom}".strip()
def nom_prenom(self) -> str:
"""Civilite NOM Prénom
Prend l'identité courante et non celle de l'état civil si elles diffèrent.
"""
return f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()} {self.prenom_str}"
@property @property
def prenom_str(self): def prenom_str(self):
"""Prénom à afficher. Par exemple: "Jean-Christophe" """ """Prénom à afficher. Par exemple: "Jean-Christophe" """
@ -347,14 +359,15 @@ class Identite(models.ScoDocModel):
"Le mail associé à la première adresse de l'étudiant, ou None" "Le mail associé à la première adresse de l'étudiant, ou None"
return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None return getattr(self.adresses[0], field) if self.adresses.count() > 0 else None
def get_formsemestres(self) -> list: def get_formsemestres(self, recent_first=True) -> list:
"""Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit, """Liste des formsemestres dans lesquels l'étudiant est (a été) inscrit,
triée par date_debut triée par date_debut, le plus récent d'abord (comme "sems" de scodoc7)
(si recent_first=False, le plus ancien en tête)
""" """
return sorted( return sorted(
[ins.formsemestre for ins in self.formsemestre_inscriptions], [ins.formsemestre for ins in self.formsemestre_inscriptions],
key=attrgetter("date_debut"), key=attrgetter("date_debut"),
reverse=True, reverse=recent_first,
) )
def get_modimpls_by_formsemestre( def get_modimpls_by_formsemestre(
@ -393,6 +406,18 @@ class Identite(models.ScoDocModel):
modimpls_by_formsemestre[formsemestre.id] = modimpls_sem modimpls_by_formsemestre[formsemestre.id] = modimpls_sem
return modimpls_by_formsemestre return modimpls_by_formsemestre
def get_modimpls_from_formsemestre(
self, formsemestre: "FormSemestre"
) -> list["ModuleImpl"]:
"""
Liste des ModuleImpl auxquels l'étudiant est inscrit dans le formsemestre.
"""
modimpls = ModuleImpl.query.join(ModuleImplInscription).filter(
ModuleImplInscription.etudid == self.id,
ModuleImpl.formsemestre_id == formsemestre.id,
)
return modimpls.all()
@classmethod @classmethod
def convert_dict_fields(cls, args: dict) -> dict: def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect. """Convert fields in the given dict. No other side effect.
@ -551,7 +576,7 @@ class Identite(models.ScoDocModel):
.all() .all()
) )
def inscription_courante(self): def inscription_courante(self) -> "FormSemestreInscription | None":
"""La première inscription à un formsemestre _actuellement_ en cours. """La première inscription à un formsemestre _actuellement_ en cours.
None s'il n'y en a pas (ou plus, ou pas encore). None s'il n'y en a pas (ou plus, ou pas encore).
""" """

View File

@ -71,6 +71,15 @@ class Evaluation(models.ScoDocModel):
EVALUATION_BONUS, EVALUATION_BONUS,
} }
def type_abbrev(self) -> str:
"Le nom abrégé du type de cette éval."
return {
self.EVALUATION_NORMALE: "std",
self.EVALUATION_RATTRAPAGE: "rattrapage",
self.EVALUATION_SESSION2: "session 2",
self.EVALUATION_BONUS: "bonus",
}.get(self.evaluation_type, "?")
def __repr__(self): def __repr__(self):
return f"""<Evaluation {self.id} { return f"""<Evaluation {self.id} {
self.date_debut.isoformat() if self.date_debut else ''} "{ self.date_debut.isoformat() if self.date_debut else ''} "{
@ -417,12 +426,13 @@ class Evaluation(models.ScoDocModel):
return modified return modified
def set_ue_poids(self, ue, poids: float) -> None: def set_ue_poids(self, ue, poids: float) -> None:
"""Set poids évaluation vers cette UE""" """Set poids évaluation vers cette UE. Commit."""
self.update_ue_poids_dict({ue.id: poids}) self.update_ue_poids_dict({ue.id: poids})
def set_ue_poids_dict(self, ue_poids_dict: dict) -> None: def set_ue_poids_dict(self, ue_poids_dict: dict) -> None:
"""set poids vers les UE (remplace existants) """set poids vers les UE (remplace existants)
ue_poids_dict = { ue_id : poids } ue_poids_dict = { ue_id : poids }
Commit session.
""" """
from app.models.ues import UniteEns from app.models.ues import UniteEns
@ -432,9 +442,12 @@ class Evaluation(models.ScoDocModel):
if ue is None: if ue is None:
raise ScoValueError("poids vers une UE inexistante") raise ScoValueError("poids vers une UE inexistante")
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids) ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
L.append(ue_poids)
db.session.add(ue_poids) db.session.add(ue_poids)
L.append(ue_poids)
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
db.session.commit()
self.moduleimpl.invalidate_evaluations_poids() # inval cache self.moduleimpl.invalidate_evaluations_poids() # inval cache
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None: def update_ue_poids_dict(self, ue_poids_dict: dict) -> None:

View File

@ -232,7 +232,9 @@ class ScolarNews(db.Model):
) )
# Transforme les URL en URL absolues # Transforme les URL en URL absolues
base = scu.ScoURL() base = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)[
: -len("/index_html")
]
txt = re.sub('href=/.*?"', 'href="' + base + "/", txt) txt = re.sub('href=/.*?"', 'href="' + base + "/", txt)
# Transforme les liens HTML en texte brut: '<a href="url">texte</a>' devient 'texte: url' # Transforme les liens HTML en texte brut: '<a href="url">texte</a>' devient 'texte: url'

View File

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

View File

@ -610,6 +610,41 @@ class FormSemestre(models.ScoDocModel):
) )
) )
@classmethod
def est_in_semestre_scolaire(
cls,
date_debut: datetime.date,
year=False,
periode=None,
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
) -> bool:
"""Vrai si la date_debut est dans la période indiquée (1,2,0)
du semestre `periode` de l'année scolaire indiquée
(ou, à défaut, de celle en cours).
La période utilise les même conventions que semset["sem_id"];
* 1 : première période
* 2 : deuxième période
* 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
)
"""
if not year:
year = scu.annee_scolaire()
# n'utilise pas le jour pivot
jour_pivot_annee = jour_pivot_periode = 1
# calcule l'année universitaire et la période
sem_annee, sem_periode = cls.comp_periode(
date_debut,
mois_pivot_annee,
mois_pivot_periode,
jour_pivot_annee,
jour_pivot_periode,
)
if periode is None or periode == 0:
return sem_annee == year
return sem_annee == year and sem_periode == periode
def est_terminal(self) -> bool: def est_terminal(self) -> bool:
"Vrai si dernier semestre de son cursus (ou formation mono-semestre)" "Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
return (self.semestre_id < 0) or ( return (self.semestre_id < 0) or (
@ -945,7 +980,7 @@ class FormSemestre(models.ScoDocModel):
ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
} }
@cached_property @property
def etuds_inscriptions(self) -> dict: def etuds_inscriptions(self) -> dict:
"""Map { etudid : inscription } (incluant DEM et DEF)""" """Map { etudid : inscription } (incluant DEM et DEF)"""
return {ins.etud.id: ins for ins in self.inscriptions} return {ins.etud.id: ins for ins in self.inscriptions}
@ -1225,9 +1260,17 @@ class FormSemestreEtape(db.Model):
"Etape False if code empty" "Etape False if code empty"
return self.etape_apo is not None and (len(self.etape_apo) > 0) return self.etape_apo is not None and (len(self.etape_apo) > 0)
def __eq__(self, other):
if isinstance(other, ApoEtapeVDI):
return self.as_apovdi() == other
return str(self) == str(other)
def __repr__(self): def __repr__(self):
return f"<Etape {self.id} apo={self.etape_apo!r}>" return f"<Etape {self.id} apo={self.etape_apo!r}>"
def __str__(self):
return self.etape_apo or ""
def as_apovdi(self) -> ApoEtapeVDI: def as_apovdi(self) -> ApoEtapeVDI:
return ApoEtapeVDI(self.etape_apo) return ApoEtapeVDI(self.etape_apo)
@ -1381,8 +1424,9 @@ class FormSemestreInscription(db.Model):
def __repr__(self): def __repr__(self):
return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={ return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
self.formsemestre_id} etat={self.etat} { self.formsemestre_id} (S{self.formsemestre.semestre_id}) etat={self.etat} {
('parcours='+str(self.parcour)) if self.parcour else ''}>""" ('parcours="'+str(self.parcour.code)+'"') if self.parcour else ''
} {('etape="'+self.etape+'"') if self.etape else ''}>"""
class NotesSemSet(db.Model): class NotesSemSet(db.Model):

View File

@ -6,6 +6,7 @@ from flask import abort, g
from flask_login import current_user from flask_login import current_user
from flask_sqlalchemy.query import Query from flask_sqlalchemy.query import Query
import app
from app import db from app import db
from app.auth.models import User from app.auth.models import User
from app.comp import df_cache from app.comp import df_cache
@ -78,7 +79,9 @@ class ModuleImpl(ScoDocModel):
] or self.module.get_edt_ids() ] or self.module.get_edt_ids()
def get_evaluations_poids(self) -> pd.DataFrame: def get_evaluations_poids(self) -> pd.DataFrame:
"""Les poids des évaluations vers les UE (accès via cache)""" """Les poids des évaluations vers les UEs (accès via cache redis).
Toutes les évaluations sont considérées (normales, bonus, rattr., etc.)
"""
evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id) evaluations_poids = df_cache.EvaluationsPoidsCache.get(self.id)
if evaluations_poids is None: if evaluations_poids is None:
from app.comp import moy_mod from app.comp import moy_mod
@ -108,20 +111,37 @@ class ModuleImpl(ScoDocModel):
"""Invalide poids cachés""" """Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id) df_cache.EvaluationsPoidsCache.delete(self.id)
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool: def check_apc_conformity(
"""true si les poids des évaluations du module permettent de satisfaire self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE
les coefficients du PN. ) -> bool:
"""true si les poids des évaluations du type indiqué (normales par défaut)
du module permettent de satisfaire les coefficients du PN.
""" """
# appelé par formsemestre_status, liste notes, et moduleimpl_status
if not self.module.formation.get_cursus().APC_SAE or ( if not self.module.formation.get_cursus().APC_SAE or (
self.module.module_type != scu.ModuleType.RESSOURCE self.module.module_type
and self.module.module_type != scu.ModuleType.SAE not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
): ):
return True # Non BUT, toujours conforme return True # Non BUT, toujours conforme
from app.comp import moy_mod from app.comp import moy_mod
mod_results = res.modimpls_results.get(self.id)
if mod_results is None:
app.critical_error("check_apc_conformity: err 1")
selected_evaluations_ids = [
eval_id
for eval_id, eval_type in mod_results.evals_type.items()
if eval_type == evaluation_type
]
if not selected_evaluations_ids:
return True # conforme si pas d'évaluations
selected_evaluations_poids = self.get_evaluations_poids().loc[
selected_evaluations_ids
]
return moy_mod.moduleimpl_is_conforme( return moy_mod.moduleimpl_is_conforme(
self, self,
self.get_evaluations_poids(), selected_evaluations_poids,
res.modimpl_coefs_df, res.modimpl_coefs_df,
) )

View File

@ -340,6 +340,21 @@ class Module(models.ScoDocModel):
# Liste seulement les coefs définis: # Liste seulement les coefs définis:
return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()] return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()]
def get_ue_coefs_descr(self) -> str:
"""Description des coefficients vers les UEs (APC)"""
coefs_descr = ", ".join(
[
f"{ue.acronyme}: {co}"
for ue, co in self.ue_coefs_list()
if isinstance(co, float) and co > 0
]
)
if coefs_descr:
descr = "Coefs: " + coefs_descr
else:
descr = "(pas de coefficients) "
return descr
def get_codes_apogee(self) -> set[str]: def get_codes_apogee(self) -> set[str]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2")""" """Les codes Apogée (codés en base comme "VRT1,VRT2")"""
if self.code_apogee: if self.code_apogee:

View File

@ -46,6 +46,8 @@ class UniteEns(models.ScoDocModel):
# coef UE, utilise seulement si l'option use_ue_coefs est activée: # coef UE, utilise seulement si l'option use_ue_coefs est activée:
coefficient = db.Column(db.Float) coefficient = db.Column(db.Float)
# id de l'élément Apogée du RCUE (utilisé pour les UEs de sem. pair du BUT)
code_apogee_rcue = db.Column(db.String(APO_CODE_STR_LEN))
# coef. pour le calcul de moyennes de RCUE. Par défaut, 1. # coef. pour le calcul de moyennes de RCUE. Par défaut, 1.
coef_rcue = db.Column(db.Float, nullable=False, default=1.0, server_default="1.0") coef_rcue = db.Column(db.Float, nullable=False, default=1.0, server_default="1.0")
@ -274,6 +276,12 @@ class UniteEns(models.ScoDocModel):
return {x.strip() for x in self.code_apogee.split(",") if x} return {x.strip() for x in self.code_apogee.split(",") if x}
return set() return set()
def get_codes_apogee_rcue(self) -> set[str]:
"""Les codes Apogée RCUE (codés en base comme "VRT1,VRT2")"""
if self.code_apogee_rcue:
return {x.strip() for x in self.code_apogee_rcue.split(",") if x}
return set()
def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]: def _parcours_niveaux_ids(self, parcours=list[ApcParcours]) -> set[int]:
"""set des ids de niveaux communs à tous les parcours listés""" """set des ids de niveaux communs à tous les parcours listés"""
return set.intersection( return set.intersection(
@ -409,6 +417,14 @@ class UniteEns(models.ScoDocModel):
Renvoie (True, "") si ok, sinon (False, error_message) Renvoie (True, "") si ok, sinon (False, error_message)
""" """
msg = "" msg = ""
# Safety check
if self.formation.referentiel_competence is None:
return False, "pas de référentiel de compétence"
# Si tous les parcours, aucun (tronc commun)
if {p.id for p in parcours} == {
p.id for p in self.formation.referentiel_competence.parcours
}:
parcours = []
# Le niveau est-il dans tous ces parcours ? Sinon, l'enlève # Le niveau est-il dans tous ces parcours ? Sinon, l'enlève
prev_niveau = self.niveau_competence prev_niveau = self.niveau_competence
if ( if (
@ -424,6 +440,7 @@ class UniteEns(models.ScoDocModel):
self.niveau_competence, parcours self.niveau_competence, parcours
) )
if not ok: if not ok:
self.formation.invalidate_cached_sems()
self.niveau_competence = prev_niveau # restore self.niveau_competence = prev_niveau # restore
return False, error_message return False, error_message

View File

@ -48,6 +48,7 @@ from typing import Any
from urllib.parse import urlparse, urlencode, parse_qs, urlunparse from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
import reportlab
from reportlab.platypus import Paragraph, Spacer from reportlab.platypus import Paragraph, Spacer
from reportlab.platypus import Table, KeepInFrame from reportlab.platypus import Table, KeepInFrame
from reportlab.lib.colors import Color from reportlab.lib.colors import Color
@ -175,6 +176,7 @@ class GenTable:
self.xml_link = xml_link self.xml_link = xml_link
# HTML parameters: # HTML parameters:
if not table_id: # random id if not table_id: # random id
log("Warning: GenTable() called without table_id")
self.table_id = "gt_" + str(random.randint(0, 1000000)) self.table_id = "gt_" + str(random.randint(0, 1000000))
else: else:
self.table_id = table_id self.table_id = table_id
@ -812,7 +814,10 @@ if __name__ == "__main__":
document, document,
) )
) )
document.build(objects) try:
document.build(objects)
except (ValueError, KeyError, reportlab.platypus.doctemplate.LayoutError) as exc:
raise ScoPDFFormatError(str(exc)) from exc
data = doc.getvalue() data = doc.getvalue()
with open("/tmp/gen_table.pdf", "wb") as f: with open("/tmp/gen_table.pdf", "wb") as f:
f.write(data) f.write(data)

View File

@ -25,12 +25,11 @@
# #
############################################################################## ##############################################################################
"""HTML Header/Footer for ScoDoc pages """HTML Header/Footer for ScoDoc pages"""
"""
import html import html
from flask import g, render_template from flask import g, render_template, url_for
from flask import request from flask import request
from flask_login import current_user from flask_login import current_user
@ -101,7 +100,7 @@ _HTML_BEGIN = f"""<!DOCTYPE html>
<script src="{scu.STATIC_DIR}/libjs/menu.js"></script> <script src="{scu.STATIC_DIR}/libjs/menu.js"></script>
<script src="{scu.STATIC_DIR}/libjs/bubble.js"></script> <script src="{scu.STATIC_DIR}/libjs/bubble.js"></script>
<script> <script>
window.onload=function(){{enableTooltips("gtrcontent")}}; window.onload=function(){{enableTooltips("gtrcontent"); enableTooltips("sidebar");}};
</script> </script>
<script src="{scu.STATIC_DIR}/jQuery/jquery.js"></script> <script src="{scu.STATIC_DIR}/jQuery/jquery.js"></script>
@ -163,7 +162,7 @@ def sco_header(
params = { params = {
"page_title": page_title or sco_version.SCONAME, "page_title": page_title or sco_version.SCONAME,
"no_side_bar": no_side_bar, "no_side_bar": no_side_bar,
"ScoURL": scu.ScoURL(), "ScoURL": url_for("scolar.index_html", scodoc_dept=g.scodoc_dept),
"encoding": scu.SCO_ENCODING, "encoding": scu.SCO_ENCODING,
"titrebandeau_mkup": "<td>" + titrebandeau + "</td>", "titrebandeau_mkup": "<td>" + titrebandeau + "</td>",
"authuser": current_user.user_name, "authuser": current_user.user_name,
@ -179,6 +178,7 @@ def sco_header(
H = [ H = [
"""<!DOCTYPE html><html lang="fr"> """<!DOCTYPE html><html lang="fr">
<!-- ScoDoc legacy -->
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<title>%(page_title)s</title> <title>%(page_title)s</title>
@ -217,9 +217,9 @@ def sco_header(
<script src="{scu.STATIC_DIR}/libjs/menu.js"></script> <script src="{scu.STATIC_DIR}/libjs/menu.js"></script>
<script src="{scu.STATIC_DIR}/libjs/bubble.js"></script> <script src="{scu.STATIC_DIR}/libjs/bubble.js"></script>
<script> <script>
window.onload=function(){{enableTooltips("gtrcontent")}}; window.onload=function(){{enableTooltips("gtrcontent"); enableTooltips("sidebar");}};
const SCO_URL="{scu.ScoURL()}"; const SCO_URL="{url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)}";
const SCO_TIMEZONE="{scu.TIME_ZONE}"; const SCO_TIMEZONE="{scu.TIME_ZONE}";
</script>""" </script>"""
) )

View File

@ -28,6 +28,7 @@
""" """
Génération de la "sidebar" (marge gauche des pages HTML) Génération de la "sidebar" (marge gauche des pages HTML)
""" """
from flask import render_template, url_for from flask import render_template, url_for
from flask import g, request from flask import g, request
from flask_login import current_user from flask_login import current_user
@ -102,25 +103,33 @@ def sidebar_common():
<a href="{home_link}" class="sidebar">Accueil</a> <br> <a href="{home_link}" class="sidebar">Accueil</a> <br>
<div id="authuser"><a id="authuserlink" href="{ <div id="authuser"><a id="authuserlink" href="{
url_for("users.user_info_page", url_for("users.user_info_page",
scodoc_dept=g.scodoc_dept, user_name=current_user.user_name) scodoc_dept=g.scodoc_dept, user_name=current_user.user_name)
}">{current_user.user_name}</a> }">{current_user.user_name}</a>
<br><a id="deconnectlink" href="{url_for("auth.logout")}">déconnexion</a> <br><a id="deconnectlink" href="{url_for("auth.logout")}">déconnexion</a>
</div> </div>
{sidebar_dept()} {sidebar_dept()}
<h2 class="insidebar">Scolarité</h2> <h2 class="insidebar">Scolarité</h2>
<a href="{scu.ScoURL()}" class="sidebar">Semestres</a> <br> <a href="{
<a href="{scu.NotesURL()}" class="sidebar">Formations</a> <br> url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)
}" class="sidebar">Semestres</a> <br>
<a href="{
url_for("notes.index_html", scodoc_dept=g.scodoc_dept)
}" class="sidebar">Formations</a> <br>
""" """
] ]
if current_user.has_permission(Permission.AbsChange): if current_user.has_permission(Permission.AbsChange):
H.append( H.append(
f""" <a href="{scu.AssiduitesURL()}" class="sidebar">Assiduité</a> <br> """ f""" <a href="{
url_for("assiduites.bilan_dept", scodoc_dept=g.scodoc_dept)
}" class="sidebar">Assiduité</a> <br> """
) )
if current_user.has_permission( if current_user.has_permission(
Permission.UsersAdmin Permission.UsersAdmin
) or current_user.has_permission(Permission.UsersView): ) or current_user.has_permission(Permission.UsersView):
H.append( H.append(
f"""<a href="{scu.UsersURL()}" class="sidebar">Utilisateurs</a> <br>""" f"""<a href="{
url_for("users.index_html", scodoc_dept=g.scodoc_dept)
}" class="sidebar">Utilisateurs</a> <br>"""
) )
if current_user.has_permission(Permission.EditPreferences): if current_user.has_permission(Permission.EditPreferences):
@ -141,7 +150,9 @@ def sidebar(etudid: int = None):
params = {} params = {}
H = [ H = [
f"""<div class="sidebar"> f"""
<!-- sidebar py -->
<div class="sidebar" id="sidebar">
{ sidebar_common() } { sidebar_common() }
<div class="box-chercheetud">Chercher étudiant:<br> <div class="box-chercheetud">Chercher étudiant:<br>
<form method="get" id="form-chercheetud" <form method="get" id="form-chercheetud"
@ -183,7 +194,7 @@ def sidebar(etudid: int = None):
formsemestre.date_debut.strftime(scu.DATE_FMT) formsemestre.date_debut.strftime(scu.DATE_FMT)
} au { } au {
formsemestre.date_fin.strftime(scu.DATE_FMT) formsemestre.date_fin.strftime(scu.DATE_FMT)
}">({ }" data-tooltip>({
sco_preferences.get_preference("assi_metrique", None)}) sco_preferences.get_preference("assi_metrique", None)})
<br>{nbabsjust:1g} J., {nbabsnj:1g} N.J.</span>""" <br>{nbabsjust:1g} J., {nbabsnj:1g} N.J.</span>"""
) )
@ -217,12 +228,9 @@ def sidebar(etudid: int = None):
<li><a href="{ url_for('assiduites.calendrier_assi_etud', <li><a href="{ url_for('assiduites.calendrier_assi_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid) scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Calendrier</a></li> }">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', <li><a href="{ url_for('assiduites.bilan_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid) scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Bilan</a></li> }" title="Les pages bilan et liste ont été fusionnées">Liste/Bilan</a></li>
</ul> </ul>
""" """
) )

View File

@ -157,5 +157,6 @@ def table_billets(
rows=rows, rows=rows,
html_sortable=True, html_sortable=True,
html_class="table_leftalign", html_class="table_leftalign",
table_id="table_billets",
) )
return tab return tab

View File

@ -288,6 +288,7 @@ def apo_table_compare_etud_results(A, B):
html_class="table_leftalign", html_class="table_leftalign",
html_with_td_classes=True, html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
table_id="apo_table_compare_etud_results",
) )
return T return T

View File

@ -43,14 +43,13 @@ import re
import time import time
from zipfile import ZipFile from zipfile import ZipFile
from flask import send_file from flask import g, send_file
import numpy as np import numpy as np
from app import log from app import log
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.comp.res_but import ResultatsSemestreBUT
from app.models import ( from app.models import (
ApcValidationAnnee, ApcValidationAnnee,
ApcValidationRCUE, ApcValidationRCUE,
@ -79,7 +78,6 @@ from app.scodoc.codes_cursus import (
) )
from app.scodoc import sco_cursus from app.scodoc import sco_cursus
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_etud
def _apo_fmt_note(note, fmt="%3.2f"): def _apo_fmt_note(note, fmt="%3.2f"):
@ -99,7 +97,7 @@ class EtuCol:
"""Valeurs colonnes d'un element pour un etudiant""" """Valeurs colonnes d'un element pour un etudiant"""
def __init__(self, nip, apo_elt, init_vals): def __init__(self, nip, apo_elt, init_vals):
pass # XXX pass
ETUD_OK = "ok" ETUD_OK = "ok"
@ -132,7 +130,7 @@ class ApoEtud(dict):
"Vrai si BUT" "Vrai si BUT"
self.col_elts = {} self.col_elts = {}
"{'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}" "{'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}"
self.etud: Identite = None self.etud: Identite | None = None
"etudiant ScoDoc associé" "etudiant ScoDoc associé"
self.etat = None # ETUD_OK, ... self.etat = None # ETUD_OK, ...
self.is_nar = False self.is_nar = False
@ -150,9 +148,9 @@ class ApoEtud(dict):
_apo_fmt_note, fmt=ScoDocSiteConfig.get_code_apo("NOTES_FMT") or "3.2f" _apo_fmt_note, fmt=ScoDocSiteConfig.get_code_apo("NOTES_FMT") or "3.2f"
) )
# Initialisés par associate_sco: # Initialisés par associate_sco:
self.autre_sem: dict = None self.autre_formsemestre: FormSemestre = None
self.autre_res: NotesTableCompat = None self.autre_res: NotesTableCompat = None
self.cur_sem: dict = None self.cur_formsemestre: FormSemestre = None
self.cur_res: NotesTableCompat = None self.cur_res: NotesTableCompat = None
self.new_cols = {} self.new_cols = {}
"{ col_id : value to record in csv }" "{ col_id : value to record in csv }"
@ -171,24 +169,18 @@ class ApoEtud(dict):
Sinon, cherche le semestre, et met l'état à ETUD_OK ou ETUD_NON_INSCRIT. Sinon, cherche le semestre, et met l'état à ETUD_OK ou ETUD_NON_INSCRIT.
""" """
# futur: #WIP self.etud = Identite.query.filter_by(
# etud: Identite = Identite.query.filter_by(code_nip=self["nip"], dept_id=g.scodoc_dept_id).first() code_nip=self["nip"], dept_id=g.scodoc_dept_id
# self.etud = etud ).first()
etuds = sco_etud.get_etud_info(code_nip=self["nip"], filled=True) if not self.etud:
if not etuds:
# pas dans ScoDoc # pas dans ScoDoc
self.etud = None
self.log.append("non inscrit dans ScoDoc") self.log.append("non inscrit dans ScoDoc")
self.etat = ETUD_ORPHELIN self.etat = ETUD_ORPHELIN
else: else:
# futur: #WIP
# formsemestre_ids = {
# ins.formsemestre_id for ins in etud.formsemestre_inscriptions
# }
# in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids)
self.etud = etuds[0]
# cherche le semestre ScoDoc correspondant à l'un de ceux de l'etape: # cherche le semestre ScoDoc correspondant à l'un de ceux de l'etape:
formsemestre_ids = {s["formsemestre_id"] for s in self.etud["sems"]} formsemestre_ids = {
ins.formsemestre_id for ins in self.etud.formsemestre_inscriptions
}
in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids) in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids)
if not in_formsemestre_ids: if not in_formsemestre_ids:
self.log.append( self.log.append(
@ -228,7 +220,8 @@ class ApoEtud(dict):
self.new_cols[col_id] = self.cols[col_id] self.new_cols[col_id] = self.cols[col_id]
except KeyError as exc: except KeyError as exc:
raise ScoFormatError( raise ScoFormatError(
f"""Fichier Apogee invalide : ligne mal formatée ? <br>colonne <tt>{col_id}</tt> non déclarée ?""" f"""Fichier Apogee invalide : ligne mal formatée ? <br>colonne <tt>{
col_id}</tt> non déclarée ?"""
) from exc ) from exc
else: else:
try: try:
@ -254,7 +247,7 @@ class ApoEtud(dict):
# codes = set([apo_data.apo_csv.cols[col_id].code for col_id in apo_data.apo_csv.col_ids]) # codes = set([apo_data.apo_csv.cols[col_id].code for col_id in apo_data.apo_csv.col_ids])
# return codes - set(sco_elts) # return codes - set(sco_elts)
def search_elt_in_sem(self, code, sem) -> dict: def search_elt_in_sem(self, code: str, sem: dict) -> dict:
""" """
VET code jury etape (en BUT, le code annuel) VET code jury etape (en BUT, le code annuel)
ELP élément pédagogique: UE, module ELP élément pédagogique: UE, module
@ -267,13 +260,17 @@ class ApoEtud(dict):
Args: Args:
code (str): code apo de l'element cherché code (str): code apo de l'element cherché
sem (dict): semestre dans lequel on cherche l'élément sem (dict): semestre dans lequel on cherche l'élément
cur_sem (dict): semestre "courant" pour résultats annuels (VET)
autre_sem (dict): autre semestre utilisé pour calculer les résultats annuels (VET) Utilise notamment:
cur_formsemestre : semestre "courant" pour résultats annuels (VET)
autre_formsemestre : autre formsemestre utilisé pour les résultats annuels (VET)
Returns: Returns:
dict: with N, B, J, R keys, ou None si elt non trouvé dict: with N, B, J, R keys, ou None si elt non trouvé
""" """
etudid = self.etud["etudid"] if not self.etud:
return None
etudid = self.etud.id
if not self.cur_res: if not self.cur_res:
log("search_elt_in_sem: no cur_res !") log("search_elt_in_sem: no cur_res !")
return None return None
@ -316,10 +313,10 @@ class ApoEtud(dict):
code in {x.strip() for x in sem["elt_annee_apo"].split(",")} code in {x.strip() for x in sem["elt_annee_apo"].split(",")}
): ):
export_res_etape = self.export_res_etape export_res_etape = self.export_res_etape
if (not export_res_etape) and self.cur_sem: if (not export_res_etape) and self.cur_formsemestre:
# exporte toujours le résultat de l'étape si l'étudiant est diplômé # exporte toujours le résultat de l'étape si l'étudiant est diplômé
Se = sco_cursus.get_situation_etud_cursus( Se = sco_cursus.get_situation_etud_cursus(
self.etud, self.cur_sem["formsemestre_id"] self.etud, self.cur_formsemestre.id
) )
export_res_etape = Se.all_other_validated() export_res_etape = Se.all_other_validated()
@ -377,6 +374,20 @@ class ApoEtud(dict):
if module_code_found: if module_code_found:
return VOID_APO_RES return VOID_APO_RES
# RCUE du BUT
if res.is_apc:
for val_rcue in ApcValidationRCUE.query.filter_by(
etudid=etudid, formsemestre_id=sem["formsemestre_id"]
):
if code in val_rcue.get_codes_apogee():
return dict(
N="", # n'exporte pas de moyenne RCUE
B=20,
J="",
R=ScoDocSiteConfig.get_code_apo(val_rcue.code),
M="",
)
# #
return None # element Apogee non trouvé dans ce semestre return None # element Apogee non trouvé dans ce semestre
@ -418,11 +429,10 @@ class ApoEtud(dict):
# #
# XXX cette règle est discutable, à valider # XXX cette règle est discutable, à valider
# log('comp_elt_annuel cur_sem=%s autre_sem=%s' % (cur_sem['formsemestre_id'], autre_sem['formsemestre_id'])) if not self.cur_formsemestre:
if not self.cur_sem:
# l'étudiant n'a pas de semestre courant ?! # l'étudiant n'a pas de semestre courant ?!
self.log.append("pas de semestre courant") self.log.append("pas de semestre courant")
log(f"comp_elt_annuel: etudid {etudid} has no cur_sem") log(f"comp_elt_annuel: etudid {etudid} has no cur_formsemestre")
return VOID_APO_RES return VOID_APO_RES
if self.is_apc: if self.is_apc:
@ -438,7 +448,7 @@ class ApoEtud(dict):
# ne touche pas aux RATs # ne touche pas aux RATs
return VOID_APO_RES return VOID_APO_RES
if not self.autre_sem: if not self.autre_formsemestre:
# formations monosemestre, ou code VET semestriel, # formations monosemestre, ou code VET semestriel,
# ou jury intermediaire et etudiant non redoublant... # ou jury intermediaire et etudiant non redoublant...
return self.comp_elt_semestre(self.cur_res, cur_decision, etudid) return self.comp_elt_semestre(self.cur_res, cur_decision, etudid)
@ -518,7 +528,7 @@ class ApoEtud(dict):
self.validation_annee_but: ApcValidationAnnee = ( self.validation_annee_but: ApcValidationAnnee = (
ApcValidationAnnee.query.filter_by( ApcValidationAnnee.query.filter_by(
formsemestre_id=formsemestre.id, formsemestre_id=formsemestre.id,
etudid=self.etud["etudid"], etudid=self.etud.id,
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id, referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
).first() ).first()
) )
@ -527,7 +537,7 @@ class ApoEtud(dict):
) )
def etud_set_semestres_de_etape(self, apo_data: "ApoData"): def etud_set_semestres_de_etape(self, apo_data: "ApoData"):
"""Set .cur_sem and .autre_sem et charge les résultats. """Set .cur_formsemestre and .autre_formsemestre et charge les résultats.
Lorsqu'on a une formation semestrialisée mais avec un code étape annuel, Lorsqu'on a une formation semestrialisée mais avec un code étape annuel,
il faut considérer les deux semestres ((S1,S2) ou (S3,S4)) pour calculer il faut considérer les deux semestres ((S1,S2) ou (S3,S4)) pour calculer
le code annuel (VET ou VRT1A (voir elt_annee_apo)). le code annuel (VET ou VRT1A (voir elt_annee_apo)).
@ -535,52 +545,49 @@ class ApoEtud(dict):
Pour les jurys intermediaires (janvier, S1 ou S3): (S2 ou S4) de la même Pour les jurys intermediaires (janvier, S1 ou S3): (S2 ou S4) de la même
étape lors d'une année précédente ? étape lors d'une année précédente ?
Set cur_sem: le semestre "courant" et autre_sem, ou None s'il n'y en a pas. Set cur_formsemestre: le formsemestre "courant"
et autre_formsemestre, ou None s'il n'y en a pas.
""" """
# Cherche le semestre "courant": # Cherche le formsemestre "courant":
cur_sems = [ cur_formsemestres = [
sem formsemestre
for sem in self.etud["sems"] for formsemestre in self.etud.get_formsemestres()
if ( if (
(sem["semestre_id"] == apo_data.cur_semestre_id) (formsemestre.semestre_id == apo_data.cur_semestre_id)
and (apo_data.etape in sem["etapes"]) and (apo_data.etape in formsemestre.etapes)
and ( and (
sco_formsemestre.sem_in_semestre_scolaire( FormSemestre.est_in_semestre_scolaire(
sem, formsemestre.date_debut,
apo_data.annee_scolaire, apo_data.annee_scolaire,
0, # annee complete 0, # annee complete
) )
) )
) )
] ]
if not cur_sems: cur_formsemestre = None
cur_sem = None if cur_formsemestres:
else: # prend le plus récent avec décision
# prend le plus recent avec decision for formsemestre in cur_formsemestres:
cur_sem = None
for sem in cur_sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
has_decision = res.etud_has_decision(self.etud["etudid"]) has_decision = res.etud_has_decision(self.etud.id)
if has_decision: if has_decision:
cur_sem = sem cur_formsemestre = formsemestre
self.cur_res = res self.cur_res = res
break break
if cur_sem is None: if cur_formsemestres is None:
cur_sem = cur_sems[0] # aucun avec décision, prend le plus recent cur_formsemestre = cur_formsemestres[
if res.formsemestre.id == cur_sem["formsemestre_id"]: 0
] # aucun avec décision, prend le plus recent
if res.formsemestre.id == cur_formsemestre.id:
self.cur_res = res self.cur_res = res
else: else:
formsemestre = FormSemestre.query.get_or_404( self.cur_res = res_sem.load_formsemestre_results(cur_formsemestre)
cur_sem["formsemestre_id"]
)
self.cur_res = res_sem.load_formsemestre_results(formsemestre)
self.cur_sem = cur_sem self.cur_formsemestre = cur_formsemestre
if apo_data.cur_semestre_id <= 0: if apo_data.cur_semestre_id <= 0:
# "autre_sem" non pertinent pour sessions sans semestres: # autre_formsemestre non pertinent pour sessions sans semestres:
self.autre_sem = None self.autre_formsemestre = None
self.autre_res = None self.autre_res = None
return return
@ -601,52 +608,49 @@ class ApoEtud(dict):
courant_mois_debut = 1 # ou 2 (fev-jul) courant_mois_debut = 1 # ou 2 (fev-jul)
else: else:
raise ValueError("invalid periode value !") # bug ? raise ValueError("invalid periode value !") # bug ?
courant_date_debut = "%d-%02d-01" % ( courant_date_debut = datetime.date(
courant_annee_debut, day=1, month=courant_mois_debut, year=courant_annee_debut
courant_mois_debut,
) )
else: else:
courant_date_debut = "9999-99-99" courant_date_debut = datetime.date(day=31, month=12, year=9999)
# etud['sems'] est la liste des semestres de l'étudiant, triés par date,
# le plus récemment effectué en tête.
# Cherche les semestres (antérieurs) de l'indice autre de la même étape apogée # Cherche les semestres (antérieurs) de l'indice autre de la même étape apogée
# s'il y en a plusieurs, choisit le plus récent ayant une décision # s'il y en a plusieurs, choisit le plus récent ayant une décision
autres_sems = [] autres_sems = []
for sem in self.etud["sems"]: for formsemestre in self.etud.get_formsemestres():
if ( if (
sem["semestre_id"] == autre_semestre_id formsemestre.semestre_id == autre_semestre_id
and apo_data.etape_apogee in sem["etapes"] and apo_data.etape_apogee in formsemestre.etapes
): ):
if ( if (
sem["date_debut_iso"] < courant_date_debut formsemestre.date_debut < courant_date_debut
): # on demande juste qu'il ait démarré avant ): # on demande juste qu'il ait démarré avant
autres_sems.append(sem) autres_sems.append(formsemestre)
if not autres_sems: if not autres_sems:
autre_sem = None autre_formsemestre = None
elif len(autres_sems) == 1: elif len(autres_sems) == 1:
autre_sem = autres_sems[0] autre_formsemestre = autres_sems[0]
else: else:
autre_sem = None autre_formsemestre = None
for sem in autres_sems: for formsemestre in autres_sems:
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) res: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if res.is_apc: if res.is_apc:
has_decision = res.etud_has_decision(self.etud["etudid"]) has_decision = res.etud_has_decision(self.etud.id)
else: else:
has_decision = res.get_etud_decision_sem(self.etud["etudid"]) has_decision = res.get_etud_decision_sem(self.etud.id)
if has_decision: if has_decision:
autre_sem = sem autre_formsemestre = formsemestre
break break
if autre_sem is None: if autre_formsemestre is None:
autre_sem = autres_sems[0] # aucun avec decision, prend le plus recent autre_formsemestre = autres_sems[
0
] # aucun avec decision, prend le plus recent
self.autre_sem = autre_sem self.autre_formsemestre = autre_formsemestre
# Charge les résultats: # Charge les résultats:
if autre_sem: if autre_formsemestre:
formsemestre = FormSemestre.query.get_or_404(autre_sem["formsemestre_id"]) self.autre_res = res_sem.load_formsemestre_results(self.autre_formsemestre)
self.autre_res = res_sem.load_formsemestre_results(formsemestre)
else: else:
self.autre_res = None self.autre_res = None
@ -873,6 +877,16 @@ class ApoData:
codes_ues = set().union( codes_ues = set().union(
*[ue.get_codes_apogee() for ue in formsemestre.get_ues(with_sport=True)] *[ue.get_codes_apogee() for ue in formsemestre.get_ues(with_sport=True)]
) )
codes_rcues = (
set().union(
*[
ue.get_codes_apogee_rcue()
for ue in formsemestre.get_ues(with_sport=True)
]
)
if self.is_apc
else set()
)
s = set() s = set()
codes_by_sem[sem["formsemestre_id"]] = s codes_by_sem[sem["formsemestre_id"]] = s
for col_id in self.apo_csv.col_ids[4:]: for col_id in self.apo_csv.col_ids[4:]:
@ -885,9 +899,14 @@ class ApoData:
if code in codes_ues: if code in codes_ues:
s.add(code) s.add(code)
continue continue
# associé à un RCUE BUT
if code in codes_rcues:
s.add(code)
continue
# associé à un module: # associé à un module:
if code in codes_modules: if code in codes_modules:
s.add(code) s.add(code)
# log('codes_by_sem=%s' % pprint.pformat(codes_by_sem)) # log('codes_by_sem=%s' % pprint.pformat(codes_by_sem))
return codes_by_sem return codes_by_sem
@ -917,6 +936,7 @@ class ApoData:
columns_ids=columns_ids, columns_ids=columns_ids,
titles=dict(zip(columns_ids, columns_ids)), titles=dict(zip(columns_ids, columns_ids)),
rows=rows, rows=rows,
table_id="build_cr_table",
xls_sheet_name="Decisions ScoDoc", xls_sheet_name="Decisions ScoDoc",
) )
return T return T
@ -969,6 +989,7 @@ class ApoData:
"rcue": "RCUE", "rcue": "RCUE",
}, },
rows=rows, rows=rows,
table_id="adsup_table",
xls_sheet_name="ADSUPs", xls_sheet_name="ADSUPs",
) )
@ -1054,6 +1075,7 @@ def nar_etuds_table(apo_data, nar_etuds):
columns_ids=columns_ids, columns_ids=columns_ids,
titles=dict(zip(columns_ids, columns_ids)), titles=dict(zip(columns_ids, columns_ids)),
rows=rows, rows=rows,
table_id="nar_etuds_table",
xls_sheet_name="NAR ScoDoc", xls_sheet_name="NAR ScoDoc",
) )
return table.excel() return table.excel()

View File

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

View File

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

View File

@ -751,7 +751,7 @@ def formsemestre_get_assiduites_count(
) -> tuple[int, int, int]: ) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre: """Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non justifiées, nb abs justifiées, nb abs total) tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
Utilise un cache. Utilise un cache (si moduleimpl_id n'est pas spécifié).
""" """
metrique = sco_preferences.get_preference("assi_metrique", formsemestre.id) metrique = sco_preferences.get_preference("assi_metrique", formsemestre.id)
return get_assiduites_count_in_interval( return get_assiduites_count_in_interval(
@ -779,7 +779,7 @@ def get_assiduites_count_in_interval(
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses: """Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs non justifiées, nb abs justifiées, nb abs total) tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
On peut spécifier les dates comme datetime ou iso. On peut spécifier les dates comme datetime ou iso.
Utilise un cache. Utilise un cache (si moduleimpl_id n'est pas spécifié).
""" """
date_debut_iso = date_debut_iso or date_debut.strftime("%Y-%m-%d") date_debut_iso = date_debut_iso or date_debut.strftime("%Y-%m-%d")
date_fin_iso = date_fin_iso or date_fin.strftime("%Y-%m-%d") date_fin_iso = date_fin_iso or date_fin.strftime("%Y-%m-%d")

View File

@ -0,0 +1,102 @@
# -*- mode: python -*-
# -*- coding: utf-8 -*-
##############################################################################
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
#
# Emmanuel Viennet emmanuel.viennet@viennet.net
#
##############################################################################
"""Rapport de bug ScoDoc
Permet de créer un rapport de bug (ticket) sur la plateforme git scodoc.org.
Le principe est le suivant:
1- Si l'utilisateur le demande, on dump la base de données et on l'envoie
2- ScoDoc envoie une requête POST à scodoc.org pour qu'un ticket git soit créé avec les
informations fournies par l'utilisateur + quelques métadonnées.
"""
from flask import g
from flask_login import current_user
import requests
import app.scodoc.sco_utils as scu
import sco_version
from app import log
from app.scodoc.sco_dump_db import sco_dump_and_send_db
from app.scodoc.sco_exceptions import ScoValueError
def sco_bug_report(
title: str = "", message: str = "", etab: str = "", include_dump: bool = False
) -> requests.Response:
"""Envoi d'un bug report (ticket)"""
dump_id = None
if include_dump:
dump = sco_dump_and_send_db()
try:
dump_id = dump.json()["dump_id"]
except (requests.exceptions.JSONDecodeError, KeyError):
dump_id = "inconnu (erreur)"
log(f"sco_bug_report: {scu.SCO_BUG_REPORT_URL} by {current_user.user_name}")
try:
r = requests.post(
scu.SCO_BUG_REPORT_URL,
json={
"ticket": {
"title": title,
"message": message,
"etab": etab,
"dept": getattr(g, "scodoc_dept", "-"),
},
"user": {
"name": current_user.get_nomcomplet(),
"email": current_user.email,
},
"dump": {
"included": include_dump,
"id": dump_id,
},
"scodoc": {
"version": sco_version.SCOVERSION,
},
},
timeout=scu.SCO_ORG_TIMEOUT,
)
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc:
log("ConnectionError: Impossible de joindre le serveur d'assistance")
raise ScoValueError(
"""
Impossible de joindre le serveur d'assistance (scodoc.org).
Veuillez contacter le service informatique de votre établissement pour
corriger la configuration de ScoDoc. Dans la plupart des cas, il
s'agit d'un proxy mal configuré.
"""
) from exc
return r

View File

@ -446,7 +446,8 @@ def _ue_mod_bulletin(
): ):
"""Infos sur les modules (et évaluations) dans une UE """Infos sur les modules (et évaluations) dans une UE
(ajoute les informations aux modimpls) (ajoute les informations aux modimpls)
Result: liste de modules de l'UE avec les infos dans chacun (seulement ceux où l'étudiant est inscrit). Result: liste de modules de l'UE avec les infos dans chacun (seulement
ceux l'étudiant est inscrit).
""" """
bul_show_mod_rangs = sco_preferences.get_preference( bul_show_mod_rangs = sco_preferences.get_preference(
"bul_show_mod_rangs", formsemestre_id "bul_show_mod_rangs", formsemestre_id

View File

@ -61,7 +61,7 @@ from flask_login import current_user
from app.models import FormSemestre, Identite, ScoDocSiteConfig from app.models import FormSemestre, Identite, ScoDocSiteConfig
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import NoteProcessError from app.scodoc.sco_exceptions import NoteProcessError, ScoPDFFormatError
from app import log from app import log
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_pdf from app.scodoc import sco_pdf
@ -226,9 +226,18 @@ class BulletinGenerator:
server_name=self.server_name, server_name=self.server_name,
filigranne=self.filigranne, filigranne=self.filigranne,
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
with_page_numbers=self.multi_pages,
) )
) )
document.build(story) try:
document.build(story)
except (
ValueError,
KeyError,
reportlab.platypus.doctemplate.LayoutError,
) as exc:
raise ScoPDFFormatError(str(exc)) from exc
data = report.getvalue() data = report.getvalue()
return data return data

View File

@ -106,6 +106,7 @@ def assemble_bulletins_pdf(
pagesbookmarks=pagesbookmarks, pagesbookmarks=pagesbookmarks,
filigranne=filigranne, filigranne=filigranne,
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
with_page_numbers=False, # on ne veut pas de no de pages sur les bulletins imprimés en masse
) )
) )
document.multiBuild(story) document.multiBuild(story)
@ -122,7 +123,8 @@ def replacement_function(match) -> str:
return r'<img %s src="%s"%s/>' % (match.group(2), logo.filepath, match.group(4)) return r'<img %s src="%s"%s/>' % (match.group(2), logo.filepath, match.group(4))
raise ScoValueError( raise ScoValueError(
'balise "%s": logo "%s" introuvable' 'balise "%s": logo "%s" introuvable'
% (pydoc.html.escape(balise), pydoc.html.escape(name)) % (pydoc.html.escape(balise), pydoc.html.escape(name)),
safe=True,
) )

View File

@ -114,6 +114,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
html_class="notes_bulletin", html_class="notes_bulletin",
html_class_ignore_default=True, html_class_ignore_default=True,
html_with_td_classes=True, html_with_td_classes=True,
table_id="std_bul_table",
) )
return T.gen(fmt=fmt) return T.gen(fmt=fmt)

View File

@ -55,7 +55,6 @@ from flask import g
import app import app
from app import db, log from app import db, log
from app.scodoc import notesdb as ndb
from app.scodoc import sco_utils as scu from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException from app.scodoc.sco_exceptions import ScoException
@ -174,17 +173,15 @@ class EvaluationCache(ScoDocCache):
@classmethod @classmethod
def invalidate_all_sems(cls): def invalidate_all_sems(cls):
"delete all evaluations in current dept from cache" "delete all evaluations in current dept from cache"
from app.models.evaluations import Evaluation
from app.models.formsemestre import FormSemestre
from app.models.moduleimpls import ModuleImpl
evaluation_ids = [ evaluation_ids = [
x[0] e.id
for x in ndb.SimpleQuery( for e in Evaluation.query.join(ModuleImpl)
"""SELECT e.id .join(FormSemestre)
FROM notes_evaluation e, notes_moduleimpl mi, notes_formsemestre s .filter_by(dept_id=g.scodoc_dept_id)
WHERE s.dept_id=%(dept_id)s
AND s.id = mi.formsemestre_id
AND mi.id = e.moduleimpl_id;
""",
{"dept_id": g.scodoc_dept_id},
)
] ]
cls.delete_many(evaluation_ids) cls.delete_many(evaluation_ids)
@ -277,6 +274,7 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
"""expire cache pour un semestre (ou tous ceux du département si formsemestre_id non spécifié). """expire cache pour un semestre (ou tous ceux du département si formsemestre_id non spécifié).
Si pdfonly, n'expire que les bulletins pdf cachés. Si pdfonly, n'expire que les bulletins pdf cachés.
""" """
from app.comp import df_cache
from app.models.formsemestre import FormSemestre from app.models.formsemestre import FormSemestre
from app.scodoc import sco_cursus from app.scodoc import sco_cursus
@ -318,12 +316,14 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa
and fid in g.formsemestre_results_cache and fid in g.formsemestre_results_cache
): ):
del g.formsemestre_results_cache[fid] del g.formsemestre_results_cache[fid]
df_cache.EvaluationsPoidsCache.invalidate_sem(formsemestre_id)
else: else:
# optimization when we invalidate all evaluations: # optimization when we invalidate all evaluations:
EvaluationCache.invalidate_all_sems() EvaluationCache.invalidate_all_sems()
df_cache.EvaluationsPoidsCache.invalidate_all()
if hasattr(g, "formsemestre_results_cache"): if hasattr(g, "formsemestre_results_cache"):
del g.formsemestre_results_cache del g.formsemestre_results_cache
SemInscriptionsCache.delete_many(formsemestre_ids) SemInscriptionsCache.delete_many(formsemestre_ids)
ResultatsSemestreCache.delete_many(formsemestre_ids) ResultatsSemestreCache.delete_many(formsemestre_ids)
ValidationsSemestreCache.delete_many(formsemestre_ids) ValidationsSemestreCache.delete_many(formsemestre_ids)

View File

@ -230,41 +230,41 @@ def next_iso_day(date):
def YearTable( def YearTable(
year, year,
events=[], events_by_day: dict[str, list[dict]],
firstmonth=9, firstmonth=9,
lastmonth=7, lastmonth=7,
halfday=0,
dayattributes="", dayattributes="",
pad_width=8,
): ):
# Code simplifié en 2024: utilisé seulement pour calendrier évaluations
"""Generate a calendar table """Generate a calendar table
events = list of tuples (date, text, color, href [,halfday]) events = list of tuples (date, text, color, href [,halfday])
where date is a string in ISO format (yyyy-mm-dd) where date is a string in ISO format (yyyy-mm-dd)
halfday is boolean (true: morning, false: afternoon) halfday is boolean (true: morning, false: afternoon)
text = text to put in calendar (must be short, 1-5 cars) (optional) text = text to put in calendar (must be short, 1-5 cars) (optional)
if halfday, generate 2 cells per day (morning, afternoon)
""" """
T = [ T = [
'<table id="maincalendar" class="maincalendar" border="3" cellpadding="1" cellspacing="1" frame="box">' """<table id="maincalendar" class="maincalendar"
border="3" cellpadding="1" cellspacing="1" frame="box">"""
] ]
T.append("<tr>") T.append("<tr>")
month = firstmonth month = firstmonth
while 1: while True:
T.append('<td valign="top">') T.append('<td valign="top">')
T.append(MonthTableHead(month)) T.append(_month_table_head(month))
T.append( T.append(
MonthTableBody( _month_table_body(
month, month,
year, year,
events, events_by_day,
halfday,
dayattributes, dayattributes,
is_work_saturday(), is_work_saturday(),
pad_width=pad_width,
) )
) )
T.append(MonthTableTail()) T.append(
T.append("</td>") """
</table>
</td>"""
)
if month == lastmonth: if month == lastmonth:
break break
month = month + 1 month = month + 1
@ -322,29 +322,32 @@ WEEKDAYCOLOR = GRAY1
WEEKENDCOLOR = GREEN3 WEEKENDCOLOR = GREEN3
def MonthTableHead(month): def _month_table_head(month):
color = WHITE color = WHITE
return """<table class="monthcalendar" border="0" cellpadding="0" cellspacing="0" frame="box"> return f"""<table class="monthcalendar" border="0" cellpadding="0" cellspacing="0" frame="box">
<tr bgcolor="%s"><td class="calcol" colspan="2" align="center">%s</td></tr>\n""" % ( <tr bgcolor="{color}">
color, <td class="calcol" colspan="2" align="center">{MONTHNAMES_ABREV[month - 1]}</td>
MONTHNAMES_ABREV[month - 1], </tr>\n"""
)
def MonthTableTail(): def _month_table_body(
return "</table>\n" month,
year,
events_by_day: dict[str, list[dict]],
def MonthTableBody( trattributes="",
month, year, events=[], halfday=0, trattributes="", work_saturday=False, pad_width=8 work_saturday=False,
): ) -> str:
"""
events : [event]
event = [ yyyy-mm-dd, legend, href, color, descr ] XXX
"""
firstday, nbdays = calendar.monthrange(year, month) firstday, nbdays = calendar.monthrange(year, month)
localtime = time.localtime() localtime = time.localtime()
current_weeknum = time.strftime("%U", localtime) current_weeknum = time.strftime("%U", localtime)
current_year = localtime[0] current_year = localtime[0]
T = [] rows = []
# cherche date du lundi de la 1ere semaine de ce mois # cherche date du lundi de la 1ere semaine de ce mois
monday = ddmmyyyy("1/%d/%d" % (month, year)) monday = ddmmyyyy(f"1/{month}/{year}")
while monday.weekday != 0: while monday.weekday != 0:
monday = monday.prev() monday = monday.prev()
@ -353,158 +356,51 @@ def MonthTableBody(
else: else:
weekend = ("S", "D") weekend = ("S", "D")
if not halfday: for d in range(1, nbdays + 1):
for d in range(1, nbdays + 1): weeknum = time.strftime(
weeknum = time.strftime( "%U", time.strptime("%d/%d/%d" % (d, month, year), scu.DATE_FMT)
"%U", time.strptime("%d/%d/%d" % (d, month, year), scu.DATE_FMT) )
) day = DAYNAMES_ABREV[(firstday + d - 1) % 7]
day = DAYNAMES_ABREV[(firstday + d - 1) % 7] if day in weekend:
if day in weekend: bgcolor = WEEKENDCOLOR
bgcolor = WEEKENDCOLOR weekclass = "wkend"
weekclass = "wkend" attrs = ""
attrs = "" else:
else: bgcolor = WEEKDAYCOLOR
bgcolor = WEEKDAYCOLOR weekclass = "wk" + str(monday).replace("/", "_")
weekclass = "wk" + str(monday).replace("/", "_") attrs = trattributes
attrs = trattributes # events this day ?
color = None events = events_by_day.get(f"{year}-{month:02}-{d:02}", [])
legend = "" color = None
href = "" ev_txts = []
descr = "" for ev in events:
# event this day ? color = ev.get("color")
# each event is a tuple (date, text, color, href) href = ev.get("href", "")
# where date is a string in ISO format (yyyy-mm-dd) description = ev.get("description", "")
for ev in events:
ev_year = int(ev[0][:4])
ev_month = int(ev[0][5:7])
ev_day = int(ev[0][8:10])
if year == ev_year and month == ev_month and ev_day == d:
if ev[1]:
legend = ev[1]
if ev[2]:
color = ev[2]
if ev[3]:
href = ev[3]
if len(ev) > 4 and ev[4]:
descr = ev[4]
#
cc = []
if color is not None:
cc.append('<td bgcolor="%s" class="calcell">' % color)
else:
cc.append('<td class="calcell">')
if href: if href:
href = 'href="%s"' % href href = f'href="{href}"'
if descr: if description:
descr = 'title="%s"' % html.escape(descr, quote=True) description = f"""title="{html.escape(description, quote=True)}" """
if href or descr: if href or description:
cc.append("<a %s %s>" % (href, descr)) ev_txts.append(f"""<a {href} {description}>{ev.get("title", "")}</a>""")
if legend or d == 1:
if pad_width is not None:
n = pad_width - len(legend) # pad to 8 cars
if n > 0:
legend = (
"&nbsp;" * (n // 2) + legend + "&nbsp;" * ((n + 1) // 2)
)
else: else:
legend = "&nbsp;" # empty cell ev_txts.append(ev.get("title", "&nbsp;"))
cc.append(legend) #
if href or descr: cc = []
cc.append("</a>") if color is not None:
cc.append("</td>") cc.append(f'<td bgcolor="{color}" class="calcell">')
cell = "".join(cc) else:
if day == "D": cc.append('<td class="calcell">')
monday = monday.next_day(7)
if (
weeknum == current_weeknum
and current_year == year
and weekclass != "wkend"
):
weekclass += " currentweek"
T.append(
'<tr bgcolor="%s" class="%s" %s><td class="calday">%d%s</td>%s</tr>'
% (bgcolor, weekclass, attrs, d, day, cell)
)
else:
# Calendar with 2 cells / day
for d in range(1, nbdays + 1):
weeknum = time.strftime(
"%U", time.strptime("%d/%d/%d" % (d, month, year), scu.DATE_FMT)
)
day = DAYNAMES_ABREV[(firstday + d - 1) % 7]
if day in weekend:
bgcolor = WEEKENDCOLOR
weekclass = "wkend"
attrs = ""
else:
bgcolor = WEEKDAYCOLOR
weekclass = "wk" + str(monday).replace("/", "_")
attrs = trattributes
if (
weeknum == current_weeknum
and current_year == year
and weekclass != "wkend"
):
weeknum += " currentweek"
if day == "D": cc.append(f"{', '.join(ev_txts)}</td>")
monday = monday.next_day(7) cells = "".join(cc)
T.append( if day == "D":
'<tr bgcolor="%s" class="wk%s" %s><td class="calday">%d%s</td>' monday = monday.next_day(7)
% (bgcolor, weekclass, attrs, d, day) if weeknum == current_weeknum and current_year == year and weekclass != "wkend":
) weekclass += " currentweek"
cc = [] rows.append(
for morning in (True, False): f"""<tr bgcolor="{bgcolor}" class="{weekclass}" {attrs}>
color = None <td class="calday">{d}{day}</td>{cells}</tr>"""
legend = "" )
href = ""
descr = "" return "\n".join(rows)
for ev in events:
ev_year = int(ev[0][:4])
ev_month = int(ev[0][5:7])
ev_day = int(ev[0][8:10])
if ev[4] is not None:
ev_half = int(ev[4])
else:
ev_half = 0
if (
year == ev_year
and month == ev_month
and ev_day == d
and morning == ev_half
):
if ev[1]:
legend = ev[1]
if ev[2]:
color = ev[2]
if ev[3]:
href = ev[3]
if len(ev) > 5 and ev[5]:
descr = ev[5]
#
if color is not None:
cc.append('<td bgcolor="%s" class="calcell">' % (color))
else:
cc.append('<td class="calcell">')
if href:
href = 'href="%s"' % href
if descr:
descr = 'title="%s"' % html.escape(descr, quote=True)
if href or descr:
cc.append("<a %s %s>" % (href, descr))
if legend or d == 1:
n = 3 - len(legend) # pad to 3 cars
if n > 0:
legend = (
"&nbsp;" * (n // 2) + legend + "&nbsp;" * ((n + 1) // 2)
)
else:
legend = "&nbsp;&nbsp;&nbsp;" # empty cell
cc.append(legend)
if href or descr:
cc.append("</a>")
cc.append("</td>\n")
T.append("".join(cc) + "</tr>")
return "\n".join(T)

View File

@ -141,6 +141,7 @@ def formsemestre_table_estim_cost(
""", """,
origin=f"""Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}""", origin=f"""Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}""",
filename=f"EstimCout-S{formsemestre.semestre_id}", filename=f"EstimCout-S{formsemestre.semestre_id}",
table_id="formsemestre_table_estim_cost",
) )
return tab return tab

View File

@ -34,13 +34,13 @@ from app.scodoc import sco_cursus_dut
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.comp import res_sem from app.comp import res_sem
from app.models import FormSemestre from app.models import FormSemestre, Identite
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
# SituationEtudParcours -> get_situation_etud_cursus # SituationEtudParcours -> get_situation_etud_cursus
def get_situation_etud_cursus( def get_situation_etud_cursus(
etud: dict, formsemestre_id: int etud: Identite, formsemestre_id: int
) -> sco_cursus_dut.SituationEtudCursus: ) -> sco_cursus_dut.SituationEtudCursus:
"""renvoie une instance de SituationEtudCursus (ou sous-classe spécialisée)""" """renvoie une instance de SituationEtudCursus (ou sous-classe spécialisée)"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.get_formsemestre(formsemestre_id)

View File

@ -31,7 +31,7 @@
from app import db from app import db
from app.comp import res_sem from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat from app.comp.res_compat import NotesTableCompat
from app.models import FormSemestre, UniteEns, ScolarAutorisationInscription from app.models import FormSemestre, Identite, ScolarAutorisationInscription, UniteEns
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
@ -115,14 +115,22 @@ class SituationEtudCursus:
class SituationEtudCursusClassic(SituationEtudCursus): class SituationEtudCursusClassic(SituationEtudCursus):
"Semestre dans un parcours" "Semestre dans un parcours"
def __init__(self, etud: dict, formsemestre_id: int, nt: NotesTableCompat): def __init__(self, etud: Identite, formsemestre_id: int, nt: NotesTableCompat):
""" """
etud: dict filled by fill_etuds_info() etud: dict filled by fill_etuds_info()
""" """
assert formsemestre_id == nt.formsemestre.id
self.etud = etud self.etud = etud
self.etudid = etud["etudid"] self.etudid = etud.id
self.formsemestre_id = formsemestre_id self.formsemestre_id = formsemestre_id
self.sem = sco_formsemestre.get_formsemestre(formsemestre_id) self.formsemestres: list[FormSemestre] = []
"les semestres parcourus, le plus ancien en tête"
self.sem = sco_formsemestre.get_formsemestre(
formsemestre_id
) # TODO utiliser formsemestres
self.cur_sem: FormSemestre = nt.formsemestre
self.can_compensate: set[int] = set()
"les formsemestre_id qui peuvent compenser le courant"
self.nt: NotesTableCompat = nt self.nt: NotesTableCompat = nt
self.formation = self.nt.formsemestre.formation self.formation = self.nt.formsemestre.formation
self.parcours = self.nt.parcours self.parcours = self.nt.parcours
@ -130,18 +138,20 @@ class SituationEtudCursusClassic(SituationEtudCursus):
# pour le DUT, le dernier est toujours S4. # pour le DUT, le dernier est toujours S4.
# Ici: terminal si semestre == NB_SEM ou bien semestre_id==-1 # Ici: terminal si semestre == NB_SEM ou bien semestre_id==-1
# (licences et autres formations en 1 seule session)) # (licences et autres formations en 1 seule session))
self.semestre_non_terminal = self.sem["semestre_id"] != self.parcours.NB_SEM self.semestre_non_terminal = self.cur_sem.semestre_id != self.parcours.NB_SEM
if self.sem["semestre_id"] == NO_SEMESTRE_ID: if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
self.semestre_non_terminal = False self.semestre_non_terminal = False
# Liste des semestres du parcours de cet étudiant: # Liste des semestres du parcours de cet étudiant:
self._comp_semestres() self._comp_semestres()
# Determine le semestre "precedent" # Determine le semestre "precedent"
self.prev_formsemestre_id = self._search_prev() self._search_prev()
# Verifie barres # Verifie barres
self._comp_barres() self._comp_barres()
# Verifie compensation # Verifie compensation
if self.prev and self.sem["gestion_compensation"]: if self.prev_formsemestre and self.cur_sem.gestion_compensation:
self.can_compensate_with_prev = self.prev["can_compensate"] self.can_compensate_with_prev = (
self.prev_formsemestre.id in self.can_compensate
)
else: else:
self.can_compensate_with_prev = False self.can_compensate_with_prev = False
@ -170,14 +180,14 @@ class SituationEtudCursusClassic(SituationEtudCursus):
if rule.conclusion[0] in self.parcours.UNUSED_CODES: if rule.conclusion[0] in self.parcours.UNUSED_CODES:
continue continue
# Saute regles REDOSEM si pas de semestres decales: # Saute regles REDOSEM si pas de semestres decales:
if (not self.sem["gestion_semestrielle"]) and rule.conclusion[ if (not self.cur_sem.gestion_semestrielle) and rule.conclusion[
3 3
] == "REDOSEM": ] == "REDOSEM":
continue continue
if rule.match(state): if rule.match(state):
if rule.conclusion[0] == ADC: if rule.conclusion[0] == ADC:
# dans les regles on ne peut compenser qu'avec le PRECEDENT: # dans les regles on ne peut compenser qu'avec le PRECEDENT:
fiduc = self.prev_formsemestre_id fiduc = self.prev_formsemestre.id
assert fiduc assert fiduc
else: else:
fiduc = None fiduc = None
@ -203,8 +213,8 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"Phrase d'explication pour le code devenir" "Phrase d'explication pour le code devenir"
if not devenir: if not devenir:
return "" return ""
s = self.sem["semestre_id"] # numero semestre courant s_idx = self.cur_sem.semestre_id # numero semestre courant
if s < 0: # formation sans semestres (eg licence) if s_idx < 0: # formation sans semestres (eg licence)
next_s = 1 next_s = 1
else: else:
next_s = self._get_next_semestre_id() next_s = self._get_next_semestre_id()
@ -219,27 +229,27 @@ class SituationEtudCursusClassic(SituationEtudCursus):
elif devenir == REO: elif devenir == REO:
return "Réorienté" return "Réorienté"
elif devenir == REDOANNEE: elif devenir == REDOANNEE:
return "Redouble année (recommence %s%s)" % (SA, (s - 1)) return "Redouble année (recommence %s%s)" % (SA, (s_idx - 1))
elif devenir == REDOSEM: elif devenir == REDOSEM:
return "Redouble semestre (recommence en %s%s)" % (SA, s) return "Redouble semestre (recommence en %s%s)" % (SA, s_idx)
elif devenir == RA_OR_NEXT: elif devenir == RA_OR_NEXT:
return passage + ", ou redouble année (en %s%s)" % (SA, (s - 1)) return passage + ", ou redouble année (en %s%s)" % (SA, (s_idx - 1))
elif devenir == RA_OR_RS: elif devenir == RA_OR_RS:
return "Redouble semestre %s%s, ou redouble année (en %s%s)" % ( return "Redouble semestre %s%s, ou redouble année (en %s%s)" % (
SA, SA,
s, s_idx,
SA, SA,
s - 1, s_idx - 1,
) )
elif devenir == RS_OR_NEXT: elif devenir == RS_OR_NEXT:
return passage + ", ou semestre %s%s" % (SA, s) return passage + ", ou semestre %s%s" % (SA, s_idx)
elif devenir == NEXT_OR_NEXT2: elif devenir == NEXT_OR_NEXT2:
return passage + ", ou en semestre %s%s" % ( return passage + ", ou en semestre %s%s" % (
SA, SA,
s + 2, s_idx + 2,
) # coherent avec get_next_semestre_ids ) # coherent avec get_next_semestre_ids
elif devenir == NEXT2: elif devenir == NEXT2:
return "Passe en %s%s" % (SA, s + 2) return "Passe en %s%s" % (SA, s_idx + 2)
else: else:
log("explique_devenir: code devenir inconnu: %s" % devenir) log("explique_devenir: code devenir inconnu: %s" % devenir)
return "Code devenir inconnu !" return "Code devenir inconnu !"
@ -258,7 +268,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
def _sems_validated(self, exclude_current=False): def _sems_validated(self, exclude_current=False):
"True si semestres du parcours validés" "True si semestres du parcours validés"
if self.sem["semestre_id"] == NO_SEMESTRE_ID: if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
# mono-semestre: juste celui ci # mono-semestre: juste celui ci
decision = self.nt.get_etud_decision_sem(self.etudid) decision = self.nt.get_etud_decision_sem(self.etudid)
return decision and code_semestre_validant(decision["code"]) return decision and code_semestre_validant(decision["code"])
@ -266,8 +276,8 @@ class SituationEtudCursusClassic(SituationEtudCursus):
to_validate = set( to_validate = set(
range(1, self.parcours.NB_SEM + 1) range(1, self.parcours.NB_SEM + 1)
) # ensemble des indices à valider ) # ensemble des indices à valider
if exclude_current and self.sem["semestre_id"] in to_validate: if exclude_current and self.cur_sem.semestre_id in to_validate:
to_validate.remove(self.sem["semestre_id"]) to_validate.remove(self.cur_sem.semestre_id)
return self._sem_list_validated(to_validate) return self._sem_list_validated(to_validate)
def can_jump_to_next2(self): def can_jump_to_next2(self):
@ -275,20 +285,20 @@ class SituationEtudCursusClassic(SituationEtudCursus):
Il faut donc que tous les semestres 1...n-1 soient validés et que n+1 soit en attente. Il faut donc que tous les semestres 1...n-1 soient validés et que n+1 soit en attente.
(et que le sem courant n soit validé, ce qui n'est pas testé ici) (et que le sem courant n soit validé, ce qui n'est pas testé ici)
""" """
n = self.sem["semestre_id"] s_idx = self.cur_sem.semestre_id
if not self.sem["gestion_semestrielle"]: if not self.cur_sem.gestion_semestrielle:
return False # pas de semestre décalés return False # pas de semestre décalés
if n == NO_SEMESTRE_ID or n > self.parcours.NB_SEM - 2: if s_idx == NO_SEMESTRE_ID or s_idx > self.parcours.NB_SEM - 2:
return False # n+2 en dehors du parcours return False # n+2 en dehors du parcours
if self._sem_list_validated(set(range(1, n))): if self._sem_list_validated(set(range(1, s_idx))):
# antérieurs validé, teste suivant # antérieurs validés, teste suivant
n1 = n + 1 n1 = s_idx + 1
for sem in self.get_semestres(): for formsemestre in self.formsemestres:
if ( if (
sem["semestre_id"] == n1 formsemestre.semestre_id == n1
and sem["formation_code"] == self.formation.formation_code and formsemestre.formation.formation_code
== self.formation.formation_code
): ):
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results( nt: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre formsemestre
) )
@ -315,19 +325,17 @@ class SituationEtudCursusClassic(SituationEtudCursus):
return not sem_idx_set return not sem_idx_set
def _comp_semestres(self): def _comp_semestres(self):
# etud['sems'] est trie par date decroissante (voir fill_etuds_info) # plus ancien en tête:
if not "sems" in self.etud: self.formsemestres = self.etud.get_formsemestres(recent_first=False)
self.etud["sems"] = sco_etud.etud_inscriptions_infos(
self.etud["etudid"], self.etud["ne"]
)["sems"]
sems = self.etud["sems"][:] # copy
sems.reverse()
# Nb max d'UE et acronymes # Nb max d'UE et acronymes
ue_acros = {} # acronyme ue : 1 ue_acros = {} # acronyme ue : 1
nb_max_ue = 0 nb_max_ue = 0
for sem in sems: sems = []
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) for formsemestre in self.formsemestres: # plus ancien en tête
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
sem = formsemestre.to_dict()
sems.append(sem)
ues = nt.get_ues_stat_dict(filter_sport=True) ues = nt.get_ues_stat_dict(filter_sport=True)
for ue in ues: for ue in ues:
ue_acros[ue["acronyme"]] = 1 ue_acros[ue["acronyme"]] = 1
@ -338,37 +346,48 @@ class SituationEtudCursusClassic(SituationEtudCursus):
sem["formation_code"] = formsemestre.formation.formation_code sem["formation_code"] = formsemestre.formation.formation_code
# si sem peut servir à compenser le semestre courant, positionne # si sem peut servir à compenser le semestre courant, positionne
# can_compensate # can_compensate
sem["can_compensate"] = self.check_compensation_dut(sem, nt) if self.check_compensation_dut(sem, nt):
self.can_compensate.add(formsemestre.id)
self.ue_acros = list(ue_acros.keys()) self.ue_acros = list(ue_acros.keys())
self.ue_acros.sort() self.ue_acros.sort()
self.nb_max_ue = nb_max_ue self.nb_max_ue = nb_max_ue
self.sems = sems self.sems = sems
def get_semestres(self): def get_semestres(self) -> list[dict]:
"""Liste des semestres dans lesquels a été inscrit """Liste des semestres dans lesquels a été inscrit
l'étudiant (quelle que soit la formation), le plus ancien en tête""" l'étudiant (quelle que soit la formation), le plus ancien en tête"""
return self.sems return self.sems
def get_cursus_descr(self, filter_futur=False): def get_cursus_descr(self, filter_futur=False, filter_formation_code=False):
"""Description brève du parcours: "S1, S2, ..." """Description brève du parcours: "S1, S2, ..."
Si filter_futur, ne mentionne pas les semestres qui sont après le semestre courant. Si filter_futur, ne mentionne pas les semestres qui sont après le semestre courant.
Si filter_formation_code, restreint aux semestres de même code formation que le courant.
""" """
cur_begin_date = self.sem["dateord"] cur_begin_date = self.cur_sem.date_debut
cur_formation_code = self.cur_sem.formation.formation_code
p = [] p = []
for s in self.sems: for formsemestre in self.formsemestres:
if s["ins"]["etat"] == scu.DEMISSION: inscription = formsemestre.etuds_inscriptions.get(self.etud.id)
if inscription is None:
raise ValueError("Etudiant non inscrit au semestre") # bug
if inscription.etat == scu.DEMISSION:
dem = " (dem.)" dem = " (dem.)"
else: else:
dem = "" dem = ""
if filter_futur and s["dateord"] > cur_begin_date: if filter_futur and formsemestre.date_debut > cur_begin_date:
continue # skip semestres demarrant apres le courant continue # skip semestres demarrant apres le courant
SA = self.parcours.SESSION_ABBRV # 'S' ou 'A' if (
if s["semestre_id"] < 0: filter_formation_code
SA = "A" # force, cas des DUT annuels par exemple and formsemestre.formation.formation_code != cur_formation_code
p.append("%s%d%s" % (SA, -s["semestre_id"], dem)) ):
continue # restreint aux semestres de la formation courante (pour les PV)
session_abbrv = self.parcours.SESSION_ABBRV # 'S' ou 'A'
if formsemestre.semestre_id < 0:
session_abbrv = "A" # force, cas des DUT annuels par exemple
p.append("%s%d%s" % (session_abbrv, -formsemestre.semestre_id, dem))
else: else:
p.append("%s%d%s" % (SA, s["semestre_id"], dem)) p.append("%s%d%s" % (session_abbrv, formsemestre.semestre_id, dem))
return ", ".join(p) return ", ".join(p)
def get_parcours_decisions(self): def get_parcours_decisions(self):
@ -377,7 +396,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
Returns: { semestre_id : code } Returns: { semestre_id : code }
""" """
r = {} r = {}
if self.sem["semestre_id"] == NO_SEMESTRE_ID: if self.cur_sem.semestre_id == NO_SEMESTRE_ID:
indices = [NO_SEMESTRE_ID] indices = [NO_SEMESTRE_ID]
else: else:
indices = list(range(1, self.parcours.NB_SEM + 1)) indices = list(range(1, self.parcours.NB_SEM + 1))
@ -420,22 +439,22 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"true si ce semestre pourrait etre compensé par un autre (e.g. barres UE > 8)" "true si ce semestre pourrait etre compensé par un autre (e.g. barres UE > 8)"
return self.barres_ue_ok return self.barres_ue_ok
def _search_prev(self): def _search_prev(self) -> FormSemestre | None:
"""Recherche semestre 'precedent'. """Recherche semestre 'precedent'.
return prev_formsemestre_id positionne .prev_decision
""" """
self.prev = None self.prev_formsemestre = None
self.prev_decision = None self.prev_decision = None
if len(self.sems) < 2: if len(self.formsemestres) < 2:
return None return None
# Cherche sem courant dans la liste triee par date_debut # Cherche sem courant dans la liste triee par date_debut
cur = None cur = None
icur = -1 icur = -1
for cur in self.sems: for cur in self.formsemestres:
icur += 1 icur += 1
if cur["formsemestre_id"] == self.formsemestre_id: if cur.id == self.formsemestre_id:
break break
if not cur or cur["formsemestre_id"] != self.formsemestre_id: if not cur or cur.id != self.formsemestre_id:
log( log(
f"*** SituationEtudCursus: search_prev: cur not found (formsemestre_id={self.formsemestre_id}, etudid={self.etudid})" f"*** SituationEtudCursus: search_prev: cur not found (formsemestre_id={self.formsemestre_id}, etudid={self.etudid})"
) )
@ -443,60 +462,59 @@ class SituationEtudCursusClassic(SituationEtudCursus):
# Cherche semestre antérieur de même formation (code) et semestre_id precedent # Cherche semestre antérieur de même formation (code) et semestre_id precedent
# #
# i = icur - 1 # part du courant, remonte vers le passé # i = icur - 1 # part du courant, remonte vers le passé
i = len(self.sems) - 1 # par du dernier, remonte vers le passé i = len(self.formsemestres) - 1 # par du dernier, remonte vers le passé
prev = None prev_formsemestre = None
while i >= 0: while i >= 0:
if ( if (
self.sems[i]["formation_code"] == self.formation.formation_code self.formsemestres[i].formation.formation_code
and self.sems[i]["semestre_id"] == cur["semestre_id"] - 1 == self.formation.formation_code
and self.formsemestres[i].semestre_id == cur.semestre_id - 1
): ):
prev = self.sems[i] prev_formsemestre = self.formsemestres[i]
break break
i -= 1 i -= 1
if not prev: if not prev_formsemestre:
return None # pas de precedent trouvé return None # pas de precedent trouvé
self.prev = prev self.prev_formsemestre = prev_formsemestre
# Verifications basiques: # Verifications basiques:
# ? # ?
# Code etat du semestre precedent: # Code etat du semestre precedent:
formsemestre = FormSemestre.query.get_or_404(prev["formsemestre_id"]) nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_formsemestre)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
self.prev_decision = nt.get_etud_decision_sem(self.etudid) self.prev_decision = nt.get_etud_decision_sem(self.etudid)
self.prev_moy_gen = nt.get_etud_moy_gen(self.etudid) self.prev_moy_gen = nt.get_etud_moy_gen(self.etudid)
self.prev_barres_ue_ok = nt.etud_check_conditions_ues(self.etudid)[0] self.prev_barres_ue_ok = nt.etud_check_conditions_ues(self.etudid)[0]
return self.prev["formsemestre_id"]
def get_next_semestre_ids(self, devenir): def get_next_semestre_ids(self, devenir: str) -> list[int]:
"""Liste des numeros de semestres autorises avec ce devenir """Liste des numeros de semestres autorises avec ce devenir
Ne vérifie pas que le devenir est possible (doit être fait avant), Ne vérifie pas que le devenir est possible (doit être fait avant),
juste que le rang du semestre est dans le parcours [1..NB_SEM] juste que le rang du semestre est dans le parcours [1..NB_SEM]
""" """
s = self.sem["semestre_id"] s_idx = self.cur_sem.semestre_id
if devenir == NEXT: if devenir == NEXT:
ids = [self._get_next_semestre_id()] ids = [self._get_next_semestre_id()]
elif devenir == REDOANNEE: elif devenir == REDOANNEE:
ids = [s - 1] ids = [s_idx - 1]
elif devenir == REDOSEM: elif devenir == REDOSEM:
ids = [s] ids = [s_idx]
elif devenir == RA_OR_NEXT: elif devenir == RA_OR_NEXT:
ids = [s - 1, self._get_next_semestre_id()] ids = [s_idx - 1, self._get_next_semestre_id()]
elif devenir == RA_OR_RS: elif devenir == RA_OR_RS:
ids = [s - 1, s] ids = [s_idx - 1, s_idx]
elif devenir == RS_OR_NEXT: elif devenir == RS_OR_NEXT:
ids = [s, self._get_next_semestre_id()] ids = [s_idx, self._get_next_semestre_id()]
elif devenir == NEXT_OR_NEXT2: elif devenir == NEXT_OR_NEXT2:
ids = [ ids = [
self._get_next_semestre_id(), self._get_next_semestre_id(),
s + 2, s_idx + 2,
] # cohérent avec explique_devenir() ] # cohérent avec explique_devenir()
elif devenir == NEXT2: elif devenir == NEXT2:
ids = [s + 2] ids = [s_idx + 2]
else: else:
ids = [] # reoriente ou autre: pas de next ! ids = [] # reoriente ou autre: pas de next !
# clip [1..NB_SEM] # clip [1..NB_SEM]
r = [] r = []
for idx in ids: for idx in ids:
if idx > 0 and idx <= self.parcours.NB_SEM: if 0 < idx <= self.parcours.NB_SEM:
r.append(idx) r.append(idx)
return r return r
@ -504,27 +522,27 @@ class SituationEtudCursusClassic(SituationEtudCursus):
"""Indice du semestre suivant non validé. """Indice du semestre suivant non validé.
S'il n'y en a pas, ramène NB_SEM+1 S'il n'y en a pas, ramène NB_SEM+1
""" """
s = self.sem["semestre_id"] s_idx = self.cur_sem.semestre_id
if s >= self.parcours.NB_SEM: if s_idx >= self.parcours.NB_SEM:
return self.parcours.NB_SEM + 1 return self.parcours.NB_SEM + 1
validated = True validated = True
while validated and (s < self.parcours.NB_SEM): while validated and (s_idx < self.parcours.NB_SEM):
s = s + 1 s_idx = s_idx + 1
# semestre s validé ? # semestre s validé ?
validated = False validated = False
for sem in self.sems: for formsemestre in self.formsemestres:
if ( if (
sem["formation_code"] == self.formation.formation_code formsemestre.formation.formation_code
and sem["semestre_id"] == s == self.formation.formation_code
and formsemestre.semestre_id == s_idx
): ):
formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results( nt: NotesTableCompat = res_sem.load_formsemestre_results(
formsemestre formsemestre
) )
decision = nt.get_etud_decision_sem(self.etudid) decision = nt.get_etud_decision_sem(self.etudid)
if decision and code_semestre_validant(decision["code"]): if decision and code_semestre_validant(decision["code"]):
validated = True validated = True
return s return s_idx
def valide_decision(self, decision): def valide_decision(self, decision):
"""Enregistre la decision (instance de DecisionSem) """Enregistre la decision (instance de DecisionSem)
@ -539,8 +557,11 @@ class SituationEtudCursusClassic(SituationEtudCursus):
fsid = decision.formsemestre_id_utilise_pour_compenser fsid = decision.formsemestre_id_utilise_pour_compenser
if fsid: if fsid:
ok = False ok = False
for sem in self.sems: for formsemestre in self.formsemestres:
if sem["formsemestre_id"] == fsid and sem["can_compensate"]: if (
formsemestre.id == fsid
and formsemestre.id in self.can_compensate
):
ok = True ok = True
break break
if not ok: if not ok:
@ -581,7 +602,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
decision.assiduite, decision.assiduite,
) )
# -- modification du code du semestre precedent # -- modification du code du semestre precedent
if self.prev and decision.new_code_prev: if self.prev_formsemestre and decision.new_code_prev:
if decision.new_code_prev == ADC: if decision.new_code_prev == ADC:
# ne compense le prec. qu'avec le sem. courant # ne compense le prec. qu'avec le sem. courant
fsid = self.formsemestre_id fsid = self.formsemestre_id
@ -589,7 +610,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
fsid = None fsid = None
to_invalidate += formsemestre_update_validation_sem( to_invalidate += formsemestre_update_validation_sem(
cnx, cnx,
self.prev["formsemestre_id"], self.prev_formsemestre.id,
self.etudid, self.etudid,
decision.new_code_prev, decision.new_code_prev,
assidu=True, assidu=True,
@ -601,18 +622,18 @@ class SituationEtudCursusClassic(SituationEtudCursus):
etudid=self.etudid, etudid=self.etudid,
commit=False, commit=False,
msg="formsemestre_id=%s code=%s" msg="formsemestre_id=%s code=%s"
% (self.prev["formsemestre_id"], decision.new_code_prev), % (self.prev_formsemestre.id, decision.new_code_prev),
) )
# modifs des codes d'UE (pourraient passer de ADM a CMP, meme sans modif des notes) # modifs des codes d'UE (pourraient passer de ADM a CMP, meme sans modif des notes)
formsemestre_validate_ues( formsemestre_validate_ues(
self.prev["formsemestre_id"], self.prev_formsemestre.id,
self.etudid, self.etudid,
decision.new_code_prev, decision.new_code_prev,
decision.assiduite, # attention: en toute rigueur il faudrait utiliser une indication de l'assiduite au sem. precedent, que nous n'avons pas... decision.assiduite, # attention: en toute rigueur il faudrait utiliser une indication de l'assiduite au sem. precedent, que nous n'avons pas...
) )
sco_cache.invalidate_formsemestre( sco_cache.invalidate_formsemestre(
formsemestre_id=self.prev["formsemestre_id"] formsemestre_id=self.prev_formsemestre.id
) # > modif decisions jury (sem, UE) ) # > modif decisions jury (sem, UE)
try: try:
@ -694,7 +715,7 @@ class SituationEtudCursusClassic(SituationEtudCursus):
class SituationEtudCursusECTS(SituationEtudCursusClassic): class SituationEtudCursusECTS(SituationEtudCursusClassic):
"""Gestion parcours basés sur ECTS""" """Gestion parcours basés sur ECTS"""
def __init__(self, etud, formsemestre_id, nt): def __init__(self, etud: Identite, formsemestre_id: int, nt):
SituationEtudCursusClassic.__init__(self, etud, formsemestre_id, nt) SituationEtudCursusClassic.__init__(self, etud, formsemestre_id, nt)
def could_be_compensated(self): def could_be_compensated(self):

View File

@ -222,6 +222,7 @@ def table_debouche_etudids(etudids, keep_numeric=True):
html_sortable=True, html_sortable=True,
html_class="table_leftalign table_listegroupe", html_class="table_leftalign table_listegroupe",
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
table_id="table_debouche_etudids",
) )
return tab return tab

View File

@ -148,7 +148,7 @@ def _convert_formsemestres_to_dicts(
), ),
"formsemestre_id": formsemestre.id, "formsemestre_id": formsemestre.id,
"groupicon": groupicon if nb_inscrits > 0 else emptygroupicon, "groupicon": groupicon if nb_inscrits > 0 else emptygroupicon,
"lockimg": lockicon, "lockimg": "" if formsemestre.etat else lockicon,
"modalite": formsemestre.modalite, "modalite": formsemestre.modalite,
"mois_debut": formsemestre.mois_debut(), "mois_debut": formsemestre.mois_debut(),
"mois_fin": formsemestre.mois_fin(), "mois_fin": formsemestre.mois_fin(),
@ -192,12 +192,24 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
"elt_sem_apo", "elt_sem_apo",
] ]
if showcodes: if showcodes:
columns_ids = ("formsemestre_id",) + columns_ids columns_ids.insert(0, "formsemestre_id") # prepend
html_class = "stripe cell-border compact hover order-column table_leftalign semlist" html_class = "stripe cell-border compact hover order-column table_leftalign semlist"
if current_user.has_permission(Permission.EditApogee): if current_user.has_permission(Permission.EditApogee):
html_class += " apo_editable" html_class += " apo_editable"
tab = GenTable( tab = GenTable(
columns_ids=columns_ids,
html_class_ignore_default=True,
html_class=html_class,
html_sortable=True,
html_table_attrs=f"""
data-apo_save_url="{url_for('notes.formsemestre_set_apo_etapes', scodoc_dept=g.scodoc_dept)}"
data-elt_annee_apo_save_url="{url_for('notes.formsemestre_set_elt_annee_apo', scodoc_dept=g.scodoc_dept)}"
data-elt_sem_apo_save_url="{url_for('notes.formsemestre_set_elt_sem_apo', scodoc_dept=g.scodoc_dept)}"
""",
html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(),
rows=sems,
titles={ titles={
"formsemestre_id": "id", "formsemestre_id": "id",
"semestre_id_n": "S#", "semestre_id_n": "S#",
@ -211,19 +223,7 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
"elt_sem_apo": "Elt. sem. Apo.", "elt_sem_apo": "Elt. sem. Apo.",
"formation": "Formation", "formation": "Formation",
}, },
columns_ids=columns_ids,
rows=sems,
table_id="semlist", table_id="semlist",
html_class_ignore_default=True,
html_class=html_class,
html_sortable=True,
html_table_attrs=f"""
data-apo_save_url="{url_for('notes.formsemestre_set_apo_etapes', scodoc_dept=g.scodoc_dept)}"
data-elt_annee_apo_save_url="{url_for('notes.formsemestre_set_elt_annee_apo', scodoc_dept=g.scodoc_dept)}"
data-elt_sem_apo_save_url="{url_for('notes.formsemestre_set_elt_sem_apo', scodoc_dept=g.scodoc_dept)}"
""",
html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(),
) )
return tab return tab

View File

@ -67,7 +67,7 @@ SCO_DUMP_LOCK = "/tmp/scodump.lock"
def sco_dump_and_send_db( def sco_dump_and_send_db(
message: str = "", request_url: str = "", traceback_str_base64: str = "" message: str = "", request_url: str = "", traceback_str_base64: str = ""
): ) -> requests.Response:
"""Dump base de données et l'envoie anonymisée pour debug""" """Dump base de données et l'envoie anonymisée pour debug"""
traceback_str = base64.urlsafe_b64decode(traceback_str_base64).decode( traceback_str = base64.urlsafe_b64decode(traceback_str_base64).decode(
scu.SCO_ENCODING scu.SCO_ENCODING
@ -97,7 +97,6 @@ def sco_dump_and_send_db(
# Send # Send
r = _send_db(ano_db_name, message, request_url, traceback_str=traceback_str) r = _send_db(ano_db_name, message, request_url, traceback_str=traceback_str)
code = r.status_code
finally: finally:
# Drop anonymized database # Drop anonymized database
@ -107,7 +106,7 @@ def sco_dump_and_send_db(
log("sco_dump_and_send_db: done.") log("sco_dump_and_send_db: done.")
return code return r
def _duplicate_db(db_name, ano_db_name): def _duplicate_db(db_name, ano_db_name):
@ -216,11 +215,11 @@ def _drop_ano_db(ano_db_name):
log("_drop_ano_db: no temp db, nothing to drop") log("_drop_ano_db: no temp db, nothing to drop")
return return
cmd = ["dropdb", ano_db_name] cmd = ["dropdb", ano_db_name]
log("sco_dump_and_send_db: {}".format(cmd)) log(f"sco_dump_and_send_db: {cmd}")
try: try:
_ = subprocess.check_output(cmd) _ = subprocess.check_output(cmd)
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as exc:
log("sco_dump_and_send_db: exception dropdb {}".format(e)) log(f"sco_dump_and_send_db: exception dropdb {exc}")
raise ScoValueError( raise ScoValueError(
"erreur lors de la suppression de la base {}".format(ano_db_name) f"erreur lors de la suppression de la base {ano_db_name}"
) ) from exc

View File

@ -58,21 +58,20 @@ def formation_delete(formation_id=None, dialog_confirmed=False):
html_sco_header.sco_header(page_title="Suppression d'une formation"), html_sco_header.sco_header(page_title="Suppression d'une formation"),
f"""<h2>Suppression de la formation {formation.titre} ({formation.acronyme})</h2>""", f"""<h2>Suppression de la formation {formation.titre} ({formation.acronyme})</h2>""",
] ]
formsemestres = formation.formsemestres.all()
sems = sco_formsemestre.do_formsemestre_list({"formation_id": formation_id}) if formsemestres:
if sems:
H.append( H.append(
"""<p class="warning">Impossible de supprimer cette formation, """<p class="warning">Impossible de supprimer cette formation,
car les sessions suivantes l'utilisent:</p> car les sessions suivantes l'utilisent:</p>
<ul>""" <ul>"""
) )
for sem in sems: for formsemestre in formsemestres:
H.append( H.append(f"""<li>{formsemestre.html_link_status()}</li>""")
'<li><a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titremois)s</a></li>'
% sem
)
H.append( H.append(
'</ul><p><a class="stdlink" href="%s">Revenir</a></p>' % scu.NotesURL() f"""</ul>
<p><a class="stdlink" href="{
url_for("notes.index_html", scodoc_dept=g.scodoc_dept)
}">Revenir</a></p>"""
) )
else: else:
if not dialog_confirmed: if not dialog_confirmed:
@ -85,14 +84,16 @@ def formation_delete(formation_id=None, dialog_confirmed=False):
</p> </p>
""", """,
OK="Supprimer cette formation", OK="Supprimer cette formation",
cancel_url=scu.NotesURL(), cancel_url=url_for("notes.index_html", scodoc_dept=g.scodoc_dept),
parameters={"formation_id": formation_id}, parameters={"formation_id": formation_id},
) )
else: else:
do_formation_delete(formation_id) do_formation_delete(formation_id)
H.append( H.append(
f"""<p>OK, formation supprimée.</p> f"""<p>OK, formation supprimée.</p>
<p><a class="stdlink" href="{scu.NotesURL()}">continuer</a></p>""" <p><a class="stdlink" href="{
url_for("notes.index_html", scodoc_dept=g.scodoc_dept)
}">continuer</a></p>"""
) )
H.append(html_sco_header.sco_footer()) H.append(html_sco_header.sco_footer())
@ -252,7 +253,7 @@ def formation_edit(formation_id=None, create=False):
if tf[0] == 0: if tf[0] == 0:
return "\n".join(H) + tf[1] + html_sco_header.sco_footer() return "\n".join(H) + tf[1] + html_sco_header.sco_footer()
elif tf[0] == -1: elif tf[0] == -1:
return flask.redirect(scu.NotesURL()) return flask.redirect(url_for("notes.index_html", scodoc_dept=g.scodoc_dept))
else: else:
# check unicity : constraint UNIQUE(acronyme,titre,version) # check unicity : constraint UNIQUE(acronyme,titre,version)
if create: if create:
@ -325,6 +326,7 @@ def do_formation_create(args: dict) -> Formation:
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formation_id=formation.id, formation_id=formation.id,
), ),
safe=True,
) from exc ) from exc
ScolarNews.add( ScolarNews.add(

View File

@ -448,7 +448,7 @@ def module_edit(
( (
"titre", "titre",
{ {
"size": 30, "size": 64,
"explanation": """nom du module. Exemple: "explanation": """nom du module. Exemple:
<em>Introduction à la démarche ergonomique</em>""", <em>Introduction à la démarche ergonomique</em>""",
}, },
@ -456,8 +456,8 @@ def module_edit(
( (
"abbrev", "abbrev",
{ {
"size": 20, "size": 32,
"explanation": """nom abrégé (pour bulletins). "explanation": """(optionnel) nom abrégé pour bulletins.
Exemple: <em>Intro. à l'ergonomie</em>""", Exemple: <em>Intro. à l'ergonomie</em>""",
}, },
), ),

View File

@ -84,6 +84,7 @@ _ueEditor = ndb.EditableTable(
"ects", "ects",
"is_external", "is_external",
"code_apogee", "code_apogee",
"code_apogee_rcue",
"coefficient", "coefficient",
"coef_rcue", "coef_rcue",
"color", "color",
@ -425,6 +426,20 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"max_length": APO_CODE_STR_LEN, "max_length": APO_CODE_STR_LEN,
}, },
), ),
]
if is_apc:
form_descr += [
(
"code_apogee_rcue",
{
"title": "Code Apogée du RCUE",
"size": 25,
"explanation": "(optionnel) code(s) élément pédagogique Apogée du RCUE",
"max_length": APO_CODE_STR_LEN,
},
),
]
form_descr += [
( (
"is_external", "is_external",
{ {
@ -837,8 +852,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
<a href="{url_for('notes.refcomp_show', <a href="{url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}" scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}"
class="stdlink"> class="stdlink">
{formation.referentiel_competence.type_titre} {formation.referentiel_competence.get_title()}
{formation.referentiel_competence.specialite_long}
</a>&nbsp;""" </a>&nbsp;"""
msg_refcomp = "changer" msg_refcomp = "changer"
H.append(f"""<ul><li>{descr_refcomp}""") H.append(f"""<ul><li>{descr_refcomp}""")

View File

@ -30,6 +30,7 @@
Lecture et conversion des ics. Lecture et conversion des ics.
""" """
from datetime import timezone from datetime import timezone
import glob import glob
import os import os
@ -229,7 +230,7 @@ def translate_calendar(
heure_deb=event["heure_deb"], heure_deb=event["heure_deb"],
heure_fin=event["heure_fin"], heure_fin=event["heure_fin"],
moduleimpl_id=modimpl.id, moduleimpl_id=modimpl.id,
jour=event["jour"], day=event["jour"],
) )
if modimpl and group if modimpl and group
else None else None

View File

@ -247,9 +247,7 @@ def apo_csv_check_etape(semset, set_nips, etape_apo):
return nips_ok, apo_nips, nips_no_apo, nips_no_sco, maq_elems, sem_elems return nips_ok, apo_nips, nips_no_apo, nips_no_sco, maq_elems, sem_elems
def apo_csv_semset_check( def apo_csv_semset_check(semset, allow_missing_apo=False, allow_missing_csv=False):
semset, allow_missing_apo=False, allow_missing_csv=False
): # was apo_csv_check
""" """
check students in stored maqs vs students in semset check students in stored maqs vs students in semset
Cas à détecter: Cas à détecter:
@ -346,120 +344,3 @@ def apo_csv_retreive_etuds_by_nip(semset, nips):
etuds[nip] = apo_etuds_by_nips.get(nip, {"nip": nip, "etape_apo": "?"}) etuds[nip] = apo_etuds_by_nips.get(nip, {"nip": nip, "etape_apo": "?"})
return etuds return etuds
"""
Tests:
from debug import *
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_formsemestre
from app.scodoc.sco_etape_apogee import *
from app.scodoc.sco_apogee_csv import *
from app.scodoc.sco_semset import *
app.set_sco_dept('RT')
csv_data = open('/opt/misc/VDTRT_V1RT.TXT').read()
annee_scolaire=2015
sem_id=1
apo_data = sco_apogee_csv.ApoData(csv_data, periode=sem_id)
print apo_data.etape_apogee
apo_data.setup()
e = apo_data.etuds[0]
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco( apo_data)
print apo_csv_list_stored_archives()
# apo_csv_store(csv_data, annee_scolaire, sem_id)
groups_infos = sco_groups_view.DisplayedGroupsInfos( [sco_groups.get_default_group(formsemestre_id)], formsemestre_id=formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
#
s = SemSet('NSS29902')
apo_data = sco_apogee_csv.ApoData(open('/opt/scodoc/var/scodoc/archives/apo_csv/RT/2015-2/2016-07-10-11-26-15/V1RT.csv').read(), periode=1)
# cas Tiziri K. (inscrite en S1, démission en fin de S1, pas inscrite en S2)
# => pas de décision, ce qui est voulu (?)
#
apo_data.setup()
e = [ e for e in apo_data.etuds if e['nom'] == 'XYZ' ][0]
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco(apo_data)
self=e
col_id='apoL_c0129'
# --
from app.scodoc import sco_portal_apogee
_ = go_dept(app, 'GEA').Notes
#csv_data = sco_portal_apogee.get_maquette_apogee(etape='V1GE', annee_scolaire=2015)
csv_data = open('/tmp/V1GE.txt').read()
apo_data = sco_apogee_csv.ApoData(csv_data, periode=1)
# ------
# les elements inconnus:
from debug import *
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_formsemestre
from app.scodoc.sco_etape_apogee import *
from app.scodoc.sco_apogee_csv import *
from app.scodoc.sco_semset import *
_ = go_dept(app, 'RT').Notes
csv_data = open('/opt/misc/V2RT.csv').read()
annee_scolaire=2015
sem_id=1
apo_data = sco_apogee_csv.ApoData(csv_data, periode=1)
print apo_data.etape_apogee
apo_data.setup()
for e in apo_data.etuds:
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco(apo_data)
# ------
# test export jury intermediaire
from debug import *
from app.scodoc import sco_groups
from app.scodoc import sco_groups_view
from app.scodoc import sco_formsemestre
from app.scodoc.sco_etape_apogee import *
from app.scodoc.sco_apogee_csv import *
from app.scodoc.sco_semset import *
_ = go_dept(app, 'CJ').Notes
csv_data = open('/opt/scodoc/var/scodoc/archives/apo_csv/CJ/2016-1/2017-03-06-21-46-32/V1CJ.csv').read()
annee_scolaire=2016
sem_id=1
apo_data = sco_apogee_csv.ApoData(csv_data, periode=1)
print apo_data.etape_apogee
apo_data.setup()
e = [ e for e in apo_data.etuds if e['nom'] == 'XYZ' ][0] #
e.lookup_scodoc( apo_data.etape_formsemestre_ids)
e.associate_sco(apo_data)
self=e
sco_elts = {}
col_id='apoL_c0001'
code = apo_data.cols[col_id]['Code'] # 'V1RT'
sem = apo_data.sems_periode[0] # le S1
"""

View File

@ -125,14 +125,19 @@ def apo_semset_maq_status(
H.append("""<p><em>Aucune maquette chargée</em></p>""") H.append("""<p><em>Aucune maquette chargée</em></p>""")
# Upload fichier: # Upload fichier:
H.append( H.append(
"""<form id="apo_csv_add" action="view_apo_csv_store" method="post" enctype="multipart/form-data"> f"""<form id="apo_csv_add" action="view_apo_csv_store"
Charger votre fichier maquette Apogée: method="post" enctype="multipart/form-data"
style="margin-bottom: 8px;"
>
<div style="margin-top: 12px; margin-bottom: 8px;">
{'Charger votre fichier' if tab_archives.is_empty() else 'Ajouter un autre fichier'}
maquette Apogée:
</div>
<input type="file" size="30" name="csvfile"/> <input type="file" size="30" name="csvfile"/>
<input type="hidden" name="semset_id" value="%s"/> <input type="hidden" name="semset_id" value="{semset_id}"/>
<input type="submit" value="Ajouter ce fichier"/> <input type="submit" value="Ajouter ce fichier"/>
<input type="checkbox" name="autodetect" checked/>autodétecter encodage</input> <input type="checkbox" name="autodetect" checked/>autodétecter encodage</input>
</form>""" </form>"""
% (semset_id,)
) )
# Récupération sur portail: # Récupération sur portail:
maquette_url = sco_portal_apogee.get_maquette_url() maquette_url = sco_portal_apogee.get_maquette_url()
@ -335,7 +340,7 @@ def apo_semset_maq_status(
missing = maq_elems - sem_elems missing = maq_elems - sem_elems
H.append('<div id="apo_elements">') H.append('<div id="apo_elements">')
H.append( H.append(
'<p>Elements Apogée: <span class="apo_elems">%s</span></p>' '<p>Élements Apogée: <span class="apo_elems">%s</span></p>'
% ", ".join( % ", ".join(
[ [
e if not e in missing else '<span class="missing">' + e + "</span>" e if not e in missing else '<span class="missing">' + e + "</span>"
@ -351,7 +356,7 @@ def apo_semset_maq_status(
] ]
H.append( H.append(
f"""<div class="apo_csv_status_missing_elems"> f"""<div class="apo_csv_status_missing_elems">
<span class="fontred">Elements Apogée absents dans ScoDoc: </span> <span class="fontred">Élements Apogée absents dans ScoDoc: </span>
<span class="apo_elems fontred">{ <span class="apo_elems fontred">{
", ".join(sorted(missing)) ", ".join(sorted(missing))
}</span> }</span>
@ -442,11 +447,11 @@ def table_apo_csv_list(semset):
annee_scolaire = semset["annee_scolaire"] annee_scolaire = semset["annee_scolaire"]
sem_id = semset["sem_id"] sem_id = semset["sem_id"]
T = sco_etape_apogee.apo_csv_list_stored_archives( rows = sco_etape_apogee.apo_csv_list_stored_archives(
annee_scolaire, sem_id, etapes=semset.list_etapes() annee_scolaire, sem_id, etapes=semset.list_etapes()
) )
for t in T: for t in rows:
# Ajoute qq infos pour affichage: # Ajoute qq infos pour affichage:
csv_data = sco_etape_apogee.apo_csv_get(t["etape_apo"], annee_scolaire, sem_id) csv_data = sco_etape_apogee.apo_csv_get(t["etape_apo"], annee_scolaire, sem_id)
apo_data = sco_apogee_csv.ApoData(csv_data, periode=semset["sem_id"]) apo_data = sco_apogee_csv.ApoData(csv_data, periode=semset["sem_id"])
@ -484,12 +489,13 @@ def table_apo_csv_list(semset):
"date_str": "Enregistré le", "date_str": "Enregistré le",
}, },
columns_ids=columns_ids, columns_ids=columns_ids,
rows=T, rows=rows,
html_class="table_leftalign apo_maq_list", html_class="table_leftalign apo_maq_list",
html_sortable=True, html_sortable=True,
# base_url = '%s?formsemestre_id=%s' % (request.base_url, formsemestre_id), # base_url = '%s?formsemestre_id=%s' % (request.base_url, formsemestre_id),
# caption='Maquettes enregistrées', # caption='Maquettes enregistrées',
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
table_id="apo_csv_list",
) )
return tab return tab
@ -582,6 +588,7 @@ def _view_etuds_page(
html_class="table_leftalign", html_class="table_leftalign",
filename="students_apo", filename="students_apo",
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
table_id="view_etuds_page",
) )
if fmt != "html": if fmt != "html":
return tab.make_page(fmt=fmt) return tab.make_page(fmt=fmt)
@ -798,6 +805,7 @@ def view_apo_csv(etape_apo="", semset_id="", fmt="html"):
filename="students_" + etape_apo, filename="students_" + etape_apo,
caption="Étudiants Apogée en " + etape_apo, caption="Étudiants Apogée en " + etape_apo,
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
table_id="view_apo_csv",
) )
if fmt != "html": if fmt != "html":

View File

@ -93,7 +93,7 @@ import json
from flask import url_for, g from flask import url_for, g
from app.scodoc.sco_portal_apogee import get_inscrits_etape from app.scodoc import sco_portal_apogee
from app import log from app import log
from app.scodoc.sco_utils import annee_scolaire_debut from app.scodoc.sco_utils import annee_scolaire_debut
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
@ -136,11 +136,16 @@ class DataEtudiant(object):
self.etudid = etudid self.etudid = etudid
self.data_apogee = None self.data_apogee = None
self.data_scodoc = None self.data_scodoc = None
self.etapes = set() # l'ensemble des étapes où il est inscrit self.etapes = set()
self.semestres = set() # l'ensemble des formsemestre_id où il est inscrit "l'ensemble des étapes où il est inscrit"
self.tags = set() # les anomalies relevées self.semestres = set()
self.ind_row = "-" # là où il compte dans les effectifs (ligne et colonne) "l'ensemble des formsemestre_id où il est inscrit"
self.tags = set()
"les anomalies relevées"
self.ind_row = "-"
"ligne où il compte dans les effectifs"
self.ind_col = "-" self.ind_col = "-"
"colonne où il compte dans les effectifs"
def add_etape(self, etape): def add_etape(self, etape):
self.etapes.add(etape) self.etapes.add(etape)
@ -163,9 +168,9 @@ class DataEtudiant(object):
def set_ind_col(self, indicatif): def set_ind_col(self, indicatif):
self.ind_col = indicatif self.ind_col = indicatif
def get_identity(self): def get_identity(self) -> str:
""" """
Calcul le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée) Calcule le nom/prénom de l'étudiant (données ScoDoc en priorité, sinon données Apogée)
:return: L'identité calculée :return: L'identité calculée
""" """
if self.data_scodoc is not None: if self.data_scodoc is not None:
@ -176,9 +181,12 @@ class DataEtudiant(object):
def _help() -> str: def _help() -> str:
return """ return """
<div id="export_help" class="pas_help"> <span>Explications sur les tableaux des effectifs et liste des <div id="export_help" class="pas_help">
étudiants</span> <span>Explications sur les tableaux des effectifs
<div> <p>Le tableau des effectifs présente le nombre d'étudiants selon deux critères:</p> et liste des étudiants</span>
<div>
<p>Le tableau des effectifs présente le nombre d'étudiants selon deux critères:
</p>
<ul> <ul>
<li>En colonne le statut de l'étudiant par rapport à Apogée: <li>En colonne le statut de l'étudiant par rapport à Apogée:
<ul> <ul>
@ -406,7 +414,8 @@ class EtapeBilan:
for key_etape in self.etapes: for key_etape in self.etapes:
annee_apogee, etapestr = key_to_values(key_etape) annee_apogee, etapestr = key_to_values(key_etape)
self.etu_etapes[key_etape] = set() self.etu_etapes[key_etape] = set()
for etud in get_inscrits_etape(etapestr, annee_apogee): # get_inscrits_etape interroge portail Apo:
for etud in sco_portal_apogee.get_inscrits_etape(etapestr, annee_apogee):
key_etu = self.register_etud_apogee(etud, key_etape) key_etu = self.register_etud_apogee(etud, key_etape)
self.etu_etapes[key_etape].add(key_etu) self.etu_etapes[key_etape].add(key_etu)
@ -444,7 +453,6 @@ class EtapeBilan:
data_etu = self.etudiants[key_etu] data_etu = self.etudiants[key_etu]
ind_col = "-" ind_col = "-"
ind_row = "-" ind_row = "-"
# calcul de la colonne # calcul de la colonne
if len(data_etu.etapes) == 1: if len(data_etu.etapes) == 1:
ind_col = self.indicatifs[list(data_etu.etapes)[0]] ind_col = self.indicatifs[list(data_etu.etapes)[0]]
@ -478,32 +486,34 @@ class EtapeBilan:
affichage de l'html affichage de l'html
:return: Le code html à afficher :return: Le code html à afficher
""" """
if not sco_portal_apogee.has_portal():
return """<div id="synthese" class="semset_description">
<em>Pas de portail Apogée configuré</em>
</div>"""
self.load_listes() # chargement des données self.load_listes() # chargement des données
self.dispatch() # analyse et répartition self.dispatch() # analyse et répartition
# calcul de la liste des colonnes et des lignes de la table des effectifs # calcul de la liste des colonnes et des lignes de la table des effectifs
self.all_rows_str = "'" + ",".join(["." + r for r in self.all_rows_ind]) + "'" self.all_rows_str = "'" + ",".join(["." + r for r in self.all_rows_ind]) + "'"
self.all_cols_str = "'" + ",".join(["." + c for c in self.all_cols_ind]) + "'" self.all_cols_str = "'" + ",".join(["." + c for c in self.all_cols_ind]) + "'"
H = [ return f"""
"""<div id="synthese" class="semset_description"> <div id="synthese" class="semset_description">
<details open="true"> <details open="true">
<summary><b>Tableau des effectifs</b> <summary><b>Tableau des effectifs</b>
</summary> </summary>
""", {self._diagtable()}
self._diagtable(), </details>
"""</details>""", {self.display_tags()}
self.display_tags(), <details open="true">
"""<details open="true"> <summary>
<summary><b id="effectifs">Liste des étudiants <span id="compte"></span></b> <b id="effectifs">Liste des étudiants <span id="compte"></span></b>
</summary> </summary>
""", {entete_liste_etudiant()}
entete_liste_etudiant(), {self.table_effectifs()}
self.table_effectifs(), </details>
"""</details>""", {_help()}
_help(), </div>
] """
return "\n".join(H)
def _inc_count(self, ind_row, ind_col): def _inc_count(self, ind_row, ind_col):
if (ind_row, ind_col) not in self.repartition: if (ind_row, ind_col) not in self.repartition:
@ -666,7 +676,9 @@ class EtapeBilan:
col_ids, col_ids,
self.titres, self.titres,
html_class="repartition", html_class="repartition",
html_sortable=True,
html_with_td_classes=True, html_with_td_classes=True,
table_id="apo-repartition",
).gen(fmt="html") ).gen(fmt="html")
) )
return "\n".join(H) return "\n".join(H)
@ -690,26 +702,34 @@ class EtapeBilan:
return "\n".join(H) return "\n".join(H)
@staticmethod @staticmethod
def link_etu(etudid, nom): def link_etu(etudid, nom) -> str:
return '<a class="stdlink" href="%s">%s</a>' % ( "Lien html vers fiche de l'étudiant"
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid), return f"""<a class="stdlink" href="{
nom, url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
) }">{nom}</a>"""
def link_semestre(self, semestre, short=False): def link_semestre(self, semestre, short=False) -> str:
if short: "Lien html vers tableau de bord semestre"
return ( key = "session_id" if short else "titremois"
'<a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(' sem = self.semestres[semestre]
"formsemestre_id)s</a> " % self.semestres[semestre] return f"""<a class="stdlink" href="{
) url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept,
else: formsemestre_id=sem['formsemestre_id']
return ( )}">{sem[key]}</a>
'<a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titre_num)s' """
" %(mois_debut)s - %(mois_fin)s)</a>" % self.semestres[semestre]
)
def table_effectifs(self): def table_effectifs(self) -> str:
H = [] "Table html donnant les étudiants dans chaque semestre"
H = [
"""
<style>
table#apo-detail td.semestre {
white-space: nowrap;
word-break: normal;
}
</style>
"""
]
col_ids = ["tag", "etudiant", "prenom", "nip", "semestre", "apogee", "annee"] col_ids = ["tag", "etudiant", "prenom", "nip", "semestre", "apogee", "annee"]
titles = { titles = {
@ -762,9 +782,10 @@ class EtapeBilan:
rows, rows,
col_ids, col_ids,
titles, titles,
table_id="detail",
html_class="table_leftalign", html_class="table_leftalign",
html_sortable=True, html_sortable=True,
html_with_td_classes=True,
table_id="apo-detail",
).gen(fmt="html") ).gen(fmt="html")
) )
return "\n".join(H) return "\n".join(H)

View File

@ -367,6 +367,9 @@ def evaluation_create_form(
+ "\n".join(H) + "\n".join(H)
+ "\n" + "\n"
+ tf[1] + tf[1]
+ render_template(
"scodoc/forms/evaluation_edit.j2",
)
+ render_template( + render_template(
"scodoc/help/evaluations.j2", is_apc=is_apc, modimpl=modimpl "scodoc/help/evaluations.j2", is_apc=is_apc, modimpl=modimpl
) )

View File

@ -70,8 +70,8 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
Colonnes: Colonnes:
- code (UE ou module), - code (UE ou module),
- titre - titre
- type évaluation
- complete - complete
- publiée
- inscrits (non dem. ni def.) - inscrits (non dem. ni def.)
- nb notes manquantes - nb notes manquantes
- nb ATT - nb ATT
@ -81,9 +81,10 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
rows = [] rows = []
titles = { titles = {
"type": "", "type": "",
"code": "Code", "code": "Module",
"titre": "", "titre": "",
"date": "Date", "date": "Date",
"type_evaluation": "Type",
"complete": "Comptée", "complete": "Comptée",
"inscrits": "Inscrits", "inscrits": "Inscrits",
"manquantes": "Manquantes", # notes eval non entrées "manquantes": "Manquantes", # notes eval non entrées
@ -114,7 +115,9 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
rows.append(row) rows.append(row)
line_idx += 1 line_idx += 1
for evaluation_id in modimpl_results.evals_notes: for evaluation_id in modimpl_results.evals_notes:
e = db.session.get(Evaluation, evaluation_id) e: Evaluation = db.session.get(Evaluation, evaluation_id)
if e is None:
continue # ignore errors (rare race conditions?)
eval_etat = modimpl_results.evaluations_etat[evaluation_id] eval_etat = modimpl_results.evaluations_etat[evaluation_id]
row = { row = {
"type": "", "type": "",
@ -128,6 +131,7 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
"_titre_target_attrs": 'class="discretelink"', "_titre_target_attrs": 'class="discretelink"',
"date": e.date_debut.strftime(scu.DATE_FMT) if e.date_debut else "", "date": e.date_debut.strftime(scu.DATE_FMT) if e.date_debut else "",
"_date_order": e.date_debut.isoformat() if e.date_debut else "", "_date_order": e.date_debut.isoformat() if e.date_debut else "",
"type_evaluation": e.type_abbrev(),
"complete": "oui" if eval_etat.is_complete else "non", "complete": "oui" if eval_etat.is_complete else "non",
"_complete_target": "#", "_complete_target": "#",
"_complete_target_attrs": ( "_complete_target_attrs": (

View File

@ -25,8 +25,8 @@
# #
############################################################################## ##############################################################################
"""Evaluations """Evaluations"""
"""
import collections import collections
import datetime import datetime
import operator import operator
@ -50,6 +50,7 @@ from app.scodoc import sco_cal
from app.scodoc import sco_evaluation_db from app.scodoc import sco_evaluation_db
from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups from app.scodoc import sco_groups
from app.scodoc import sco_gen_cal
from app.scodoc import sco_moduleimpl from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc import sco_users from app.scodoc import sco_users
@ -360,6 +361,108 @@ def do_evaluation_etat_in_mod(nt, modimpl: ModuleImpl):
return etat return etat
class JourEval(sco_gen_cal.Jour):
"""
Représentation d'un jour dans un calendrier d'évaluations
"""
COLOR_INCOMPLETE = "#FF6060"
COLOR_COMPLETE = "#A0FFA0"
COLOR_FUTUR = "#70E0FF"
def __init__(
self,
date: datetime.date,
evaluations: list[Evaluation],
parent: "CalendrierEval",
):
super().__init__(date)
self.evaluations: list[Evaluation] = evaluations
self.evaluations.sort(key=lambda e: e.date_debut)
self.parent: "CalendrierEval" = parent
def get_html(self) -> str:
htmls = []
for e in self.evaluations:
url: str = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=e.moduleimpl_id,
)
title: str = (
e.moduleimpl.module.code or e.moduleimpl.module.abbrev or "éval."
)
htmls.append(
f"""<a
href="{url}"
style="{self._get_eval_style(e)}"
title="{self._get_eval_title(e)}"
class="stdlink"
>{title}</a>"""
)
return ", ".join(htmls)
def _get_eval_style(self, e: Evaluation) -> str:
color: str = ""
# Etat (notes completes) de l'évaluation:
modimpl_result = self.parent.nt.modimpls_results[e.moduleimpl.id]
if modimpl_result.evaluations_etat[e.id].is_complete:
color = JourEval.COLOR_COMPLETE
else:
color = JourEval.COLOR_INCOMPLETE
if e.date_debut > datetime.datetime.now(scu.TIME_ZONE):
color = JourEval.COLOR_FUTUR
return f"background-color: {color};"
def _get_eval_title(self, e: Evaluation) -> str:
heure_debut_txt, heure_fin_txt = "", ""
if e.date_debut != e.date_fin:
heure_debut_txt = (
e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else ""
)
heure_fin_txt = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else ""
title = f"{e.moduleimpl.module.titre_str()}"
if e.description:
title += f" : {e.description}"
if heure_debut_txt:
title += f" de {heure_debut_txt} à {heure_fin_txt}"
return title
class CalendrierEval(sco_gen_cal.Calendrier):
"""
Représentation des évaluations d'un semestre dans un calendrier
"""
def __init__(self, year: int, evals: list[Evaluation], nt: NotesTableCompat):
# On prend du 01/09 au 31/08
date_debut: datetime.datetime = datetime.datetime(year, 9, 1, 0, 0)
date_fin: datetime.datetime = datetime.datetime(year + 1, 8, 31, 23, 59)
super().__init__(date_debut, date_fin)
# évalutions du semestre
self.evals: dict[datetime.date, list[Evaluation]] = {}
for e in evals:
if e.date_debut is not None:
day = e.date_debut.date()
if day not in self.evals:
self.evals[day] = []
self.evals[day].append(e)
self.nt: NotesTableCompat = nt
def instanciate_jour(self, date: datetime.date) -> JourEval:
return JourEval(date, self.evals.get(date, []), parent=self)
# View
def formsemestre_evaluations_cal(formsemestre_id): def formsemestre_evaluations_cal(formsemestre_id):
"""Page avec calendrier de toutes les evaluations de ce semestre""" """Page avec calendrier de toutes les evaluations de ce semestre"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
@ -368,58 +471,9 @@ def formsemestre_evaluations_cal(formsemestre_id):
evaluations = formsemestre.get_evaluations() evaluations = formsemestre.get_evaluations()
nb_evals = len(evaluations) nb_evals = len(evaluations)
color_incomplete = "#FF6060"
color_complete = "#A0FFA0"
color_futur = "#70E0FF"
year = formsemestre.annee_scolaire() year = formsemestre.annee_scolaire()
events = {} # (day, halfday) : event cal = CalendrierEval(year, evaluations, nt)
for e in evaluations: cal_html = cal.get_html()
if e.date_debut is None:
continue # éval. sans date
txt = e.moduleimpl.module.code or e.moduleimpl.module.abbrev or "éval."
if e.date_debut == e.date_fin:
heure_debut_txt, heure_fin_txt = "?", "?"
else:
heure_debut_txt = (
e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else "?"
)
heure_fin_txt = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else "?"
description = f"""{
e.moduleimpl.module.titre
}, de {heure_debut_txt} à {heure_fin_txt}"""
# Etat (notes completes) de l'évaluation:
modimpl_result = nt.modimpls_results[e.moduleimpl.id]
if modimpl_result.evaluations_etat[e.id].is_complete:
color = color_complete
else:
color = color_incomplete
if e.date_debut > datetime.datetime.now(scu.TIME_ZONE):
color = color_futur
href = url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=e.moduleimpl_id,
)
day = e.date_debut.date().isoformat() # yyyy-mm-dd
event = events.get(day)
if not event:
events[day] = [day, txt, color, href, description, e.moduleimpl]
else:
if event[-1].id != e.moduleimpl.id:
# plusieurs evals de modules differents a la meme date
event[1] += ", " + txt
event[4] += ", " + description
if color == color_incomplete:
event[2] = color_incomplete
if color == color_futur:
event[2] = color_futur
cal_html = sco_cal.YearTable(
year, events=list(events.values()), halfday=False, pad_width=None
)
return f""" return f"""
{ {
@ -435,15 +489,15 @@ def formsemestre_evaluations_cal(formsemestre_id):
</p> </p>
<ul> <ul>
<li>en <span style= <li>en <span style=
"background-color: {color_incomplete}">rouge</span> "background-color: {JourEval.COLOR_INCOMPLETE}">rouge</span>
les évaluations passées auxquelles il manque des notes les évaluations passées auxquelles il manque des notes
</li> </li>
<li>en <span style= <li>en <span style=
"background-color: {color_complete}">vert</span> "background-color: {JourEval.COLOR_COMPLETE}">vert</span>
les évaluations déjà notées les évaluations déjà notées
</li> </li>
<li>en <span style= <li>en <span style=
"background-color: {color_futur}">bleu</span> "background-color: {JourEval.COLOR_FUTUR}">bleu</span>
les évaluations futures les évaluations futures
</li> </li>
</ul> </ul>
@ -541,7 +595,9 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"):
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
moduleimpl_id=e.moduleimpl.id, moduleimpl_id=e.moduleimpl.id,
), ),
"module_titre": e.moduleimpl.module.abbrev or e.moduleimpl.module.titre, "module_titre": e.moduleimpl.module.abbrev
or e.moduleimpl.module.titre
or "",
"responsable_id": e.moduleimpl.responsable_id, "responsable_id": e.moduleimpl.responsable_id,
"responsable_nomplogin": sco_users.user_info( "responsable_nomplogin": sco_users.user_info(
e.moduleimpl.responsable_id e.moduleimpl.responsable_id
@ -579,6 +635,7 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"):
base_url="%s?formsemestre_id=%s" % (request.base_url, formsemestre_id), base_url="%s?formsemestre_id=%s" % (request.base_url, formsemestre_id),
origin=f"""Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}""", origin=f"""Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}""",
filename=scu.make_filename("evaluations_delais_" + formsemestre.titre_annee()), filename=scu.make_filename("evaluations_delais_" + formsemestre.titre_annee()),
table_id="formsemestre_evaluations_delai_correction",
) )
return tab.make_page(fmt=fmt) return tab.make_page(fmt=fmt)

View File

@ -45,13 +45,17 @@ class ScoInvalidCSRF(ScoException):
class ScoValueError(ScoException): class ScoValueError(ScoException):
"Exception avec page d'erreur utilisateur, et qui stoque dest_url" """Exception avec page d'erreur utilisateur
- dest_url : url aller après la page d'erreur
- safe (default False): si vrai, affiche le message non html quoté.
"""
# mal nommée: super classe de toutes les exceptions avec page # mal nommée: super classe de toutes les exceptions avec page
# d'erreur gentille. # d'erreur gentille.
def __init__(self, msg, dest_url=None): def __init__(self, msg, dest_url=None, safe=False):
super().__init__(msg) super().__init__(msg)
self.dest_url = dest_url self.dest_url = dest_url
self.safe = safe # utilisé par template sco_value_error.j2
class ScoPermissionDenied(ScoValueError): class ScoPermissionDenied(ScoValueError):
@ -103,7 +107,7 @@ class ScoPDFFormatError(ScoValueError):
super().__init__( super().__init__(
f"""Erreur dans un format pdf: f"""Erreur dans un format pdf:
<p>{msg}</p> <p>{msg}</p>
<p>Vérifiez les paramètres (polices de caractères, balisage) <p>Vérifiez les paramètres (polices de caractères, balisage, réglages bulletins...)
dans les paramètres ou préférences. dans les paramètres ou préférences.
</p> </p>
""", """,

View File

@ -106,6 +106,7 @@ def _build_results_table(start_date=None, end_date=None, types_parcours=[]):
html_class="table_leftalign", html_class="table_leftalign",
html_sortable=True, html_sortable=True,
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
table_id="export_result_table",
) )
return tab, semlist return tab, semlist

View File

@ -32,8 +32,7 @@ from flask import url_for, g, request
from flask_login import current_user from flask_login import current_user
import app import app
from app.models import Departement from app.models import Departement, Identite
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb import app.scodoc.notesdb as ndb
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
@ -55,7 +54,9 @@ def form_search_etud(
"form recherche par nom" "form recherche par nom"
H = [] H = []
H.append( H.append(
f"""<form action="{ url_for("scolar.search_etud_in_dept", scodoc_dept=g.scodoc_dept) }" method="POST"> f"""<form action="{
url_for("scolar.search_etud_in_dept", scodoc_dept=g.scodoc_dept)
}" method="POST">
<b>{title}</b> <b>{title}</b>
<input type="text" name="expnom" class="in-expnom" width="12" spellcheck="false" value=""> <input type="text" name="expnom" class="in-expnom" width="12" spellcheck="false" value="">
<input type="submit" value="Chercher"> <input type="submit" value="Chercher">
@ -100,9 +101,9 @@ def form_search_etud(
return "\n".join(H) return "\n".join(H)
def search_etuds_infos_from_exp(expnom: str = "") -> list[dict]: def search_etuds_infos_from_exp(expnom: str = "") -> list[Identite]:
"""Cherche étudiants, expnom peut être, dans cet ordre: """Cherche étudiants, expnom peut être, dans cet ordre:
un etudid (int), un code NIP, ou le début d'un nom. un etudid (int), un code NIP, ou une partie d'un nom (case insensitive).
""" """
if not isinstance(expnom, int) and len(expnom) <= 1: if not isinstance(expnom, int) and len(expnom) <= 1:
return [] # si expnom est trop court, n'affiche rien return [] # si expnom est trop court, n'affiche rien
@ -111,13 +112,22 @@ def search_etuds_infos_from_exp(expnom: str = "") -> list[dict]:
except ValueError: except ValueError:
etudid = None etudid = None
if etudid is not None: if etudid is not None:
etuds = sco_etud.get_etud_info(filled=True, etudid=expnom) etud = Identite.query.filter_by(dept_id=g.scodoc_dept_id, id=etudid).first()
if len(etuds) == 1: if etud:
return etuds return [etud]
expnom_str = str(expnom) expnom_str = str(expnom)
if scu.is_valid_code_nip(expnom_str): if scu.is_valid_code_nip(expnom_str):
return search_etuds_infos(code_nip=expnom_str) etuds = Identite.query.filter_by(
return search_etuds_infos(expnom=expnom_str) dept_id=g.scodoc_dept_id, code_nip=expnom_str
).all()
if etuds:
return etuds
return (
Identite.query.filter_by(dept_id=g.scodoc_dept_id)
.filter(Identite.nom.op("~*")(expnom_str))
.all()
)
def search_etud_in_dept(expnom=""): def search_etud_in_dept(expnom=""):
@ -152,7 +162,7 @@ def search_etud_in_dept(expnom=""):
if len(etuds) == 1: if len(etuds) == 1:
# va directement a la fiche # va directement a la fiche
url_args["etudid"] = etuds[0]["etudid"] url_args["etudid"] = etuds[0].id
return flask.redirect(url_for(endpoint, **url_args)) return flask.redirect(url_for(endpoint, **url_args))
H = [ H = [
@ -179,14 +189,39 @@ def search_etud_in_dept(expnom=""):
) )
if len(etuds) > 0: if len(etuds) > 0:
# Choix dans la liste des résultats: # Choix dans la liste des résultats:
rows = []
e: Identite
for e in etuds: for e in etuds:
url_args["etudid"] = e["etudid"] url_args["etudid"] = e.id
target = url_for(endpoint, **url_args) target = url_for(endpoint, **url_args)
e["_nomprenom_target"] = target cur_inscription = e.inscription_courante()
e["inscription_target"] = target inscription = (
e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"]) e.inscription_descr().get("inscription_str", "")
sco_groups.etud_add_group_infos( if cur_inscription
e, e["cursem"]["formsemestre_id"] if e["cursem"] else None else ""
)
groupes = (
", ".join(
gr.group_name
for gr in sco_groups.get_etud_formsemestre_groups(
e, cur_inscription.formsemestre
)
)
if cur_inscription
else ""
)
rows.append(
{
"code_nip": e.code_nip or "",
"etudid": e.id,
"inscription": inscription,
"inscription_target": target,
"groupes": groupes,
"nomprenom": e.nomprenom,
"_nomprenom_target": target,
"_nomprenom_td_attrs": f'id="{e.id}" class="etudinfo"',
}
) )
tab = GenTable( tab = GenTable(
@ -197,10 +232,11 @@ def search_etud_in_dept(expnom=""):
"inscription": "Inscription", "inscription": "Inscription",
"groupes": "Groupes", "groupes": "Groupes",
}, },
rows=etuds, rows=rows,
html_sortable=True, html_sortable=True,
html_class="table_leftalign", html_class="table_leftalign",
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
table_id="search_etud_in_dept",
) )
H.append(tab.html()) H.append(tab.html())
if len(etuds) > 20: # si la page est grande if len(etuds) > 20: # si la page est grande
@ -213,15 +249,16 @@ def search_etud_in_dept(expnom=""):
) )
) )
else: else:
H.append('<h2 style="color: red;">Aucun résultat pour "%s".</h2>' % expnom) H.append(f'<h2 style="color: red;">Aucun résultat pour "{expnom}".</h2>')
H.append( H.append(
"""<p class="help">La recherche porte sur tout ou partie du NOM ou du NIP de l'étudiant. Saisir au moins deux caractères.</p>""" """<p class="help">La recherche porte sur tout ou partie du NOM ou du NIP
de l'étudiant. Saisir au moins deux caractères.</p>"""
) )
return "\n".join(H) + html_sco_header.sco_footer() return "\n".join(H) + html_sco_header.sco_footer()
# Was chercheEtudsInfo() # Was chercheEtudsInfo()
def search_etuds_infos(expnom=None, code_nip=None): def search_etuds_infos(expnom=None, code_nip=None) -> list[dict]:
"""recherche les étudiants correspondants à expnom ou au code_nip """recherche les étudiants correspondants à expnom ou au code_nip
et ramene liste de mappings utilisables en DTML. et ramene liste de mappings utilisables en DTML.
""" """
@ -264,7 +301,7 @@ def search_etud_by_name(term: str) -> list:
FROM identite FROM identite
WHERE WHERE
dept_id = %(dept_id)s dept_id = %(dept_id)s
AND code_nip LIKE %(beginning)s AND code_nip ILIKE %(beginning)s
ORDER BY nom ORDER BY nom
""", """,
{"beginning": term + "%", "dept_id": g.scodoc_dept_id}, {"beginning": term + "%", "dept_id": g.scodoc_dept_id},
@ -283,7 +320,7 @@ def search_etud_by_name(term: str) -> list:
FROM identite FROM identite
WHERE WHERE
dept_id = %(dept_id)s dept_id = %(dept_id)s
AND nom LIKE %(beginning)s AND nom ILIKE %(beginning)s
ORDER BY nom ORDER BY nom
""", """,
{"beginning": term + "%", "dept_id": g.scodoc_dept_id}, {"beginning": term + "%", "dept_id": g.scodoc_dept_id},
@ -348,6 +385,7 @@ def table_etud_in_accessible_depts(expnom=None):
rows=etuds, rows=etuds,
html_sortable=True, html_sortable=True,
html_class="table_leftalign", html_class="table_leftalign",
table_id="etud_in_accessible_depts",
) )
H.append('<div class="table_etud_in_dept">') H.append('<div class="table_etud_in_dept">')
@ -383,13 +421,13 @@ def search_inscr_etud_by_nip(code_nip, fmt="json"):
""" """
result, _ = search_etud_in_accessible_depts(code_nip=code_nip) result, _ = search_etud_in_accessible_depts(code_nip=code_nip)
T = [] rows = []
for etuds in result: for etuds in result:
if etuds: if etuds:
dept_id = etuds[0]["dept"] dept_id = etuds[0]["dept"]
for e in etuds: for e in etuds:
for sem in e["sems"]: for sem in e["sems"]:
T.append( rows.append(
{ {
"dept": dept_id, "dept": dept_id,
"etudid": e["etudid"], "etudid": e["etudid"],
@ -414,6 +452,6 @@ def search_inscr_etud_by_nip(code_nip, fmt="json"):
"date_debut_iso", "date_debut_iso",
"date_fin_iso", "date_fin_iso",
) )
tab = GenTable(columns_ids=columns_ids, rows=T) tab = GenTable(columns_ids=columns_ids, rows=rows, table_id="inscr_etud_by_nip")
return tab.make_page(fmt=fmt, with_html_headers=False, publish=True) return tab.make_page(fmt=fmt, with_html_headers=False, publish=True)

View File

@ -143,6 +143,7 @@ def formation_export_dict(
if not export_codes_apo: if not export_codes_apo:
ue_dict.pop("code_apogee", None) ue_dict.pop("code_apogee", None)
ue_dict.pop("code_apogee_rcue", None)
if ue_dict.get("ects") is None: if ue_dict.get("ects") is None:
ue_dict.pop("ects", None) ue_dict.pop("ects", None)
mats = sco_edit_matiere.matiere_list({"ue_id": ue.id}) mats = sco_edit_matiere.matiere_list({"ue_id": ue.id})
@ -489,9 +490,10 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
return formation.id, modules_old2new, ues_old2new return formation.id, modules_old2new, ues_old2new
def formation_list_table() -> GenTable: def formation_list_table(detail: bool) -> GenTable:
"""List formation, grouped by titre and sorted by versions """List formation, grouped by titre and sorted by versions
and listing associated semestres and listing associated semestres.
If detail, add column with more details.
returns a table returns a table
""" """
formations: list[Formation] = Formation.query.filter_by(dept_id=g.scodoc_dept_id) formations: list[Formation] = Formation.query.filter_by(dept_id=g.scodoc_dept_id)
@ -534,6 +536,15 @@ def formation_list_table() -> GenTable:
if formation.referentiel_competence if formation.referentiel_competence
else "" else ""
), ),
"_referentiel_target": (
url_for(
"notes.refcomp_show",
scodoc_dept=g.scodoc_dept,
refcomp_id=formation.referentiel_competence.id,
)
if formation.referentiel_competence
else ""
),
} }
# Ajoute les semestres associés à chaque formation: # Ajoute les semestres associés à chaque formation:
row["formsemestres"] = formation.formsemestres.order_by( row["formsemestres"] = formation.formsemestres.order_by(
@ -561,10 +572,15 @@ def formation_list_table() -> GenTable:
else [] else []
) )
) )
# Répartition des UEs dans les semestres
# utilise pour voir si la formation couvre tous les semestres
row["semestres_ues"] = ", ".join(
"S" + str(x if (x is not None and x > 0) else "-")
for x in sorted({(ue.semestre_idx or 0) for ue in formation.ues})
)
# Date surtout utilisées pour le tri:
if row["formsemestres"]: if row["formsemestres"]:
row["date_fin_dernier_sem"] = ( row["date_fin_dernier_sem"] = row["formsemestres"][-1].date_fin.isoformat()
row["formsemestres"][-1].date_fin.isoformat(),
)
row["annee_dernier_sem"] = row["formsemestres"][-1].date_fin.year row["annee_dernier_sem"] = row["formsemestres"][-1].date_fin.year
else: else:
row["date_fin_dernier_sem"] = "" row["date_fin_dernier_sem"] = ""
@ -617,6 +633,8 @@ def formation_list_table() -> GenTable:
"commentaire", "commentaire",
"sems_list_txt", "sems_list_txt",
) )
if detail:
columns_ids += ("annee_dernier_sem", "semestres_ues")
titles = { titles = {
"buttons": "", "buttons": "",
"commentaire": "Commentaire", "commentaire": "Commentaire",
@ -627,22 +645,25 @@ def formation_list_table() -> GenTable:
"formation_code": "Code", "formation_code": "Code",
"sems_list_txt": "Semestres", "sems_list_txt": "Semestres",
"referentiel": "Réf.", "referentiel": "Réf.",
"date_fin_dernier_sem": "Fin dernier sem.",
"annee_dernier_sem": "Année dernier sem.",
"semestres_ues": "Semestres avec UEs",
} }
return GenTable( return GenTable(
columns_ids=columns_ids, base_url=f"{request.base_url}" + ("?detail=on" if detail else ""),
rows=rows,
titles=titles,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
caption=title, caption=title,
columns_ids=columns_ids,
html_caption=title, html_caption=title,
table_id="formation_list_table",
html_class="formation_list_table table_leftalign", html_class="formation_list_table table_leftalign",
html_with_td_classes=True,
html_sortable=True, html_sortable=True,
base_url=f"{request.base_url}", html_with_td_classes=True,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
page_title=title, page_title=title,
pdf_title=title, pdf_title=title,
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
rows=rows,
table_id="formation_list_table",
titles=titles,
) )

View File

@ -419,49 +419,23 @@ def sem_set_responsable_name(sem):
) )
def sem_in_semestre_scolaire( def sem_in_annee_scolaire(sem: dict, year=False): # OBSOLETE
sem,
year=False,
periode=None,
mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
) -> bool:
"""Vrai si la date du début du semestre est dans la période indiquée (1,2,0)
du semestre `periode` de l'année scolaire indiquée
(ou, à défaut, de celle en cours).
La période utilise les même conventions que semset["sem_id"];
* 1 : première période
* 2 : deuxième période
* 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
)
"""
if not year:
year = scu.annee_scolaire()
# n'utilise pas le jour pivot
jour_pivot_annee = jour_pivot_periode = 1
# calcule l'année universitaire et la période
sem_annee, sem_periode = FormSemestre.comp_periode(
datetime.datetime.fromisoformat(sem["date_debut_iso"]),
mois_pivot_annee,
mois_pivot_periode,
jour_pivot_annee,
jour_pivot_periode,
)
if periode is None or periode == 0:
return sem_annee == year
return sem_annee == year and sem_periode == periode
def sem_in_annee_scolaire(sem, year=False):
"""Test si sem appartient à l'année scolaire year (int). """Test si sem appartient à l'année scolaire year (int).
N'utilise que la date de début, pivot au 1er août. N'utilise que la date de début, pivot au 1er août.
Si année non specifiée, année scolaire courante Si année non specifiée, année scolaire courante
""" """
return sem_in_semestre_scolaire(sem, year, periode=0) return FormSemestre.est_in_semestre_scolaire(
datetime.date.fromisoformat(sem["date_debut_iso"]), year, periode=0
)
def sem_est_courant(sem): # -> FormSemestre.est_courant def sem_in_semestre_scolaire(sem, year=False, periode=None): # OBSOLETE
return FormSemestre.est_in_semestre_scolaire(
datetime.date.fromisoformat(sem["date_debut_iso"]), year, periode=periode
)
def sem_est_courant(sem: dict): # -> FormSemestre.est_courant
"""Vrai si la date actuelle (now) est dans le semestre (les dates de début et fin sont incluses)""" """Vrai si la date actuelle (now) est dans le semestre (les dates de début et fin sont incluses)"""
now = time.strftime("%Y-%m-%d") now = time.strftime("%Y-%m-%d")
debut = ndb.DateDMYtoISO(sem["date_debut"]) debut = ndb.DateDMYtoISO(sem["date_debut"])
@ -527,15 +501,16 @@ def table_formsemestres(
preferences = sco_preferences.SemPreferences() preferences = sco_preferences.SemPreferences()
tab = GenTable( tab = GenTable(
columns_ids=columns_ids, columns_ids=columns_ids,
rows=sems,
titles=titles,
html_class="table_leftalign", html_class="table_leftalign",
html_empty_element="<p><em>aucun résultat</em></p>",
html_next_section=html_next_section,
html_sortable=True, html_sortable=True,
html_title=html_title, html_title=html_title,
html_next_section=html_next_section,
html_empty_element="<p><em>aucun résultat</em></p>",
page_title="Semestres", page_title="Semestres",
preferences=preferences, preferences=preferences,
rows=sems,
table_id="table_formsemestres",
titles=titles,
) )
return tab return tab

View File

@ -438,12 +438,13 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
"elt_sem_apo", "elt_sem_apo",
{ {
"size": 32, "size": 32,
"title": "Element(s) Apogée:", "title": "Element(s) Apogée sem.:",
"explanation": "associé(s) au résultat du semestre (ex: VRTW1). Inutile en BUT. Séparés par des virgules.", "explanation": "associé(s) au résultat du semestre (ex: VRTW1). Inutile en BUT. Séparés par des virgules.",
"allow_null": not sco_preferences.get_preference( "allow_null": not sco_preferences.get_preference(
"always_require_apo_sem_codes" "always_require_apo_sem_codes"
) )
or (formsemestre and formsemestre.modalite == "EXT"), or (formsemestre and formsemestre.modalite == "EXT")
or (formsemestre.formation.is_apc()),
}, },
) )
) )
@ -452,7 +453,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
"elt_annee_apo", "elt_annee_apo",
{ {
"size": 32, "size": 32,
"title": "Element(s) Apogée:", "title": "Element(s) Apogée année:",
"explanation": "associé(s) au résultat de l'année (ex: VRT1A). Séparés par des virgules.", "explanation": "associé(s) au résultat de l'année (ex: VRT1A). Séparés par des virgules.",
"allow_null": not sco_preferences.get_preference( "allow_null": not sco_preferences.get_preference(
"always_require_apo_sem_codes" "always_require_apo_sem_codes"
@ -1431,18 +1432,25 @@ Ceci n'est possible que si :
def formsemestre_delete2(formsemestre_id, dialog_confirmed=False): def formsemestre_delete2(formsemestre_id, dialog_confirmed=False):
"""Delete a formsemestre (confirmation)""" """Delete a formsemestre (confirmation)"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# Confirmation dialog # Confirmation dialog
if not dialog_confirmed: if not dialog_confirmed:
return scu.confirm_dialog( return scu.confirm_dialog(
"""<h2>Vous voulez vraiment supprimer ce semestre ???</h2><p>(opération irréversible)</p>""", """<h2>Vous voulez vraiment supprimer ce semestre ???</h2>
<p>(opération irréversible)</p>
""",
dest_url="", dest_url="",
cancel_url="formsemestre_status?formsemestre_id=%s" % formsemestre_id, cancel_url=url_for(
parameters={"formsemestre_id": formsemestre_id}, "notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
parameters={"formsemestre_id": formsemestre.id},
) )
# Bon, s'il le faut... # Bon, s'il le faut...
do_formsemestre_delete(formsemestre_id) do_formsemestre_delete(formsemestre.id)
flash("Semestre supprimé !") flash("Semestre supprimé !")
return flask.redirect(scu.ScoURL()) return flask.redirect(url_for("scolar.index_html", scodoc_dept=g.scodoc_dept))
def formsemestre_has_decisions_or_compensations( def formsemestre_has_decisions_or_compensations(

View File

@ -143,8 +143,10 @@ def _build_menu_stats(formsemestre: FormSemestre):
] ]
def formsemestre_status_menubar(formsemestre: FormSemestre) -> str: def formsemestre_status_menubar(formsemestre: FormSemestre | None) -> str:
"""HTML to render menubar""" """HTML to render menubar"""
if formsemestre is None:
return ""
formsemestre_id = formsemestre.id formsemestre_id = formsemestre.id
if formsemestre.etat: if formsemestre.etat:
change_lock_msg = "Verrouiller" change_lock_msg = "Verrouiller"
@ -632,7 +634,7 @@ def formsemestre_description_table(
"UE": modimpl.module.ue.acronyme, "UE": modimpl.module.ue.acronyme,
"_UE_td_attrs": ue_info.get("_UE_td_attrs", ""), "_UE_td_attrs": ue_info.get("_UE_td_attrs", ""),
"Code": modimpl.module.code or "", "Code": modimpl.module.code or "",
"Module": modimpl.module.abbrev or modimpl.module.titre, "Module": modimpl.module.abbrev or modimpl.module.titre or "",
"_Module_class": "scotext", "_Module_class": "scotext",
"Inscrits": mod_nb_inscrits, "Inscrits": mod_nb_inscrits,
"Responsable": sco_users.user_info(modimpl.responsable_id)["nomprenom"], "Responsable": sco_users.user_info(modimpl.responsable_id)["nomprenom"],
@ -724,20 +726,21 @@ def formsemestre_description_table(
rows.append(sums) rows.append(sums)
return GenTable( return GenTable(
columns_ids=columns_ids, base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}&with_evals={with_evals}",
rows=rows,
titles=titles,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
caption=title, caption=title,
columns_ids=columns_ids,
html_caption=title, html_caption=title,
html_class="table_leftalign formsemestre_description", html_class="table_leftalign formsemestre_description",
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}&with_evals={with_evals}",
page_title=title,
html_title=html_sco_header.html_sem_header( html_title=html_sco_header.html_sem_header(
"Description du semestre", with_page_header=False "Description du semestre", with_page_header=False
), ),
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
page_title=title,
pdf_title=title, pdf_title=title,
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
rows=rows,
table_id="formsemestre_description_table",
titles=titles,
) )
@ -819,6 +822,55 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
</div> </div>
</div> </div>
<div class="sem-groups-assi"> <div class="sem-groups-assi">
"""
)
if can_edit_abs:
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
day=datetime.date.today().isoformat(),
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Saisir l'assiduité</a>
</div>
"""
)
# YYYY-Www (ISO 8601) :
current_week: str = datetime.datetime.now().strftime("%G-W%V")
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_hebdo",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
week=current_week,
)}">Saisie hebdomadaire</a>
</div>
"""
)
if can_edit_abs:
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.bilan_dept",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Justificatifs en attente</a>
</div>
"""
)
H.append(
f"""
<div> <div>
<a class="stdlink" href="{ <a class="stdlink" href="{
url_for("assiduites.visu_assi_group", url_for("assiduites.visu_assi_group",
@ -831,49 +883,6 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
</div> </div>
""" """
) )
if can_edit_abs:
H.append(
f"""
<div>
<a class="stdlink" href="{
url_for("assiduites.visu_assiduites_group",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
jour = datetime.date.today().isoformat(),
group_ids=group.id,
)}">
Visualiser</a>
</div>
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
jour=datetime.date.today().isoformat(),
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Saisie journalière</a>
</div>
<div>
<a class="stdlink" href="{
url_for("assiduites.signal_assiduites_diff",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Saisie différée</a>
</div>
<div>
<a class="stdlink" href="{
url_for("assiduites.bilan_dept",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group.id,
)}">
Justificatifs en attente</a>
</div>
"""
)
H.append("</div>") # /sem-groups-assi H.append("</div>") # /sem-groups-assi
if partition_is_empty: if partition_is_empty:
@ -1184,17 +1193,7 @@ def formsemestre_tableau_modules(
mod_descr = "Module " + (mod.titre or "") mod_descr = "Module " + (mod.titre or "")
is_apc = mod.is_apc() # SAE ou ressource is_apc = mod.is_apc() # SAE ou ressource
if is_apc: if is_apc:
coef_descr = ", ".join( mod_descr += " " + mod.get_ue_coefs_descr()
[
f"{ue.acronyme}: {co}"
for ue, co in mod.ue_coefs_list()
if isinstance(co, float) and co > 0
]
)
if coef_descr:
mod_descr += " Coefs: " + coef_descr
else:
mod_descr += " (pas de coefficients) "
else: else:
mod_descr += ", coef. " + str(mod.coefficient) mod_descr += ", coef. " + str(mod.coefficient)
mod_ens = sco_users.user_info(modimpl.responsable_id)["nomcomplet"] mod_ens = sco_users.user_info(modimpl.responsable_id)["nomcomplet"]

View File

@ -116,7 +116,7 @@ def formsemestre_validation_etud_form(
check = True check = True
etud_d = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud_d = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
Se = sco_cursus.get_situation_etud_cursus(etud_d, formsemestre_id) Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if not Se.sem["etat"]: if not Se.sem["etat"]:
raise ScoValueError("validation: semestre verrouille") raise ScoValueError("validation: semestre verrouille")
@ -262,8 +262,8 @@ def formsemestre_validation_etud_form(
return "\n".join(H + footer) return "\n".join(H + footer)
# Infos si pas de semestre précédent # Infos si pas de semestre précédent
if not Se.prev: if not Se.prev_formsemestre:
if Se.sem["semestre_id"] == 1: if Se.cur_sem.semestre_id == 1:
H.append("<p>Premier semestre (pas de précédent)</p>") H.append("<p>Premier semestre (pas de précédent)</p>")
else: else:
H.append("<p>Pas de semestre précédent !</p>") H.append("<p>Pas de semestre précédent !</p>")
@ -274,7 +274,7 @@ def formsemestre_validation_etud_form(
f"""Le jury n'a pas statué sur le semestre précédent ! (<a href="{ f"""Le jury n'a pas statué sur le semestre précédent ! (<a href="{
url_for("notes.formsemestre_validation_etud_form", url_for("notes.formsemestre_validation_etud_form",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formsemestre_id=Se.prev["formsemestre_id"], formsemestre_id=Se.prev_formsemestre.id,
etudid=etudid) etudid=etudid)
}">le faire maintenant</a>) }">le faire maintenant</a>)
""" """
@ -310,9 +310,9 @@ def formsemestre_validation_etud_form(
H.append("</p>") H.append("</p>")
# Cas particulier pour ATJ: corriger precedent avant de continuer # Cas particulier pour ATJ: corriger precedent avant de continuer
if Se.prev_decision and Se.prev_decision["code"] == ATJ: if Se.prev_formsemestre and Se.prev_decision and Se.prev_decision["code"] == ATJ:
H.append( H.append(
"""<div class="sfv_warning"><p>La décision du semestre précédent est en f"""<div class="sfv_warning"><p>La décision du semestre précédent est en
<b>attente</b> à cause d\'un <b>problème d\'assiduité<b>.</p> <b>attente</b> à cause d\'un <b>problème d\'assiduité<b>.</p>
<p>Vous devez la corriger avant de continuer ce jury. Soit vous considérez que le <p>Vous devez la corriger avant de continuer ce jury. Soit vous considérez que le
problème d'assiduité n'est pas réglé et choisissez de ne pas valider le semestre problème d'assiduité n'est pas réglé et choisissez de ne pas valider le semestre
@ -320,14 +320,16 @@ def formsemestre_validation_etud_form(
l'assiduité.</p> l'assiduité.</p>
<form method="get" action="formsemestre_validation_etud_form"> <form method="get" action="formsemestre_validation_etud_form">
<input type="submit" value="Statuer sur le semestre précédent"/> <input type="submit" value="Statuer sur le semestre précédent"/>
<input type="hidden" name="formsemestre_id" value="%s"/> <input type="hidden" name="formsemestre_id" value="{Se.prev_formsemestre.id}"/>
<input type="hidden" name="etudid" value="%s"/> <input type="hidden" name="etudid" value="{etudid}"/>
<input type="hidden" name="desturl" value="formsemestre_validation_etud_form?etudid=%s&formsemestre_id=%s"/> <input type="hidden" name="desturl" value="{
url_for("notes.formsemestre_validation_etud_form",
etudid=etudid, formsemestre_id=formsemestre_id, scodoc_dept=g.scodoc_dept
)}"/>
""" """
% (Se.prev["formsemestre_id"], etudid, etudid, formsemestre_id)
) )
if sortcol: if sortcol:
H.append('<input type="hidden" name="sortcol" value="%s"/>' % sortcol) H.append(f"""<input type="hidden" name="sortcol" value="{sortcol}"/>""")
H.append("</form></div>") H.append("</form></div>")
H.append(html_sco_header.sco_footer()) H.append(html_sco_header.sco_footer())
@ -405,7 +407,7 @@ def formsemestre_validation_etud(
sortcol=None, sortcol=None,
): ):
"""Enregistre validation""" """Enregistre validation"""
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
# retrouve la decision correspondant au code: # retrouve la decision correspondant au code:
choices = Se.get_possible_choices(assiduite=True) choices = Se.get_possible_choices(assiduite=True)
@ -438,7 +440,7 @@ def formsemestre_validation_etud_manu(
"""Enregistre validation""" """Enregistre validation"""
if assidu: if assidu:
assidu = True assidu = True
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if code_etat in Se.parcours.UNUSED_CODES: if code_etat in Se.parcours.UNUSED_CODES:
raise ScoValueError("code decision invalide dans ce parcours") raise ScoValueError("code decision invalide dans ce parcours")
@ -494,32 +496,35 @@ def decisions_possible_rows(Se, assiduite, subtitle="", trclass=""):
choices = Se.get_possible_choices(assiduite=assiduite) choices = Se.get_possible_choices(assiduite=assiduite)
if not choices: if not choices:
return "" return ""
TitlePrev = "" prev_title = ""
if Se.prev: if Se.prev_formsemestre:
if Se.prev["semestre_id"] >= 0: if Se.prev_formsemestre.semestre_id >= 0:
TitlePrev = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.prev["semestre_id"]) prev_title = "%s%d" % (
Se.parcours.SESSION_ABBRV,
Se.prev_formsemestre.semestre_id,
)
else: else:
TitlePrev = "Prec." prev_title = "Prec."
if Se.sem["semestre_id"] >= 0: if Se.cur_sem.semestre_id >= 0:
TitleCur = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.sem["semestre_id"]) cur_title = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.cur_sem.semestre_id)
else: else:
TitleCur = Se.parcours.SESSION_NAME cur_title = Se.parcours.SESSION_NAME
H = [ H = [
'<tr class="%s titles"><th class="sfv_subtitle">%s</em></th>' '<tr class="%s titles"><th class="sfv_subtitle">%s</em></th>'
% (trclass, subtitle) % (trclass, subtitle)
] ]
if Se.prev: if Se.prev_formsemestre:
H.append("<th>Code %s</th>" % TitlePrev) H.append(f"<th>Code {prev_title}</th>")
H.append("<th>Code %s</th><th>Devenir</th></tr>" % TitleCur) H.append(f"<th>Code {cur_title}</th><th>Devenir</th></tr>")
for ch in choices: for ch in choices:
H.append( H.append(
"""<tr class="%s"><td title="règle %s"><input type="radio" name="codechoice" value="%s" onClick="document.getElementById('subut').disabled=false;">""" """<tr class="%s"><td title="règle %s"><input type="radio" name="codechoice" value="%s" onClick="document.getElementById('subut').disabled=false;">"""
% (trclass, ch.rule_id, ch.codechoice) % (trclass, ch.rule_id, ch.codechoice)
) )
H.append("%s </input></td>" % ch.explication) H.append("%s </input></td>" % ch.explication)
if Se.prev: if Se.prev_formsemestre:
H.append('<td class="centercell">%s</td>' % _dispcode(ch.new_code_prev)) H.append('<td class="centercell">%s</td>' % _dispcode(ch.new_code_prev))
H.append( H.append(
'<td class="centercell">%s</td><td>%s</td>' '<td class="centercell">%s</td><td>%s</td>'
@ -535,7 +540,6 @@ def formsemestre_recap_parcours_table(
etudid, etudid,
with_links=False, with_links=False,
with_all_columns=True, with_all_columns=True,
a_url="",
sem_info=None, sem_info=None,
show_details=False, show_details=False,
): ):
@ -576,14 +580,14 @@ def formsemestre_recap_parcours_table(
H.append("<th></th></tr>") H.append("<th></th></tr>")
num_sem = 0 num_sem = 0
for sem in situation_etud_cursus.get_semestres(): for formsemestre in situation_etud_cursus.formsemestres:
is_prev = situation_etud_cursus.prev and ( is_prev = situation_etud_cursus.prev_formsemestre and (
situation_etud_cursus.prev["formsemestre_id"] == sem["formsemestre_id"] situation_etud_cursus.prev_formsemestre.id == formsemestre.id
) )
is_cur = situation_etud_cursus.formsemestre_id == sem["formsemestre_id"] is_cur = situation_etud_cursus.formsemestre_id == formsemestre.id
num_sem += 1 num_sem += 1
dpv = sco_pv_dict.dict_pvjury(sem["formsemestre_id"], etudids=[etudid]) dpv = sco_pv_dict.dict_pvjury(formsemestre.id, etudids=[etudid])
pv = dpv["decisions"][0] pv = dpv["decisions"][0]
decision_sem = pv["decision_sem"] decision_sem = pv["decision_sem"]
decisions_ue = pv["decisions_ue"] decisions_ue = pv["decisions_ue"]
@ -592,7 +596,6 @@ def formsemestre_recap_parcours_table(
else: else:
ass = "" ass = ""
formsemestre = db.session.get(FormSemestre, sem["formsemestre_id"])
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if is_cur: if is_cur:
type_sem = "*" # now unused type_sem = "*" # now unused
@ -603,20 +606,24 @@ def formsemestre_recap_parcours_table(
else: else:
type_sem = "" type_sem = ""
class_sem = "sem_autre" class_sem = "sem_autre"
if sem["formation_code"] != situation_etud_cursus.formation.formation_code: if (
formsemestre.formation.formation_code
!= situation_etud_cursus.formation.formation_code
):
class_sem += " sem_autre_formation" class_sem += " sem_autre_formation"
if sem["bul_bgcolor"]: bgcolor = (
bgcolor = sem["bul_bgcolor"] formsemestre.bul_bgcolor
else: if formsemestre.bul_bgcolor
bgcolor = "background-color: rgb(255,255,240)" else "background-color: rgb(255,255,240)"
)
# 1ere ligne: titre sem, decision, acronymes UE # 1ere ligne: titre sem, decision, acronymes UE
H.append('<tr class="%s rcp_l1 sem_%s">' % (class_sem, sem["formsemestre_id"])) H.append('<tr class="%s rcp_l1 sem_%s">' % (class_sem, formsemestre.id))
if is_cur: if is_cur:
pm = "" pm = ""
elif is_prev: elif is_prev:
pm = minuslink % sem["formsemestre_id"] pm = minuslink % formsemestre.id
else: else:
pm = plusminus % sem["formsemestre_id"] pm = plusminus % formsemestre.id
inscr = formsemestre.etuds_inscriptions.get(etudid) inscr = formsemestre.etuds_inscriptions.get(etudid)
parcours_name = "" parcours_name = ""
@ -638,9 +645,12 @@ def formsemestre_recap_parcours_table(
H.append( H.append(
f""" f"""
<td class="rcp_type_sem" style="background-color:{bgcolor};">{num_sem}{pm}</td> <td class="rcp_type_sem" style="background-color:{bgcolor};">{num_sem}{pm}</td>
<td class="datedebut">{sem['mois_debut']}</td> <td class="datedebut">{formsemestre.mois_debut()}</td>
<td class="rcp_titre_sem"><a class="formsemestre_status_link" <td class="rcp_titre_sem"><a class="formsemestre_status_link"
href="{a_url}formsemestre_bulletinetud?formsemestre_id={formsemestre.id}&etudid={etudid}" href="{
url_for("notes.formsemestre_bulletinetud", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id, etudid=etudid
)}"
title="Bulletin de notes">{formsemestre.titre_annee()}{parcours_name}</a> title="Bulletin de notes">{formsemestre.titre_annee()}{parcours_name}</a>
""" """
) )
@ -675,7 +685,7 @@ def formsemestre_recap_parcours_table(
ues = [ ues = [
ue ue
for ue in ues for ue in ues
if etud_est_inscrit_ue(cnx, etudid, sem["formsemestre_id"], ue.id) if etud_est_inscrit_ue(cnx, etudid, formsemestre.id, ue.id)
or etud_ue_status[ue.id]["is_capitalized"] or etud_ue_status[ue.id]["is_capitalized"]
] ]
@ -697,7 +707,7 @@ def formsemestre_recap_parcours_table(
H.append("<td></td>") H.append("<td></td>")
H.append("</tr>") H.append("</tr>")
# 2eme ligne: notes # 2eme ligne: notes
H.append(f"""<tr class="{class_sem} rcp_l2 sem_{sem["formsemestre_id"]}">""") H.append(f"""<tr class="{class_sem} rcp_l2 sem_{formsemestre.id}">""")
H.append( H.append(
f"""<td class="rcp_type_sem" f"""<td class="rcp_type_sem"
style="background-color:{bgcolor};">&nbsp;</td>""" style="background-color:{bgcolor};">&nbsp;</td>"""
@ -706,21 +716,28 @@ def formsemestre_recap_parcours_table(
default_sem_info = '<span class="fontred">[sem. précédent]</span>' default_sem_info = '<span class="fontred">[sem. précédent]</span>'
else: else:
default_sem_info = "" default_sem_info = ""
if not sem["etat"]: # locked if not formsemestre.etat: # locked
lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") lockicon = scu.icontag("lock32_img", title="verrouillé", border="0")
default_sem_info += lockicon default_sem_info += lockicon
if sem["formation_code"] != situation_etud_cursus.formation.formation_code: if (
default_sem_info += f"""Autre formation: {sem["formation_code"]}""" formsemestre.formation.formation_code
!= situation_etud_cursus.formation.formation_code
):
default_sem_info += (
f"""Autre formation: {formsemestre.formation.formation_code}"""
)
H.append( H.append(
'<td class="datefin">%s</td><td class="sem_info">%s</td>' '<td class="datefin">%s</td><td class="sem_info">%s</td>'
% (sem["mois_fin"], sem_info.get(sem["formsemestre_id"], default_sem_info)) % (formsemestre.mois_fin(), sem_info.get(formsemestre.id, default_sem_info))
) )
# Moy Gen (sous le code decision) # Moy Gen (sous le code decision)
H.append( H.append(
f"""<td class="rcp_moy">{scu.fmt_note(nt.get_etud_moy_gen(etudid))}</td>""" f"""<td class="rcp_moy">{scu.fmt_note(nt.get_etud_moy_gen(etudid))}</td>"""
) )
# Absences (nb d'abs non just. dans ce semestre) # Absences (nb d'abs non just. dans ce semestre)
nbabsnj = sco_assiduites.get_assiduites_count(etudid, sem)[0] nbabsnj = sco_assiduites.formsemestre_get_assiduites_count(
etudid, formsemestre
)[0]
H.append(f"""<td class="rcp_abs">{nbabsnj}</td>""") H.append(f"""<td class="rcp_abs">{nbabsnj}</td>""")
# UEs # UEs
@ -767,26 +784,30 @@ def formsemestre_recap_parcours_table(
H.append("<td></td>") H.append("<td></td>")
if with_links: if with_links:
H.append( H.append(
'<td><a href="%sformsemestre_validation_etud_form?formsemestre_id=%s&etudid=%s">modifier</a></td>' f"""<td><a class="stdlink" href="{
% (a_url, sem["formsemestre_id"], etudid) url_for("notes.formsemestre_validation_etud_form", scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id, etudid=etudid
)}">modifier</a></td>"""
) )
H.append("</tr>") H.append("</tr>")
# 3eme ligne: ECTS # 3eme ligne: ECTS
if ( if (
sco_preferences.get_preference("bul_show_ects", sem["formsemestre_id"]) sco_preferences.get_preference("bul_show_ects", formsemestre.id)
or nt.parcours.ECTS_ONLY or nt.parcours.ECTS_ONLY
): ):
etud_ects_infos = nt.get_etud_ects_pot(etudid) # ECTS potentiels etud_ects_infos = nt.get_etud_ects_pot(etudid) # ECTS potentiels
H.append( H.append(
f"""<tr class="{class_sem} rcp_l2 sem_{sem["formsemestre_id"]}"> f"""<tr class="{class_sem} rcp_l2 sem_{formsemestre.id}">
<td class="rcp_type_sem" style="background-color:{bgcolor};">&nbsp;</td> <td class="rcp_type_sem" style="background-color:{bgcolor};">&nbsp;</td>
<td></td>""" <td></td>"""
) )
# Total ECTS (affiché sous la moyenne générale) # Total ECTS (affiché sous la moyenne générale)
H.append( H.append(
f"""<td class="sem_ects_tit"><a title="crédit acquis">ECTS:</a></td> f"""<td class="sem_ects_tit"><a title="crédit acquis">ECTS:</a></td>
<td class="sem_ects">{pv.get("sum_ects",0):2.2g} / {etud_ects_infos["ects_total"]:2.2g}</td> <td class="sem_ects">{
pv.get("sum_ects",0):2.2g} / {etud_ects_infos["ects_total"]:2.2g
}</td>
<td class="rcp_abs"></td> <td class="rcp_abs"></td>
""" """
) )
@ -865,7 +886,7 @@ def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None
# précédent n'est pas géré dans ScoDoc (code ADC_) # précédent n'est pas géré dans ScoDoc (code ADC_)
# log(str(Se.sems)) # log(str(Se.sems))
for sem in Se.sems: for sem in Se.sems:
if sem["can_compensate"]: if sem["formsemestre_id"] in Se.can_compensate:
H.append( H.append(
'<option value="%s_%s">Admis par compensation avec S%s (%s)</option>' '<option value="%s_%s">Admis par compensation avec S%s (%s)</option>'
% ( % (
@ -882,7 +903,7 @@ def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None
H.append("</select></td></tr>") H.append("</select></td></tr>")
# Choix code semestre precedent: # Choix code semestre precedent:
if Se.prev: if Se.prev_formsemestre:
H.append( H.append(
'<tr><td>Code semestre précédent: </td><td><select name="new_code_prev"><option value="">Choisir une décision...</option>' '<tr><td>Code semestre précédent: </td><td><select name="new_code_prev"><option value="">Choisir une décision...</option>'
) )
@ -975,7 +996,7 @@ def do_formsemestre_validation_auto(formsemestre_id):
conflicts = [] # liste des etudiants avec decision differente déjà saisie conflicts = [] # liste des etudiants avec decision differente déjà saisie
with sco_cache.DeferredSemCacheManager(): with sco_cache.DeferredSemCacheManager():
for etudid in etudids: for etudid in etudids:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
{"etudid": etudid, "formsemestre_id": formsemestre_id} {"etudid": etudid, "formsemestre_id": formsemestre_id}
@ -984,7 +1005,7 @@ def do_formsemestre_validation_auto(formsemestre_id):
# Conditions pour validation automatique: # Conditions pour validation automatique:
if ins["etat"] == scu.INSCRIT and ( if ins["etat"] == scu.INSCRIT and (
( (
(not Se.prev) (not Se.prev_formsemestre)
or ( or (
Se.prev_decision and Se.prev_decision["code"] in (ADM, ADC, ADJ) Se.prev_decision and Se.prev_decision["code"] in (ADM, ADC, ADJ)
) )
@ -1055,8 +1076,8 @@ def do_formsemestre_validation_auto(formsemestre_id):
f"""<li><a href="{ f"""<li><a href="{
url_for('notes.formsemestre_validation_etud_form', url_for('notes.formsemestre_validation_etud_form',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id,
etudid=etud["etudid"], check=1) etudid=etud.id, check=1)
}">{etud["nomprenom"]}</li>""" }">{etud_d["nomprenom"]}</li>"""
) )
H.append("</ul>") H.append("</ul>")
H.append( H.append(

214
app/scodoc/sco_gen_cal.py Normal file
View File

@ -0,0 +1,214 @@
"""
Génération d'un calendrier
(Classe abstraite à implémenter dans les classes filles)
"""
import datetime
from flask import render_template
import app.scodoc.sco_utils as scu
from app import g
class Jour:
"""
Représente un jour dans le calendrier
Permet d'obtenir les informations sur le jour
et générer une représentation html
"""
def __init__(self, date: datetime.date):
self.date = date
self.class_list: list[str] = []
if self.is_non_work():
self.class_list.append("non-travail")
if self.is_current_week():
self.class_list.append("sem-courante")
def get_nom(self, short=True):
"""
Renvoie le nom du jour
"M19" ou "Mer 19"
par défaut en version courte
"""
str_jour: str = scu.DAY_NAMES[self.date.weekday()].capitalize()
return (
f"{str_jour[0] if short or self.is_non_work() else str_jour[:3]+' '}"
+ f"{self.date.day}"
)
def is_non_work(self):
"""
Renvoie True si le jour est un jour non travaillé
(en fonction de la préférence du département)
"""
return self.date.weekday() in scu.NonWorkDays.get_all_non_work_days(
dept_id=g.scodoc_dept_id
)
def is_current_week(self):
"""
Renvoie True si le jour est dans la semaine courante
"""
return self.date.isocalendar()[0:2] == datetime.date.today().isocalendar()[0:2]
def get_date(self, fmt=scu.DATE_FMT) -> str:
"""
Renvoie la date du jour au format fmt ou "dd/mm/yyyy" par défaut
"""
return self.date.strftime(fmt)
def get_html(self):
"""
Renvoie le code html du jour
à surcharger dans les classes filles
l'html final ressemblera à :
<div class="jour {{jour.get_class()}}">
<span class="nom">{{jour.get_nom()}}</span>
<div class="contenu">
{{jour.get_html() | safe}}
</div>
</div>
"""
raise NotImplementedError("Méthode à implémenter dans les classes filles")
def get_class(self):
"""
Renvoie la classe css du jour
utilise self.class_list
-> fait un join de la liste
"""
return " ".join(self.class_list)
class Calendrier:
"""
Représente un calendrier
Permet d'obtenir les informations sur les jours
et générer une représentation html
highlight: str
-> ["jour", "semaine", "mois"]
permet de mettre en valeur lors du passage de la souris
"""
def __init__(
self,
date_debut: datetime.date,
date_fin: datetime.date,
highlight: str = None,
):
self.date_debut = date_debut
self.date_fin = date_fin
self.jours: dict[str, list[Jour]] = {}
self.highlight: str = highlight
def _get_dates_between(self) -> list[datetime.date]:
"""
get_dates_between Renvoie la liste des dates entre date_debut et date_fin
Returns:
list[datetime.date]: liste des dates entre date_debut et date_fin
"""
resultat = []
date_actuelle: datetime.date = self.date_debut
while date_actuelle <= self.date_fin:
if isinstance(date_actuelle, datetime.datetime):
resultat.append(date_actuelle.date())
elif isinstance(date_actuelle, datetime.date):
resultat.append(date_actuelle)
date_actuelle += datetime.timedelta(days=1)
return resultat
def organize_by_month(self):
"""
Organise les jours par mois
Instancie un objet Jour pour chaque jour
met à jour self.jours
"""
organized = {}
for date in self._get_dates_between():
# Récupérer le mois en français
month = scu.MONTH_NAMES_ABBREV[date.month - 1]
# Ajouter le jour à la liste correspondante au mois
if month not in organized:
organized[month] = {} # semaine {22: []}
jour: Jour = self.instanciate_jour(date)
semaine = date.strftime("%G-W%V")
if semaine not in organized[month]:
organized[month][semaine] = []
organized[month][semaine].append(jour)
self.jours = organized
def instanciate_jour(self, date: datetime.date) -> Jour:
"""
Instancie un objet Jour pour chaque jour
A surcharger dans les classes filles si besoin
"""
raise NotImplementedError("Méthode à implémenter dans les classes filles")
def get_html(self):
"""
get_html Renvoie le code html du calendrier
"""
self.organize_by_month()
return render_template(
"calendrier.j2", calendrier=self.jours, highlight=self.highlight
)
class JourChoix(Jour):
"""
Représente un jour dans le calendrier pour choisir une date
"""
def get_html(self):
return ""
class CalendrierChoix(Calendrier):
"""
Représente un calendrier pour choisir une date
"""
def instanciate_jour(self, date: datetime.date) -> Jour:
return JourChoix(date)
def calendrier_choix_date(
date_debut: datetime.date,
date_fin: datetime.date,
url: str,
mode: str = "jour",
titre: str = "Choisir une date",
):
"""
Permet d'afficher un calendrier pour choisir une date et renvoyer sur une url.
mode : str
- "jour" -> ajoutera "&day=yyyy-mm-dd" à l'url (ex: 2024-05-30)
- "semaine" -> ajoutera "&week=yyyy-Www" à l'url (ex : 2024-W22)
titre : str
- texte à afficher au dessus du calendrier
"""
calendrier: CalendrierChoix = CalendrierChoix(date_debut, date_fin, highlight=mode)
return render_template(
"choix_date.j2",
calendrier=calendrier.get_html(),
url=url,
titre=titre,
mode=mode,
)

View File

@ -466,9 +466,9 @@ def etud_add_group_infos(
etud['groupes'] = "TDB, Gr2, TPB1" etud['groupes'] = "TDB, Gr2, TPB1"
etud['partitionsgroupes'] = "Groupes TD:TDB, Groupes TP:Gr2 (...)" etud['partitionsgroupes'] = "Groupes TD:TDB, Groupes TP:Gr2 (...)"
""" """
etud[ etud["partitions"] = (
"partitions" collections.OrderedDict()
] = collections.OrderedDict() # partition_id : group + partition_name ) # partition_id : group + partition_name
if not formsemestre_id: if not formsemestre_id:
etud["groupes"] = "" etud["groupes"] = ""
return etud return etud
@ -1409,21 +1409,17 @@ def groups_auto_repartition(partition: Partition):
return flask.redirect(dest_url) return flask.redirect(dest_url)
def _get_prev_moy(etudid, formsemestre_id): def _get_prev_moy(etudid: int, formsemestre_id: int) -> float | str:
"""Donne la derniere moyenne generale calculee pour cette étudiant, """Donne la derniere moyenne generale calculee pour cette étudiant,
ou 0 si on n'en trouve pas (nouvel inscrit,...). ou 0 si on n'en trouve pas (nouvel inscrit,...).
""" """
info = sco_etud.get_etud_info(etudid=etudid, filled=True) etud = Identite.get_etud(etudid)
if not info:
raise ScoValueError("etudiant invalide: etudid=%s" % etudid)
etud = info[0]
Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
if Se.prev: if Se.prev_formsemestre:
prev_sem = db.session.get(FormSemestre, Se.prev["formsemestre_id"]) prev_sem = db.session.get(FormSemestre, Se.prev_formsemestre.id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_sem) nt: NotesTableCompat = res_sem.load_formsemestre_results(prev_sem)
return nt.get_etud_moy_gen(etudid) return nt.get_etud_moy_gen(etud.id)
else: return 0.0
return 0.0
def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"): def create_etapes_partition(formsemestre_id, partition_name="apo_etapes"):

View File

@ -27,10 +27,8 @@
"""Exports groupes """Exports groupes
""" """
from flask import request
from app.scodoc import notesdb as ndb from app.scodoc import notesdb as ndb
from app.scodoc import sco_excel
from app.scodoc import sco_groups_view from app.scodoc import sco_groups_view
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc.gen_tables import GenTable from app.scodoc.gen_tables import GenTable
@ -83,14 +81,13 @@ def groups_export_annotations(group_ids, formsemestre_id=None, fmt="html"):
"date_str": "Date", "date_str": "Date",
"comment": "Annotation", "comment": "Annotation",
}, },
origin="Généré par %s le " % sco_version.SCONAME origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
+ scu.timedate_human_repr()
+ "",
page_title=f"Annotations sur les étudiants de {groups_infos.groups_titles}", page_title=f"Annotations sur les étudiants de {groups_infos.groups_titles}",
caption="Annotations", caption="Annotations",
base_url=groups_infos.base_url, base_url=groups_infos.base_url,
html_sortable=True, html_sortable=True,
html_class="table_leftalign", html_class="table_leftalign",
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="groups_export_annotations",
) )
return table.make_page(fmt=fmt) return table.make_page(fmt=fmt)

View File

@ -26,7 +26,7 @@
############################################################################## ##############################################################################
"""Affichage étudiants d'un ou plusieurs groupes """Affichage étudiants d'un ou plusieurs groupes
sous forme: de liste html (table exportable), de trombinoscope (exportable en pdf) sous forme: de liste html (table exportable), de trombinoscope (exportable en pdf)
""" """
# Re-ecriture en 2014 (re-organisation de l'interface, modernisation du code) # Re-ecriture en 2014 (re-organisation de l'interface, modernisation du code)
@ -39,7 +39,7 @@ from flask import url_for, g, request
from flask_login import current_user from flask_login import current_user
from app import db from app import db
from app.models import FormSemestre from app.models import FormSemestre, Identite
import app.scodoc.sco_utils as scu import app.scodoc.sco_utils as scu
from app.scodoc import html_sco_header from app.scodoc import html_sco_header
from app.scodoc import sco_excel from app.scodoc import sco_excel
@ -661,6 +661,7 @@ def groups_table(
text_fields_separator=prefs["moodle_csv_separator"], text_fields_separator=prefs["moodle_csv_separator"],
text_with_titles=prefs["moodle_csv_with_headerline"], text_with_titles=prefs["moodle_csv_with_headerline"],
preferences=prefs, preferences=prefs,
table_id="groups_table",
) )
# #
if fmt == "html": if fmt == "html":
@ -861,21 +862,25 @@ def groups_table(
# et ajoute infos inscription # et ajoute infos inscription
for m in groups_infos.members: for m in groups_infos.members:
etud_info = sco_etud.get_etud_info(m["etudid"], filled=True)[0] etud_info = sco_etud.get_etud_info(m["etudid"], filled=True)[0]
# TODO utiliser Identite
etud = Identite.get_etud(m["etudid"])
m.update(etud_info) m.update(etud_info)
sco_etud.etud_add_lycee_infos(etud_info) sco_etud.etud_add_lycee_infos(etud_info)
# et ajoute le parcours # et ajoute le parcours
Se = sco_cursus.get_situation_etud_cursus( Se = sco_cursus.get_situation_etud_cursus(
etud_info, groups_infos.formsemestre_id etud, groups_infos.formsemestre_id
) )
m["parcours"] = Se.get_cursus_descr() m["parcours"] = Se.get_cursus_descr()
m["code_cursus"], _ = sco_report.get_code_cursus_etud( m["code_cursus"], _ = sco_report.get_code_cursus_etud(
etud_info["etudid"], sems=etud_info["sems"] etud.id, formsemestres=etud.get_formsemestres()
) )
# TODO utiliser Identite:
rows = [[m.get(k, "") for k in keys] for m in groups_infos.members] rows = [[m.get(k, "") for k in keys] for m in groups_infos.members]
title = "etudiants_%s" % groups_infos.groups_filename title = f"etudiants_{groups_infos.groups_filename}"
xls = sco_excel.excel_simple_table(titles=titles, lines=rows, sheet_name=title) xls = sco_excel.excel_simple_table(titles=titles, lines=rows, sheet_name=title)
filename = title return scu.send_file(
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE) xls, filename=title, suffix=scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE
)
else: else:
raise ScoValueError("unsupported format") raise ScoValueError("unsupported format")
@ -977,16 +982,16 @@ def form_choix_jour_saisie_hebdo(groups_infos, moduleimpl_id=None):
if not authuser.has_permission(Permission.AbsChange): if not authuser.has_permission(Permission.AbsChange):
return "" return ""
return f""" return f"""
<button onclick="window.location='{ <a class="stdlink" href="{
url_for( url_for(
"assiduites.signal_assiduites_group", "assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
group_ids=",".join(map(str,groups_infos.group_ids)), group_ids=",".join(map(str,groups_infos.group_ids)),
jour=datetime.date.today().isoformat(), day=datetime.date.today().isoformat(),
formsemestre_id=groups_infos.formsemestre_id, formsemestre_id=groups_infos.formsemestre_id,
moduleimpl_id="" if moduleimpl_id is None else moduleimpl_id moduleimpl_id="" if moduleimpl_id is None else moduleimpl_id
) )
}';">Saisie du jour ({datetime.date.today().strftime('%d/%m/%Y')})</button> }">Saisie du jour ({datetime.date.today().strftime('%d/%m/%Y')})</a>
""" """
@ -997,16 +1002,16 @@ def form_choix_saisie_semaine(groups_infos):
return "" return ""
query_args = parse_qs(request.query_string) query_args = parse_qs(request.query_string)
moduleimpl_id = query_args.get("moduleimpl_id", [None])[0] moduleimpl_id = query_args.get("moduleimpl_id", [None])[0]
semaine = datetime.date.today().isocalendar().week semaine = datetime.datetime.now().strftime("%G-W%V")
return f""" return f"""
<button onclick="window.location='{url_for( <a class="stdlink" href="{url_for(
"assiduites.signal_assiduites_diff", "assiduites.signal_assiduites_hebdo",
group_ids=",".join(map(str,groups_infos.group_ids)), group_ids=",".join(map(str,groups_infos.group_ids)),
semaine=semaine, week=semaine,
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
formsemestre_id=groups_infos.formsemestre_id, formsemestre_id=groups_infos.formsemestre_id,
moduleimpl_id=moduleimpl_id moduleimpl_id=moduleimpl_id
)}';">Saisie à la semaine</button> )}">Saisie à la semaine (semaine {''.join(semaine[-2:])})</a>
""" """
@ -1028,10 +1033,9 @@ def export_groups_as_moodle_csv(formsemestre_id=None):
moodle_sem_name = sem["session_id"] moodle_sem_name = sem["session_id"]
columns_ids = ("email", "semestre_groupe") columns_ids = ("email", "semestre_groupe")
T = [] rows = []
for partition_id in partitions_etud_groups: for partition_id, members in partitions_etud_groups.items():
partition = sco_groups.get_partition(partition_id) partition = sco_groups.get_partition(partition_id)
members = partitions_etud_groups[partition_id]
for etudid in members: for etudid in members:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
group_name = members[etudid]["group_name"] group_name = members[etudid]["group_name"]
@ -1040,16 +1044,17 @@ def export_groups_as_moodle_csv(formsemestre_id=None):
elts.append(partition["partition_name"]) elts.append(partition["partition_name"])
if group_name: if group_name:
elts.append(group_name) elts.append(group_name)
T.append({"email": etud["email"], "semestre_groupe": "-".join(elts)}) rows.append({"email": etud["email"], "semestre_groupe": "-".join(elts)})
# Make table # Make table
prefs = sco_preferences.SemPreferences(formsemestre_id) prefs = sco_preferences.SemPreferences(formsemestre_id)
tab = GenTable( tab = GenTable(
rows=T,
columns_ids=("email", "semestre_groupe"), columns_ids=("email", "semestre_groupe"),
filename=moodle_sem_name + "-moodle", filename=moodle_sem_name + "-moodle",
titles={x: x for x in columns_ids}, preferences=prefs,
rows=rows,
text_fields_separator=prefs["moodle_csv_separator"], text_fields_separator=prefs["moodle_csv_separator"],
text_with_titles=prefs["moodle_csv_with_headerline"], text_with_titles=prefs["moodle_csv_with_headerline"],
preferences=prefs, table_id="export_groups_as_moodle_csv",
titles={x: x for x in columns_ids},
) )
return tab.make_page(fmt="csv") return tab.make_page(fmt="csv")

View File

@ -834,11 +834,12 @@ def adm_table_description_format():
columns_ids = ("attribute", "type", "writable", "description", "aliases_str") columns_ids = ("attribute", "type", "writable", "description", "aliases_str")
tab = GenTable( tab = GenTable(
titles=titles,
columns_ids=columns_ids, columns_ids=columns_ids,
rows=list(Fmt.values()),
html_sortable=True,
html_class="table_leftalign", html_class="table_leftalign",
html_sortable=True,
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
rows=list(Fmt.values()),
table_id="adm_table_description_format",
titles=titles,
) )
return tab return tab

View File

@ -747,10 +747,11 @@ def etuds_select_box_xls(src_cat):
else: else:
e["paiementinscription_str"] = "-" e["paiementinscription_str"] = "-"
tab = GenTable( tab = GenTable(
titles=titles,
columns_ids=columns_ids,
rows=etuds,
caption="%(title)s. %(help)s" % src_cat["infos"], caption="%(title)s. %(help)s" % src_cat["infos"],
columns_ids=columns_ids,
preferences=sco_preferences.SemPreferences(), preferences=sco_preferences.SemPreferences(),
rows=etuds,
table_id="etuds_select_box_xls",
titles=titles,
) )
return tab.excel() # tab.make_page(filename=src_cat["infos"]["filename"]) return tab.excel() # tab.make_page(filename=src_cat["infos"]["filename"])

View File

@ -599,20 +599,21 @@ def _make_table_notes(
) )
# display # display
tab = GenTable( tab = GenTable(
titles=titles,
columns_ids=columns_ids,
rows=rows,
html_sortable=True,
base_url=base_url, base_url=base_url,
filename=filename,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
caption=caption, caption=caption,
html_next_section=html_next_section, columns_ids=columns_ids,
page_title="Notes de " + formsemestre.titre_mois(), filename=filename,
html_title=html_title,
pdf_title=pdf_title,
html_class="notes_evaluation", html_class="notes_evaluation",
html_next_section=html_next_section,
html_sortable=True,
html_title=html_title,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
page_title="Notes de " + formsemestre.titre_mois(),
pdf_title=pdf_title,
preferences=sco_preferences.SemPreferences(formsemestre.id), preferences=sco_preferences.SemPreferences(formsemestre.id),
rows=rows,
table_id="table-liste-notes",
titles=titles,
# html_generate_cells=False # la derniere ligne (moyennes) est incomplete # html_generate_cells=False # la derniere ligne (moyennes) est incomplete
) )
if fmt == "bordereau": if fmt == "bordereau":

View File

@ -180,6 +180,7 @@ def _table_etuds_lycees(etuds, group_lycees, title, preferences, no_links=False)
html_class="table_leftalign table_listegroupe", html_class="table_leftalign table_listegroupe",
bottom_titles=bottom_titles, bottom_titles=bottom_titles,
preferences=preferences, preferences=preferences,
table_id="table_etuds_lycees",
) )
return tab, etuds_by_lycee return tab, etuds_by_lycee

View File

@ -64,7 +64,7 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str:
can_edit_notes_ens = modimpl.can_edit_notes(current_user) can_edit_notes_ens = modimpl.can_edit_notes(current_user)
if can_edit_notes and nbnotes != 0: if can_edit_notes and nbnotes != 0:
sup_label = "Supprimer évaluation impossible (il y a des notes)" sup_label = "Suppression évaluation impossible (il y a des notes)"
else: else:
sup_label = "Supprimer évaluation" sup_label = "Supprimer évaluation"
@ -146,29 +146,48 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str:
return htmlutils.make_menu("actions", menu_eval, alone=True) return htmlutils.make_menu("actions", menu_eval, alone=True)
def _ue_coefs_html(coefs_lst) -> str: def _ue_coefs_html(modimpl: ModuleImpl) -> str:
""" """ """ """
max_coef = max([x[1] for x in coefs_lst]) if coefs_lst else 1.0 coefs_lst = modimpl.module.ue_coefs_list()
H = """ max_coef = max(x[1] for x in coefs_lst) if coefs_lst else 1.0
H = f"""
<div id="modimpl_coefs"> <div id="modimpl_coefs">
<div>Coefficients vers les UE</div> <div>Coefficients vers les UEs
""" <span><a class="stdlink" href="{
if coefs_lst: url_for(
H += ( "notes.edit_modules_ue_coefs",
f""" scodoc_dept=g.scodoc_dept,
<div class="coefs_histo" style="--max:{max_coef}"> formation_id=modimpl.module.formation.id,
""" semestre_idx=modimpl.formsemestre.semestre_id,
+ "\n".join(
[
f"""<div style="--coef:{coef};
{'background-color: ' + ue.color + ';' if ue.color else ''}
"><div>{coef}</div>{ue.acronyme}</div>"""
for ue, coef in coefs_lst
if coef > 0
]
) )
+ "</div>" }">détail</a>
</span>
</div>
"""
if coefs_lst:
H += _html_hinton_map(
colors=(uc[0].color for uc in coefs_lst),
max_val=max_coef,
size=36,
title=modimpl.module.get_ue_coefs_descr(),
values=(uc[1] for uc in coefs_lst),
) )
# (
# f"""
# <div class="coefs_histo" style="--max:{max_coef}">
# """
# + "\n".join(
# [
# f"""<div style="--coef:{coef};
# {'background-color: ' + ue.color + ';' if ue.color else ''}
# "><div>{coef}</div>{ue.acronyme}</div>"""
# for ue, coef in coefs_lst
# if coef > 0
# ]
# )
# + "</div>"
# )
else: else:
H += """<div class="missing_value">non définis</div>""" H += """<div class="missing_value">non définis</div>"""
H += "</div>" H += "</div>"
@ -195,13 +214,22 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
Evaluation.date_debut.desc(), Evaluation.date_debut.desc(),
).all() ).all()
nb_evaluations = len(evaluations) nb_evaluations = len(evaluations)
max_poids = max( # Le poids max pour chaque catégorie d'évaluation
[ max_poids_by_type: dict[int, float] = {}
max([p.poids for p in e.ue_poids] or [0]) * (e.coefficient or 0.0) for eval_type in (
for e in evaluations Evaluation.EVALUATION_NORMALE,
] Evaluation.EVALUATION_RATTRAPAGE,
or [0] Evaluation.EVALUATION_SESSION2,
) Evaluation.EVALUATION_BONUS,
):
max_poids_by_type[eval_type] = max(
[
max([p.poids for p in e.ue_poids] or [0]) * (e.coefficient or 0.0)
for e in evaluations
if e.evaluation_type == eval_type
]
or [0.0]
)
# #
sem_locked = not formsemestre.etat sem_locked = not formsemestre.etat
can_edit_evals = ( can_edit_evals = (
@ -265,7 +293,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
H.append(scu.icontag("lock32_img", title="verrouillé")) H.append(scu.icontag("lock32_img", title="verrouillé"))
H.append("""</td><td class="fichetitre2">""") H.append("""</td><td class="fichetitre2">""")
if modimpl.module.is_apc(): if modimpl.module.is_apc():
H.append(_ue_coefs_html(modimpl.module.ue_coefs_list())) H.append(_ue_coefs_html(modimpl))
else: else:
H.append( H.append(
f"""Coef. dans le semestre: { f"""Coef. dans le semestre: {
@ -318,12 +346,28 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
f""" f"""
<span class="moduleimpl_abs_link"><a class="stdlink" href="{ <span class="moduleimpl_abs_link"><a class="stdlink" href="{
url_for("assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept) url_for("assiduites.signal_assiduites_group", scodoc_dept=g.scodoc_dept)
}?group_ids={group_id}&jour={ }?group_ids={group_id}&day={
datetime.date.today().isoformat() datetime.date.today().isoformat()
}&formsemestre_id={formsemestre.id} }&formsemestre_id={formsemestre.id}
&moduleimpl_id={moduleimpl_id} &moduleimpl_id={moduleimpl_id}
" "
>Saisie Absences journée</a></span> >Saisie Absences</a></span>
"""
)
current_week: str = datetime.datetime.now().strftime("%G-W%V")
H.append(
f"""
<span class="moduleimpl_abs_link"><a class="stdlink" href="{
url_for("assiduites.signal_assiduites_hebdo",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
group_ids=group_id,
week=current_week,
moduleimpl_id=moduleimpl_id
)
}
"
>Saisie Absences (Hebdo)</a></span>
""" """
) )
H.append( H.append(
@ -335,8 +379,8 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
group_ids=group_id, group_ids=group_id,
formsemestre_id=formsemestre.id, formsemestre_id=formsemestre.id,
moduleimpl_id="" if moduleimpl_id is None else moduleimpl_id moduleimpl_id="" if moduleimpl_id is None else moduleimpl_id
)}" )}" title="Page en cours de fusion et sera prochainement supprimée. Veuillez utiliser la page `Saisie Absences`"
>Saisie Absences Différée</a></span> >(Saisie Absences Différée)</a></span>
""" """
) )
@ -344,9 +388,34 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
# #
if not modimpl.check_apc_conformity(nt): if not modimpl.check_apc_conformity(nt):
H.append( H.append(
"""<div class="warning conformite">Les poids des évaluations de ce module ne sont """<div class="warning conformite">Les poids des évaluations de ce
pas encore conformes au PN. module ne permettent pas d'évaluer toutes les UEs (compétences)
Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE. prévues par les coefficients du programme.
<b>Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE.</b>
Vérifiez les poids des évaluations.
</div>"""
)
if not modimpl.check_apc_conformity(
nt, evaluation_type=Evaluation.EVALUATION_SESSION2
):
H.append(
"""<div class="warning conformite">
Il y a des évaluations de <b>deuxième session</b>
mais leurs poids ne permettent pas d'évaluer toutes les UEs (compétences)
prévues par les coefficients du programme.
La deuxième session ne sera donc <b>pas prise en compte</b>.
Vérifiez les poids de ces évaluations.
</div>"""
)
if not modimpl.check_apc_conformity(
nt, evaluation_type=Evaluation.EVALUATION_RATTRAPAGE
):
H.append(
"""<div class="warning conformite">
Il y a des évaluations de <b>rattrapage</b>
mais leurs poids n'évaluent pas toutes les UEs (compétences)
prévues par les coefficients du programme.
Vérifiez les poids de ces évaluations.
</div>""" </div>"""
) )
@ -437,7 +506,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
eval_index=eval_index, eval_index=eval_index,
nb_evals=nb_evaluations, nb_evals=nb_evaluations,
is_apc=nt.is_apc, is_apc=nt.is_apc,
max_poids=max_poids, max_poids=max_poids_by_type.get(evaluation.evaluation_type, 10000.0),
) )
) )
eval_index -= 1 eval_index -= 1
@ -756,27 +825,27 @@ def _ligne_evaluation(
# #
if etat["nb_notes"] == 0: if etat["nb_notes"] == 0:
H.append(f"""<tr class="{tr_class}"><td></td>""") H.append(f"""<tr class="{tr_class}"><td></td>""")
if modimpl.module.is_apc(): # if modimpl.module.is_apc():
H.append( # H.append(
f"""<td colspan="8" class="eval_poids">{ # f"""<td colspan="8" class="eval_poids">{
evaluation.get_ue_poids_str()}</td>""" # evaluation.get_ue_poids_str()}</td>"""
) # )
else: # else:
H.append('<td colspan="8"></td>') # H.append('<td colspan="8"></td>')
H.append("""</tr>""") H.append("""</tr>""")
else: # il y a deja des notes saisies else: # il y a deja des notes saisies
gr_moyennes = etat["gr_moyennes"] gr_moyennes = etat["gr_moyennes"]
first_group = True # first_group = True
for gr_moyenne in gr_moyennes: for gr_moyenne in gr_moyennes:
H.append(f"""<tr class="{tr_class}"><td>&nbsp;</td>""") H.append(f"""<tr class="{tr_class}"><td>&nbsp;</td>""")
if first_group and modimpl.module.is_apc(): # if first_group and modimpl.module.is_apc():
H.append( # H.append(
f"""<td class="eval_poids" colspan="4">{ # f"""<td class="eval_poids" colspan="4">{
evaluation.get_ue_poids_str()}</td>""" # evaluation.get_ue_poids_str()}</td>"""
) # )
else: # else:
H.append("""<td colspan="4"></td>""") H.append("""<td colspan="4"></td>""")
first_group = False # first_group = False
if gr_moyenne["group_name"] is None: if gr_moyenne["group_name"] is None:
name = "Tous" # tous name = "Tous" # tous
else: else:
@ -832,26 +901,47 @@ def _evaluation_poids_html(evaluation: Evaluation, max_poids: float = 0.0) -> st
ue_poids = evaluation.get_ue_poids_dict(sort=True) # { ue_id : poids } ue_poids = evaluation.get_ue_poids_dict(sort=True) # { ue_id : poids }
if not ue_poids: if not ue_poids:
return "" return ""
if max_poids < scu.NOTES_PRECISION: values = [poids * (evaluation.coefficient) for poids in ue_poids.values()]
colors = [db.session.get(UniteEns, ue_id).color for ue_id in ue_poids]
return _html_hinton_map(
classes=("evaluation_poids",),
colors=colors,
max_val=max_poids,
title=f"Poids de l'évaluation vers les UEs: {evaluation.get_ue_poids_str()}",
values=values,
)
def _html_hinton_map(
classes=(),
colors=(),
max_val: float | None = None,
size=12,
title: str = "",
values=(),
) -> str:
"""Représente une liste de nombres sous forme de carrés"""
if max_val is None:
max_val = max(values)
if max_val < scu.NOTES_PRECISION:
return "" return ""
H = ( return (
"""<div class="evaluation_poids">""" f"""<div class="hinton_map {" ".join(classes)}"
style="--size:{size}px;"
title="{title}"
data-tooltip>"""
+ "\n".join( + "\n".join(
[ [
f"""<div title="poids vers {ue.acronyme}: {poids:g}"> f"""<div>
<div style="--size:{math.sqrt(poids*(evaluation.coefficient)/max_poids*144)}px; <div style="--boxsize:{size*math.sqrt(value/max_val)}px;
{'background-color: ' + ue.color + ';' if ue.color else ''} {'background-color: ' + color + ';' if color else ''}
"></div> "></div>
</div>""" </div>"""
for ue, poids in ( for value, color in zip(values, colors)
(db.session.get(UniteEns, ue_id), poids)
for ue_id, poids in ue_poids.items()
)
] ]
) )
+ "</div>" + "</div>"
) )
return H
def _html_modimpl_etuds_attente(res: ResultatsSemestre, modimpl: ModuleImpl) -> str: def _html_modimpl_etuds_attente(res: ResultatsSemestre, modimpl: ModuleImpl) -> str:

View File

@ -180,7 +180,7 @@ def fiche_etud(etudid=None):
) )
else: else:
info["etat_civil"] = "" info["etat_civil"] = ""
info["ScoURL"] = scu.ScoURL() info["ScoURL"] = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)
info["authuser"] = current_user info["authuser"] = current_user
if restrict_etud_data: if restrict_etud_data:
info["info_naissance"] = "" info["info_naissance"] = ""
@ -264,14 +264,13 @@ def fiche_etud(etudid=None):
sem_info[formsemestre.id] = grlink sem_info[formsemestre.id] = grlink
if inscriptions: if inscriptions:
Se = sco_cursus.get_situation_etud_cursus(info, info["last_formsemestre_id"]) Se = sco_cursus.get_situation_etud_cursus(etud, info["last_formsemestre_id"])
info["liste_inscriptions"] = formsemestre_recap_parcours_table( info["liste_inscriptions"] = formsemestre_recap_parcours_table(
Se, Se,
etudid, etudid,
with_links=False, with_links=False,
sem_info=sem_info, sem_info=sem_info,
with_all_columns=False, with_all_columns=False,
a_url="Notes/",
) )
info["link_bul_pdf"] = ( info["link_bul_pdf"] = (
"""<span class="link_bul_pdf fontred">PDF interdits par l'admin.</span>""" """<span class="link_bul_pdf fontred">PDF interdits par l'admin.</span>"""

View File

@ -247,6 +247,7 @@ class ScoDocPageTemplate(PageTemplate):
footer_template=DEFAULT_PDF_FOOTER_TEMPLATE, footer_template=DEFAULT_PDF_FOOTER_TEMPLATE,
filigranne=None, filigranne=None,
preferences=None, # dictionnary with preferences, required preferences=None, # dictionnary with preferences, required
with_page_numbers=False,
): ):
"""Initialise our page template.""" """Initialise our page template."""
# defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf # defered import (solve circular dependency ->sco_logo ->scodoc, ->sco_pdf
@ -259,8 +260,9 @@ class ScoDocPageTemplate(PageTemplate):
self.pdfmeta_subject = subject self.pdfmeta_subject = subject
self.server_name = server_name self.server_name = server_name
self.filigranne = filigranne self.filigranne = filigranne
self.page_number = 1
self.footer_template = footer_template self.footer_template = footer_template
self.with_page_numbers = with_page_numbers
self.page_number = 1
if self.preferences: if self.preferences:
self.with_page_background = self.preferences["bul_pdf_with_background"] self.with_page_background = self.preferences["bul_pdf_with_background"]
else: else:
@ -337,6 +339,7 @@ class ScoDocPageTemplate(PageTemplate):
def draw_footer(self, canv, content): def draw_footer(self, canv, content):
"""Print the footer""" """Print the footer"""
# called 1/page
try: try:
canv.setFont( canv.setFont(
self.preferences["SCOLAR_FONT"], self.preferences["SCOLAR_FONT"],
@ -351,8 +354,11 @@ class ScoDocPageTemplate(PageTemplate):
canv.drawString( canv.drawString(
self.preferences["pdf_footer_x"] * mm, self.preferences["pdf_footer_x"] * mm,
self.preferences["pdf_footer_y"] * mm, self.preferences["pdf_footer_y"] * mm,
content, content + " " + (self.preferences["pdf_footer_extra"] or ""),
) )
if self.with_page_numbers:
canv.drawString(190.0 * mm, 6 * mm, f"Page {self.page_number}")
canv.restoreState() canv.restoreState()
def footer_string(self) -> str: def footer_string(self) -> str:
@ -382,18 +388,14 @@ class ScoDocPageTemplate(PageTemplate):
filigranne = self.filigranne.get(doc.page, None) filigranne = self.filigranne.get(doc.page, None)
if filigranne: if filigranne:
canv.saveState() canv.saveState()
canv.translate(9 * cm, 27.6 * cm) canv.translate(10 * cm, 21.0 * cm)
canv.rotate(30) canv.rotate(36)
canv.scale(4.5, 4.5) canv.scale(7, 7)
canv.setFillColorRGB(1.0, 0.65, 0.65, alpha=0.6) canv.setFillColorRGB(1.0, 0.65, 0.65, alpha=0.6)
canv.drawRightString(0, 0, SU(filigranne)) canv.drawCentredString(0, 0, SU(filigranne))
canv.restoreState() canv.restoreState()
doc.filigranne = None doc.filigranne = None
# Increment page number
def afterPage(self):
"""Called after all flowables have been drawn on a page.
Increment pageNum since the page has been completed.
"""
self.page_number += 1 self.page_number += 1
@ -458,7 +460,12 @@ def pdf_basic_page(
if title: if title:
head = Paragraph(SU(title), StyleSheet["Heading3"]) head = Paragraph(SU(title), StyleSheet["Heading3"])
objects = [head] + objects objects = [head] + objects
document.build(objects)
try:
document.build(objects)
except (ValueError, KeyError, reportlab.platypus.doctemplate.LayoutError) as exc:
raise ScoPDFFormatError(str(exc)) from exc
data = report.getvalue() data = report.getvalue()
return data return data

View File

@ -96,13 +96,16 @@ def photo_portal_url(code_nip: str):
return None return None
def get_etud_photo_url(etudid, size="small"): def get_etud_photo_url(etudid, size="small", seed=None):
"L'URL scodoc vers la photo de l'étudiant"
kwargs = {"seed": seed} if seed else {}
return ( return (
url_for( url_for(
"scolar.get_photo_image", "scolar.get_photo_image",
scodoc_dept=g.scodoc_dept, scodoc_dept=g.scodoc_dept,
etudid=etudid, etudid=etudid,
size=size, size=size,
**kwargs,
) )
if has_request_context() if has_request_context()
else "" else ""
@ -114,9 +117,11 @@ def etud_photo_url(etud: dict, size="small", fast=False) -> str:
If ScoDoc doesn't have an image and a portal is configured, link to it. If ScoDoc doesn't have an image and a portal is configured, link to it.
""" """
photo_url = get_etud_photo_url(etud["etudid"], size=size)
if fast: if fast:
return photo_url return get_etud_photo_url(etud["etudid"], size=size)
photo_url = get_etud_photo_url(
etud["etudid"], size=size, seed=hash(etud.get("photo_filename"))
)
path = photo_pathname(etud["photo_filename"], size=size) path = photo_pathname(etud["photo_filename"], size=size)
if not path: if not path:
# Portail ? # Portail ?
@ -374,7 +379,15 @@ def copy_portal_photo_to_fs(etudid: int):
portal_timeout = sco_preferences.get_preference("portal_timeout") portal_timeout = sco_preferences.get_preference("portal_timeout")
error_message = None error_message = None
try: try:
r = requests.get(url, timeout=portal_timeout) r = requests.get(
url,
timeout=portal_timeout,
params={
"nom": etud.nom or "",
"prenom": etud.prenom or "",
"civilite": etud.civilite,
},
)
except requests.ConnectionError: except requests.ConnectionError:
error_message = "ConnectionError" error_message = "ConnectionError"
except requests.Timeout: except requests.Timeout:

View File

@ -378,6 +378,7 @@ class PlacementRunner:
preferences=sco_preferences.SemPreferences( preferences=sco_preferences.SemPreferences(
self.moduleimpl_data["formsemestre_id"] self.moduleimpl_data["formsemestre_id"]
), ),
table_id="placement_pdf",
) )
return tab.make_page(fmt="pdf", with_html_headers=False) return tab.make_page(fmt="pdf", with_html_headers=False)

View File

@ -221,6 +221,7 @@ def formsemestre_poursuite_report(formsemestre_id, fmt="html"):
html_class="table_leftalign table_listegroupe", html_class="table_leftalign table_listegroupe",
pdf_link=False, # pas d'export pdf pdf_link=False, # pas d'export pdf
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="formsemestre_poursuite_report",
) )
tab.filename = scu.make_filename("poursuite " + sem["titreannee"]) tab.filename = scu.make_filename("poursuite " + sem["titreannee"])

View File

@ -110,6 +110,7 @@ get_base_preferences(formsemestre_id)
Return base preferences for current scodoc_dept (instance BasePreferences) Return base preferences for current scodoc_dept (instance BasePreferences)
""" """
import flask import flask
from flask import current_app, flash, g, request, url_for from flask import current_app, flash, g, request, url_for
@ -611,26 +612,15 @@ class BasePreferences:
"explanation": "toute saisie d'absence doit indiquer le module concerné", "explanation": "toute saisie d'absence doit indiquer le module concerné",
}, },
), ),
# (
# "forcer_present",
# {
# "initvalue": 0,
# "title": "Forcer l'appel des présents",
# "input_type": "boolcheckbox",
# "labels": ["non", "oui"],
# "category": "assi",
# },
# ),
( (
"periode_defaut", "non_present",
{ {
"initvalue": 2.0, "initvalue": 0,
"size": 10, "title": "Désactiver la saisie des présences",
"title": "Durée par défaut d'un créneau", "input_type": "boolcheckbox",
"type": "float", "labels": ["non", "oui"],
"category": "assi", "category": "assi",
"only_global": True, "explanation": "Désactive la saisie et l'affichage des présences",
"explanation": "Durée d'un créneau en heure. Utilisé dans les pages de saisie",
}, },
), ),
( (
@ -644,18 +634,18 @@ class BasePreferences:
"category": "assi", "category": "assi",
}, },
), ),
( # (
"assi_etat_defaut", # "assi_etat_defaut",
{ # {
"explanation": "⚠ non fonctionnel, travaux en cours !", # "explanation": "⚠ non fonctionnel, travaux en cours !",
"initvalue": "aucun", # "initvalue": "aucun",
"input_type": "menu", # "input_type": "menu",
"labels": ["aucun", "present", "retard", "absent"], # "labels": ["aucun", "present", "retard", "absent"],
"allowed_values": ["aucun", "present", "retard", "absent"], # "allowed_values": ["aucun", "present", "retard", "absent"],
"title": "Définir l'état par défaut", # "title": "Définir l'état par défaut",
"category": "assi", # "category": "assi",
}, # },
), # ),
( (
"non_travail", "non_travail",
{ {
@ -962,6 +952,16 @@ class BasePreferences:
"category": "pdf", "category": "pdf",
}, },
), ),
(
"pdf_footer_extra",
{
"initvalue": "",
"title": "Texte à ajouter en pied de page",
"explanation": "sur tous les documents, par exemple vos coordonnées, ...",
"size": 78,
"category": "pdf",
},
),
( (
"pdf_footer_x", "pdf_footer_x",
{ {
@ -2260,16 +2260,17 @@ class BasePreferences:
before_table="<details><summary>{title}</summary>", before_table="<details><summary>{title}</summary>",
after_table="</details>", after_table="</details>",
) )
dest_url = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)
if tf[0] == 0: if tf[0] == 0:
return "\n".join(H) + tf[1] + html_sco_header.sco_footer() return "\n".join(H) + tf[1] + html_sco_header.sco_footer()
elif tf[0] == -1: if tf[0] == -1:
return flask.redirect(scu.ScoURL()) # cancel return flask.redirect(dest_url) # cancel
else: #
for pref in self.prefs_definition: for pref in self.prefs_definition:
self.prefs[None][pref[0]] = tf[2][pref[0]] self.prefs[None][pref[0]] = tf[2][pref[0]]
self.save() self.save()
flash("Préférences modifiées") flash("Préférences modifiées")
return flask.redirect(scu.ScoURL()) return flask.redirect(dest_url)
def build_tf_form(self, categories: list[str] = None, formsemestre_id: int = None): def build_tf_form(self, categories: list[str] = None, formsemestre_id: int = None):
"""Build list of elements for TrivialFormulator. """Build list of elements for TrivialFormulator.
@ -2290,9 +2291,7 @@ class BasePreferences:
if "explanation" in descr: if "explanation" in descr:
del descr["explanation"] del descr["explanation"]
if formsemestre_id: if formsemestre_id:
descr[ descr["explanation"] = f"""ou <span class="spanlink"
"explanation"
] = f"""ou <span class="spanlink"
onclick="set_global_pref(this, '{pref_name}');" onclick="set_global_pref(this, '{pref_name}');"
>utiliser paramètre global</span>""" >utiliser paramètre global</span>"""
if formsemestre_id and self.is_global(formsemestre_id, pref_name): if formsemestre_id and self.is_global(formsemestre_id, pref_name):
@ -2433,10 +2432,12 @@ function set_global_pref(el, pref_name) {
before_table="<details><summary>{title}</summary>", before_table="<details><summary>{title}</summary>",
after_table="</details>", after_table="</details>",
) )
dest_url = ( dest_url = url_for(
scu.NotesURL() "notes.formsemestre_status",
+ "/formsemestre_status?formsemestre_id=%s" % self.formsemestre_id scodoc_dept=g.scodoc_dept,
formsemestre_id=self.formsemestre_id,
) )
if tf[0] == 0: if tf[0] == 0:
return "\n".join(H) + tf[1] + html_sco_header.sco_footer() return "\n".join(H) + tf[1] + html_sco_header.sco_footer()
elif tf[0] == -1: elif tf[0] == -1:
@ -2482,7 +2483,9 @@ function set_global_pref(el, pref_name) {
request.base_url + "?formsemestre_id=" + str(self.formsemestre_id) request.base_url + "?formsemestre_id=" + str(self.formsemestre_id)
) )
elif destination == "global": elif destination == "global":
return flask.redirect(scu.ScoURL() + "/edit_preferences") return flask.redirect(
url_for("scolar.edit_preferences", scodoc_dept=g.scodoc_dept)
)
# #

View File

@ -81,14 +81,11 @@ def feuille_preparation_jury(formsemestre_id):
nbabs = {} nbabs = {}
nbabsjust = {} nbabsjust = {}
for etud in etuds: for etud in etuds:
Se = sco_cursus.get_situation_etud_cursus( Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
etud.to_dict_scodoc7(), formsemestre_id if Se.prev_formsemestre:
) ntp: NotesTableCompat = res_sem.load_formsemestre_results(
if Se.prev: Se.prev_formsemestre
formsemestre_prev = FormSemestre.query.get_or_404(
Se.prev["formsemestre_id"]
) )
ntp: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre_prev)
for ue in ntp.get_ues_stat_dict(filter_sport=True): for ue in ntp.get_ues_stat_dict(filter_sport=True):
ue_status = ntp.get_etud_ue_status(etud.id, ue["ue_id"]) ue_status = ntp.get_etud_ue_status(etud.id, ue["ue_id"])
ue_code_s = ( ue_code_s = (
@ -110,7 +107,7 @@ def feuille_preparation_jury(formsemestre_id):
moy_ue[ue_code_s][etud.id] = ue_status["moy"] if ue_status else "" moy_ue[ue_code_s][etud.id] = ue_status["moy"] if ue_status else ""
ue_acro[ue_code_s] = (ue["numero"], ue["acronyme"], ue["titre"]) ue_acro[ue_code_s] = (ue["numero"], ue["acronyme"], ue["titre"])
if Se.prev: if Se.prev_formsemestre:
try: try:
moy_inter[etud.id] = (moy[etud.id] + prev_moy[etud.id]) / 2.0 moy_inter[etud.id] = (moy[etud.id] + prev_moy[etud.id]) / 2.0
except (KeyError, TypeError): except (KeyError, TypeError):

View File

@ -42,7 +42,6 @@ from app.models import (
but_validations, but_validations,
) )
from app.scodoc import codes_cursus from app.scodoc import codes_cursus
from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre
from app.scodoc import sco_cursus from app.scodoc import sco_cursus
@ -81,6 +80,7 @@ def dict_pvjury(
}, },
'autorisations' : [ { 'semestre_id' : { ... } } ], 'autorisations' : [ { 'semestre_id' : { ... } } ],
'validation_parcours' : True si parcours validé (diplome obtenu) 'validation_parcours' : True si parcours validé (diplome obtenu)
'parcours' : 'S1, S2, S3, S4, A1',
'prev_code' : code (calculé slt si with_prev), 'prev_code' : code (calculé slt si with_prev),
'mention' : mention (en fct moy gen), 'mention' : mention (en fct moy gen),
'sum_ects' : total ECTS acquis dans ce semestre (incluant les UE capitalisées) 'sum_ects' : total ECTS acquis dans ce semestre (incluant les UE capitalisées)
@ -107,10 +107,10 @@ def dict_pvjury(
D = {} # même chose que decisions, mais { etudid : dec } D = {} # même chose que decisions, mais { etudid : dec }
for etudid in etudids: for etudid in etudids:
etud = Identite.get_etud(etudid) etud = Identite.get_etud(etudid)
Se = sco_cursus.get_situation_etud_cursus( situation_etud = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id)
etud.to_dict_scodoc7(), formsemestre_id semestre_non_terminal = (
semestre_non_terminal or situation_etud.semestre_non_terminal
) )
semestre_non_terminal = semestre_non_terminal or Se.semestre_non_terminal
d = {} d = {}
d["identite"] = nt.identdict[etudid] d["identite"] = nt.identdict[etudid]
d["etat"] = nt.get_etud_etat( d["etat"] = nt.get_etud_etat(
@ -120,9 +120,8 @@ def dict_pvjury(
d["decisions_ue"] = nt.get_etud_decisions_ue(etudid) d["decisions_ue"] = nt.get_etud_decisions_ue(etudid)
if formsemestre.formation.is_apc(): if formsemestre.formation.is_apc():
d.update(but_validations.dict_decision_jury(etud, formsemestre)) d.update(but_validations.dict_decision_jury(etud, formsemestre))
d["last_formsemestre_id"] = Se.get_semestres()[ # id du dernier semestre (chronologiquement) dans lequel il a été inscrit:
-1 d["last_formsemestre_id"] = situation_etud.get_semestres()[-1]
] # id du dernier semestre (chronologiquement) dans lequel il a été inscrit
ects_capitalises_by_ue_code = _comp_ects_capitalises_by_ue_code(nt, etudid) ects_capitalises_by_ue_code = _comp_ects_capitalises_by_ue_code(nt, etudid)
d["sum_ects_capitalises"] = sum(ects_capitalises_by_ue_code.values()) d["sum_ects_capitalises"] = sum(ects_capitalises_by_ue_code.values())
@ -162,10 +161,13 @@ def dict_pvjury(
d["autorisations"] = [a.to_dict() for a in autorisations] d["autorisations"] = [a.to_dict() for a in autorisations]
d["autorisations_descr"] = descr_autorisations(autorisations) d["autorisations_descr"] = descr_autorisations(autorisations)
d["validation_parcours"] = Se.parcours_validated() d["validation_parcours"] = situation_etud.parcours_validated()
d["parcours"] = Se.get_cursus_descr(filter_futur=True) d["parcours"] = situation_etud.get_cursus_descr(filter_futur=True)
d["parcours_in_cur_formation"] = situation_etud.get_cursus_descr(
filter_futur=True, filter_formation_code=True
)
if with_parcours_decisions: if with_parcours_decisions:
d["parcours_decisions"] = Se.get_parcours_decisions() d["parcours_decisions"] = situation_etud.get_parcours_decisions()
# Observations sur les compensations: # Observations sur les compensations:
compensators = sco_cursus_dut.scolar_formsemestre_validation_list( compensators = sco_cursus_dut.scolar_formsemestre_validation_list(
cnx, args={"compense_formsemestre_id": formsemestre_id, "etudid": etudid} cnx, args={"compense_formsemestre_id": formsemestre_id, "etudid": etudid}
@ -206,19 +208,19 @@ def dict_pvjury(
if not info: if not info:
continue # should not occur continue # should not occur
etud = info[0] etud = info[0]
if Se.prev and Se.prev_decision: if situation_etud.prev_formsemestre and situation_etud.prev_decision:
d["prev_decision_sem"] = Se.prev_decision d["prev_decision_sem"] = situation_etud.prev_decision
d["prev_code"] = Se.prev_decision["code"] d["prev_code"] = situation_etud.prev_decision["code"]
d["prev_code_descr"] = _descr_decision_sem( d["prev_code_descr"] = _descr_decision_sem(
scu.INSCRIT, Se.prev_decision scu.INSCRIT, situation_etud.prev_decision
) )
d["prev"] = Se.prev d["prev"] = situation_etud.prev_formsemestre.to_dict()
has_prev = True has_prev = True
else: else:
d["prev_decision_sem"] = None d["prev_decision_sem"] = None
d["prev_code"] = "" d["prev_code"] = ""
d["prev_code_descr"] = "" d["prev_code_descr"] = ""
d["Se"] = Se d["Se"] = situation_etud
decisions.append(d) decisions.append(d)
D[etudid] = d D[etudid] = d

View File

@ -149,7 +149,7 @@ def pvjury_table(
etudid=e["identite"]["etudid"], etudid=e["identite"]["etudid"],
), ),
"_nomprenom_td_attrs": f"""id="{e['identite']['etudid']}" class="etudinfo" """, "_nomprenom_td_attrs": f"""id="{e['identite']['etudid']}" class="etudinfo" """,
"parcours": e["parcours"], "parcours": e["parcours_in_cur_formation"],
"decision": _descr_decision_sem_abbrev(e["etat"], e["decision_sem"]), "decision": _descr_decision_sem_abbrev(e["etat"], e["decision_sem"]),
"ue_cap": e["decisions_ue_descr"], "ue_cap": e["decisions_ue_descr"],
"validation_parcours_code": "ADM" if e["validation_parcours"] else "", "validation_parcours_code": "ADM" if e["validation_parcours"] else "",
@ -252,6 +252,7 @@ def formsemestre_pvjury(formsemestre_id, fmt="html", publish=True):
html_class="table_leftalign", html_class="table_leftalign",
html_sortable=True, html_sortable=True,
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="formsemestre_pvjury",
) )
if fmt != "html": if fmt != "html":
return tab.make_page( return tab.make_page(
@ -312,6 +313,7 @@ def formsemestre_pvjury(formsemestre_id, fmt="html", publish=True):
html_sortable=True, html_sortable=True,
html_with_td_classes=True, html_with_td_classes=True,
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="formsemestre_pvjury_counts",
).html() ).html()
) )
H.append( H.append(

View File

@ -50,7 +50,7 @@ from app.scodoc import sco_bulletins_pdf
from app.scodoc import sco_pv_dict from app.scodoc import sco_pv_dict
from app.scodoc import sco_pdf from app.scodoc import sco_pdf
from app.scodoc import sco_preferences from app.scodoc import sco_preferences
from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_exceptions import ScoPDFFormatError, ScoValueError
from app.scodoc.sco_cursus_dut import SituationEtudCursus from app.scodoc.sco_cursus_dut import SituationEtudCursus
from app.scodoc.sco_pv_templates import CourrierIndividuelTemplate, jury_titres from app.scodoc.sco_pv_templates import CourrierIndividuelTemplate, jury_titres
import sco_version import sco_version
@ -132,7 +132,11 @@ def pdf_lettres_individuelles(
) )
) )
document.build(objects) try:
document.build(objects)
except (ValueError, KeyError, reportlab.platypus.doctemplate.LayoutError) as exc:
raise ScoPDFFormatError(str(exc)) from exc
data = report.getvalue() data = report.getvalue()
return data return data
@ -241,13 +245,14 @@ def pdf_lettre_individuelle(sem, decision, etud: Identite, params, signature=Non
titre_jury_court = "s" titre_jury_court = "s"
else: else:
titre_jury_court = "" titre_jury_court = ""
params[ params["autorisations_txt"] = (
"autorisations_txt" """Vous êtes autorisé%s à continuer dans le%s semestre%s : <b>%s</b>"""
] = """Vous êtes autorisé%s à continuer dans le%s semestre%s : <b>%s</b>""" % ( % (
etud.e, etud.e,
titre_jury_court, titre_jury_court,
titre_jury_court, titre_jury_court,
decision["autorisations_descr"], decision["autorisations_descr"],
)
) )
else: else:
params["autorisations_txt"] = "" params["autorisations_txt"] = ""

View File

@ -126,7 +126,11 @@ def pvjury_pdf(
) )
) )
document.build(objects) try:
document.build(objects)
except (ValueError, KeyError, reportlab.platypus.doctemplate.LayoutError) as exc:
raise ScoPDFFormatError(str(exc)) from exc
data = report.getvalue() data = report.getvalue()
return data return data

View File

@ -198,9 +198,9 @@ def formsemestre_recapcomplet(
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id) scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
}">Calcul automatique des décisions du jury</a> }">Calcul automatique des décisions du jury</a>
</li> </li>
<li><a class="stdlink" href="{url_for('notes.formsemestre_jury_but_erase', <li><a class="stdlink" href="{url_for('notes.formsemestre_jury_erase',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, only_one_sem=1) scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, only_one_sem=1)
}">Effacer <em>toutes</em> les décisions de jury BUT issues de ce semestre</a> }">Effacer <em>toutes</em> les décisions de jury issues de ce semestre</a>
</li> </li>
""" """
) )

View File

@ -236,6 +236,7 @@ def _results_by_category(
html_col_width="4em", html_col_width="4em",
html_sortable=True, html_sortable=True,
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id=f"results_by_category-{category_name}",
) )
@ -350,6 +351,7 @@ def formsemestre_report_counts(
"statut", "statut",
"annee_admission", "annee_admission",
"type_admission", "type_admission",
"boursier",
"boursier_prec", "boursier_prec",
] ]
if jury_but_mode: if jury_but_mode:
@ -695,19 +697,18 @@ def table_suivi_cohorte(
if statut: if statut:
dbac += " statut: %s" % statut dbac += " statut: %s" % statut
tab = GenTable( tab = GenTable(
titles=titles, caption="Suivi cohorte " + pp + sem["titreannee"] + dbac,
columns_ids=columns_ids, columns_ids=columns_ids,
rows=L, filename=scu.make_filename("cohorte " + sem["titreannee"]),
html_class="table_cohorte",
html_col_width="4em", html_col_width="4em",
html_sortable=True, html_sortable=True,
filename=scu.make_filename("cohorte " + sem["titreannee"]), origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
origin="Généré par %s le " % sco_version.SCONAME
+ scu.timedate_human_repr()
+ "",
caption="Suivi cohorte " + pp + sem["titreannee"] + dbac,
page_title="Suivi cohorte " + sem["titreannee"], page_title="Suivi cohorte " + sem["titreannee"],
html_class="table_cohorte",
preferences=sco_preferences.SemPreferences(formsemestre.id), preferences=sco_preferences.SemPreferences(formsemestre.id),
rows=L,
table_id="table_suivi_cohorte",
titles=titles,
) )
# Explication: liste des semestres associés à chaque date # Explication: liste des semestres associés à chaque date
if not P: if not P:
@ -1304,6 +1305,7 @@ def table_suivi_cursus(formsemestre_id, only_primo=False, grouped_parcours=True)
"code_cursus": len(etuds), "code_cursus": len(etuds),
}, },
preferences=sco_preferences.SemPreferences(formsemestre_id), preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="table_suivi_cursus",
) )
return tab return tab
@ -1535,6 +1537,9 @@ def graph_cursus(
# semestre de depart en vert # semestre de depart en vert
n = g.get_node("SEM" + str(formsemestre_id))[0] n = g.get_node("SEM" + str(formsemestre_id))[0]
n.set_color("green") n.set_color("green")
n.set_style("filled")
n.set_fillcolor("lightgreen")
n.set_penwidth(2.0)
# demissions en rouge, octagonal # demissions en rouge, octagonal
for nid in dem_nodes.values(): for nid in dem_nodes.values():
n = g.get_node(nid)[0] n = g.get_node(nid)[0]

View File

@ -87,15 +87,16 @@ def formsemestre_but_indicateurs(formsemestre_id: int, fmt="html"):
bacs.append("Total") bacs.append("Total")
tab = GenTable( tab = GenTable(
titles={bac: bac for bac in bacs},
columns_ids=["titre_indicateur"] + bacs,
rows=rows,
html_sortable=False,
preferences=sco_preferences.SemPreferences(formsemestre_id),
filename=scu.make_filename(f"Indicateurs_BUT_{formsemestre.titre_annee()}"),
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
html_caption="Indicateurs BUT annuels.",
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}", base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
columns_ids=["titre_indicateur"] + bacs,
filename=scu.make_filename(f"Indicateurs_BUT_{formsemestre.titre_annee()}"),
html_caption="Indicateurs BUT annuels.",
html_sortable=False,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
preferences=sco_preferences.SemPreferences(formsemestre_id),
rows=rows,
titles={bac: bac for bac in bacs},
table_id="formsemestre_but_indicateurs",
) )
title = "Indicateurs suivi annuel BUT" title = "Indicateurs suivi annuel BUT"
t = tab.make_page( t = tab.make_page(

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