Compare commits

..

150 Commits

Author SHA1 Message Date
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
523ec59833 Harmonisation formats affichage dates et heures 2024-04-02 23:37:23 +02:00
bde6325391 Enrichi tableau jury BUT PV 2024-04-02 17:11:07 +02:00
0577347622 Tableau décision jury BUT excel: améliore colonne ECTS 2024-04-02 16:53:04 +02:00
28d46e413d Filtrage par groupes dans els pages statistiques: fix #791 2024-03-31 23:04:54 +02:00
126ea0741a Edition UE: cosmetic + arg check + invalidation cache desassoc_ue_niveau 2024-03-31 10:21:44 +02:00
a5b5f49f76 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-03-29 16:39:36 +01:00
Iziram
b7ab10bf4e Assiduité : docs : erratum samples 2024-03-29 16:38:14 +01:00
3e0b19c4a8 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-03-29 16:37:19 +01:00
1dd5187fae lien vers doc évaluations 2024-03-29 16:36:05 +01:00
Iziram
9a3a7d33b2 Assiduité : Docs : ajout samples 2024-03-29 16:16:13 +01:00
Iziram
a7569fe4f5 Assiduité : signal_assiduites_diff : fix visibilité tableau 2024-03-29 16:15:19 +01:00
Iziram
79e973f06d Assiduité : XXX todo #831 (non fini) 2024-03-29 15:36:35 +01:00
b6940e4882 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc into iziram-main96 2024-03-27 17:13:36 +01:00
1f24095c57 Ajout timestamp supplémentaire dans log mail 2024-03-27 17:13:05 +01:00
0ed2455028 ne présente plus le lien 'ajouter semestre' si on n'a pas le droit 2024-03-27 17:12:28 +01:00
b841b2f708 Remplace préférence dépt. bul_display_publication par paramètre global: passerelle_disabled + cosmetic 2024-03-27 16:27:45 +01:00
Iziram
0fa1478138 Assiduité : recup_assiduites_plage: ajout justificatifs 2024-03-27 15:01:58 +01:00
Iziram
85ad7b5f29 Assiduité : suppr pref limite_annee + closes #766 2024-03-27 11:51:50 +01:00
6bfd461bf2 Fix: jury BUT cas particulier sans ue 2024-03-27 09:22:34 +01:00
e1f1a95a14 merge 2024-03-26 14:43:11 +01:00
70e3006981 merge 2024-03-26 14:37:02 +01:00
bae46c2794 Page accueil département: refonte liste semestres en cours 2024-03-26 14:17:42 +01:00
Iziram
b1055a4ebe Assiduité : signal_assiduites_group : fix bug photo etud 2024-03-26 09:02:55 +01:00
Iziram
b2ef6a4c53 Assiduité : liste_assiduites : formatage des dates 2024-03-25 16:19:05 +01:00
Iziram
a7c7bd655d Assiduité : ajout_justif_etud : dates avec heures 2024-03-25 16:15:01 +01:00
Iziram
1309043a98 Assiduités : assiduites_bubble : ajout d'un bouton 📝 pour éditer l'assiduité visée 2024-03-25 15:19:59 +01:00
Iziram
a75b41ca5f Assiduité : signal_assiduites_diff : vérification date 2024-03-25 15:12:08 +01:00
8df25ca02f Ajout infos semestres dans bulletin classique JSON. Close #583 2024-03-25 14:41:20 +01:00
61f9dddeb6 Modif. clé trie étudiants et utilisation dans éditeur partition. 2024-03-25 14:41:20 +01:00
a1f5340935 Débouchés: tags. Implements #396 2024-03-25 14:41:20 +01:00
68128c27d5 Conversion date naissance étudiant. complète #593. 2024-03-25 14:41:20 +01:00
8ecaa2bed0 Conversion dates édition évaluations et formsemestres. Fix #593. 2024-03-25 14:41:20 +01:00
7c61dd8d63 Cosmetic + reorganisation css edit formation 2024-03-25 14:41:20 +01:00
f493ba344f Jury BUT: améliore présentation et information sur les UEs capitalisées. Closes #670 2024-03-25 14:41:20 +01:00
f5079d9aef Jury BUT: affiche la liste des modules avec note en ATTente 2024-03-25 14:41:20 +01:00
55add2ffb3 cosmetic: eye, table semestres 2024-03-25 14:41:20 +01:00
5865b67652 Adapte ref. pour test_api_formsemestre.py sans nom_short 2024-03-25 14:41:20 +01:00
3c8b088d5e Jury BUT auto: avertissement si semestres pairs non bloqués 2024-03-25 14:41:20 +01:00
2da359ae41 Fix export excel table jury. Closes #868 2024-03-25 14:41:20 +01:00
211 changed files with 7159 additions and 5252 deletions

148
README.md
View File

@ -2,7 +2,7 @@
(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>
@ -23,7 +23,7 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes).
### Lignes de commandes
Voir [https://scodoc.org/GuideConfig](le guide de configuration).
Voir [le guide de configuration](https://scodoc.org/GuideConfig).
## 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é.
Principaux contenus:
/opt/scodoc-data
/opt/scodoc-data/log # Fichiers de log ScoDoc
/opt/scodoc-data/config # Fichiers de configuration
```
/opt/scodoc-data
/opt/scodoc-data/log # Fichiers de log ScoDoc
/opt/scodoc-data/config # Fichiers de configuration
.../config/logos # Logos de l'établissement
.../config/depts # un fichier par département
/opt/scodoc-data/photos # Photos des étudiants
/opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants
/opt/scodoc-data/photos # Photos des étudiants
/opt/scodoc-data/archives # Archives: PV de jury, maquettes Apogée, fichiers étudiants
```
## Pour les développeurs
### 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.
```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
mv /opt/scodoc /opt/off-scodoc # ou ce que vous voulez
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
# Donner ce répertoire à l'utilisateur scodoc:
chown -R scodoc:scodoc /opt/scodoc
```
Il faut ensuite installer l'environnement et le fichier de configuration:
# Le plus simple est de piquer le virtualenv configuré par l'installeur:
mv /opt/off-scodoc/venv /opt/scodoc
```bash
# Le plus simple est de piquer le virtualenv configuré par l'installeur:
mv /opt/off-scodoc/venv /opt/scodoc
```
Et la config:
ln -s /opt/scodoc-data/.env /opt/scodoc
```bash
ln -s /opt/scodoc-data/.env /opt/scodoc
```
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
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`.
Avant le premier lancement, créer cette base ainsi:
./tools/create_database.sh SCODOC_TEST
export FLASK_ENV=test
flask db upgrade
```bash
./tools/create_database.sh SCODOC_TEST
export FLASK_ENV=test
flask db upgrade
```
Cette commande n'est nécessaire que la première fois (le contenu de la base
est effacé au début de chaque test, mais son schéma reste) et aussi si des
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
scripts de tests:
Lancer au préalable:
flask delete-dept -fy TEST00 && flask create-dept TEST00
```bash
flask delete-dept -fy TEST00 && flask create-dept TEST00
```
Puis dérouler les tests unitaires:
pytest tests/unit
```bash
pytest tests/unit
```
Ou avec couverture (`pip install pytest-cov`)
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
```bash
pytest --cov=app --cov-report=term-missing --cov-branch tests/unit/*
```
#### Utilisation des tests unitaires pour initialiser la base de dev
On peut aussi utiliser les tests unitaires pour mettre la base de données de
@ -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
par les tests:
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
```bash
export SCODOC_TEST_DATABASE_URI=postgresql:///SCODOC_DEV
```
(si elle n'existe pas, voir plus loin pour la créer) puis de les lancer
normalement, par exemple:
pytest tests/unit/test_sco_basic.py
```bash
pytest tests/unit/test_sco_basic.py
```
Il est en général nécessaire d'affecter ensuite un mot de passe à (au moins) un
utilisateur:
flask user-password admin
```bash
flask user-password admin
```
**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 !
#### Modification du schéma de la base
On utilise SQLAlchemy avec Alembic et Flask-Migrate.
flask db migrate -m "message explicatif....."
flask db upgrade
```bash
flask db migrate -m "message explicatif....."
flask db upgrade
```
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`
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
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)
# puis imports:
flask import-scodoc7-users
flask import-scodoc7-dept STID SCOSTID
# 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
migration, utiliser les commandes `flask db history`et `flask db stamp`pour se
positionner à la bonne étape.
@ -163,23 +159,23 @@ positionner à la bonne étape.
### Profiling
Sur une machine de DEV, lancer
flask profile --host 0.0.0.0 --length 32 --profile-dir /opt/scodoc-data
```bash
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`).
Pour la visualisation, [snakeviz](https://jiffyclub.github.io/snakeviz/) est bien:
pip install snakeviz
```bash
pip install snakeviz
```
puis
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
```bash
snakeviz -s --hostname 0.0.0.0 -p 5555 /opt/scodoc-data/GET.ScoDoc......prof
```
## Paquet Debian 12
Les scripts associés au paquet Debian (.deb) sont dans `tools/debian`. Le plus
important est `postinst`qui se charge de configurer le système (install ou
important est `postinst` qui se charge de configurer le système (install ou
upgrade de scodoc9).
La préparation d'une release se fait à l'aide du script

View File

@ -315,12 +315,6 @@ def create_app(config_class=DevConfig):
app.register_error_handler(503, postgresql_server_error)
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
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_web_bp
# Jinja2 configuration
# Enable autoescaping of all templates, including .j2
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
app.register_blueprint(scodoc_bp)
@ -636,14 +637,12 @@ def critical_error(msg):
import app.scodoc.sco_utils as scu
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()
raise ScoValueError(
f"""
Une erreur est survenue.
Si le problème persiste, merci de contacter le support ScoDoc via
{scu.SCO_DISCORD_ASSISTANCE}
Une erreur est survenue, veuillez -essayer.
{msg}
"""

View File

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

View File

@ -329,6 +329,8 @@ def desassoc_ue_niveau(ue_id: int):
ue.niveau_competence = None
db.session.add(ue)
db.session.commit()
# Invalidation du cache
ue.formation.invalidate_cached_sems()
log(f"desassoc_ue_niveau: {ue}")
if g.scodoc_dept:
# "usage web"

View File

@ -603,8 +603,19 @@ class Role(db.Model):
"""Create default roles if missing, then, if reset_permissions,
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"
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()
if role is None:
role = Role(name=role_name)

View File

@ -21,7 +21,7 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
return ""
ref_comp = ue.formation.referentiel_competence
if ref_comp is None:
return f"""<div class="ue_advanced">
return f"""<div class="scobox ue_advanced">
<div class="warning">Pas de référentiel de compétence associé à cette formation !</div>
<div><a class="stdlink" href="{ url_for('notes.refcomp_assoc_formation',
scodoc_dept=g.scodoc_dept, formation_id=ue.formation.id)
@ -31,19 +31,28 @@ def form_ue_choix_parcours(ue: UniteEns) -> str:
H = [
"""
<div class="ue_advanced">
<h3>Parcours du BUT</h3>
<div class="scobox ue_advanced">
<div class="scobox-title">Parcours du BUT</div>
"""
]
# Choix des 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 = {
ue.get_ects(parcour, only_parcours=True) for parcour in ref_comp.parcours
} != {None}
for parcour in ref_comp.parcours:
ects_parcour = ue.get_ects(parcour)
ects_parcour_txt = (
f" ({ue.get_ects(parcour):.3g} ects)" if ects_differents else ""
)

View File

@ -9,12 +9,14 @@
import collections
import datetime
import pandas as pd
import numpy as np
from flask import g, has_request_context, url_for
from app import db
from app.comp.moy_mod import ModuleImplResults
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.ues import UniteEns
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
d[modimpl.module.code] = {
"id": modimpl.id,
"titre": modimpl.module.titre,
"titre": modimpl.module.titre_str(),
"code_apogee": modimpl.module.code_apogee,
"url": (
url_for(
@ -249,59 +251,88 @@ class BulletinBUT:
# "moy": fmt_note(moyennes_etuds.mean()),
},
"evaluations": (
[
self.etud_eval_results(etud, e)
for e in modimpl.evaluations
if (e.visibulletin or version == "long")
and (e.id in modimpl_results.evaluations_etat)
and (
modimpl_results.evaluations_etat[e.id].is_complete
or self.prefs["bul_show_all_evals"]
self.etud_list_modimpl_evaluations(
etud, modimpl, modimpl_results, version
)
]
if version != "short"
else []
),
}
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"
# 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()
modimpls_evals_poids = self.res.modimpls_evals_poids[e.moduleimpl_id]
modimpls_evals_poids = self.res.modimpls_evals_poids[evaluation.moduleimpl_id]
try:
etud_ues_ids = self.res.etud_ues_ids(etud.id)
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
if (ue.type != UE_SPORT) and (ue.id in etud_ues_ids)
}
except KeyError:
poids = collections.defaultdict(lambda: 0.0)
d = {
"id": e.id,
"id": evaluation.id,
"coef": (
fmt_note(e.coefficient)
if e.evaluation_type == Evaluation.EVALUATION_NORMALE
fmt_note(evaluation.coefficient)
if evaluation.evaluation_type == Evaluation.EVALUATION_NORMALE
else None
),
"date_debut": e.date_debut.isoformat() if e.date_debut else None,
"date_fin": e.date_fin.isoformat() if e.date_fin else None,
"description": e.description,
"evaluation_type": e.evaluation_type,
"date_debut": (
evaluation.date_debut.isoformat() if evaluation.date_debut else None
),
"date_fin": (
evaluation.date_fin.isoformat() if evaluation.date_fin else None
),
"description": evaluation.description,
"evaluation_type": evaluation.evaluation_type,
"note": (
{
"value": fmt_note(
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),
"max": fmt_note(notes_ok.max(), note_max=e.note_max),
"moy": fmt_note(notes_ok.mean(), note_max=e.note_max),
"min": fmt_note(notes_ok.min(), note_max=evaluation.note_max),
"max": fmt_note(notes_ok.max(), note_max=evaluation.note_max),
"moy": fmt_note(notes_ok.mean(), note_max=evaluation.note_max),
}
if not e.is_blocked()
if not evaluation.is_blocked()
else {}
),
"poids": poids,
@ -309,17 +340,25 @@ class BulletinBUT:
url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
evaluation_id=evaluation.id,
)
if has_request_context()
else "na"
),
# deprecated (supprimer avant #sco9.7)
"date": e.date_debut.isoformat() if e.date_debut else None,
"heure_debut": (
e.date_debut.time().isoformat("minutes") if e.date_debut else None
"date": (
evaluation.date_debut.isoformat() if evaluation.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
@ -540,9 +579,9 @@ class BulletinBUT:
d.update(infos)
# --- Rangs
d[
"rang_nt"
] = f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
d["rang_nt"] = (
f"{d['semestre']['rang']['value']} / {d['semestre']['rang']['total']}"
)
d["rang_txt"] = "Rang " + d["rang_nt"]
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
)
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(
refcomp, etud
)

View File

@ -31,6 +31,7 @@ from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc.sco_logos import Logo
from app.scodoc.sco_pdf import PDFLOCK, SU
from app.scodoc.sco_preferences import SemPreferences
from app.scodoc import sco_utils as scu
def make_bulletin_but_court_pdf(
@ -343,9 +344,11 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
for mod in self.bul[mod_type]:
row = [mod, bul[mod_type][mod]["titre"]]
row += [
(
bul["ues"][ue][mod_type][mod]["moyenne"]
if mod in bul["ues"][ue][mod_type]
else ""
)
for ue in self.ues_acronyms
]
rows.append(row)
@ -523,7 +526,7 @@ class BulletinGeneratorBUTCourt(BulletinGeneratorStandard):
if self.bul["semestre"].get("decision_annee", None):
txt += f"""
Décision saisie le {
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime("%d/%m/%Y")
datetime.datetime.fromisoformat(self.bul["semestre"]["decision_annee"]["date"]).strftime(scu.DATE_FMT)
}, année BUT{self.bul["semestre"]["decision_annee"]["ordre"]}
<b>{self.bul["semestre"]["decision_annee"]["code"]}</b>.
<br/>

View File

@ -73,6 +73,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
html_class="notes_bulletin",
html_class_ignore_default=True,
html_with_td_classes=True,
table_id="bul-table",
)
table_objects = table.gen(fmt=fmt)
objects += table_objects
@ -269,7 +270,7 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
date_capitalisation = ue.get("date_capitalisation")
if date_capitalisation:
fields_bmr.append(
f"""Capitalisée le {date_capitalisation.strftime("%d/%m/%Y")}"""
f"""Capitalisée le {date_capitalisation.strftime(scu.DATE_FMT)}"""
)
t = {
"titre": " - ".join(fields_bmr),
@ -427,12 +428,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
else "*"
)
note_value = e["note"].get("value", "")
t = {
"titre": f"{e['description'] or ''}",
"moyenne": e["note"]["value"],
"_moyenne_pdf": Paragraph(
f"""<para align=right>{e["note"]["value"]}</para>"""
),
"moyenne": note_value,
"_moyenne_pdf": Paragraph(f"""<para align=right>{note_value}</para>"""),
"coef": coef,
"_coef_pdf": Paragraph(
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

@ -10,9 +10,11 @@
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import SelectField, SubmitField
from wtforms.validators import DataRequired
class FormationRefCompForm(FlaskForm):
"Choix d'un référentiel"
referentiel_competence = SelectField(
"Choisir parmi les référentiels déjà chargés :"
)
@ -21,6 +23,7 @@ class FormationRefCompForm(FlaskForm):
class RefCompLoadForm(FlaskForm):
"Upload d'un référentiel"
referentiel_standard = SelectField(
"Choisir un référentiel de compétences officiel BUT"
)
@ -47,3 +50,12 @@ class RefCompLoadForm(FlaskForm):
)
return False
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

@ -55,11 +55,21 @@ def pvjury_page_but(formsemestre_id: int, fmt="html"):
else:
line_sep = "\n"
rows, titles = pvjury_table_but(formsemestre, line_sep=line_sep)
if fmt.startswith("xls"):
titles.update(
{
"etudid": "etudid",
"code_nip": "nip",
"code_ine": "ine",
"ects_but": "Total ECTS BUT",
"civilite": "Civ.",
"nom": "Nom",
"prenom": "Prénom",
}
)
# Style excel... passages à la ligne sur \n
xls_style_base = sco_excel.excel_make_style()
xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top")
tab = GenTable(
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}",
caption=title,
@ -116,7 +126,7 @@ def pvjury_table_but(
"pas de référentiel de compétences associé à la formation de ce semestre !"
)
titles = {
"nom": "Code" if anonymous else "Nom",
"nom_pv": "Code" if anonymous else "Nom",
"cursus": "Cursus",
"ects": "ECTS",
"ues": "UE validées",
@ -144,33 +154,47 @@ def pvjury_table_but(
except ScoValueError:
deca = None
ects_but_valides = but_ects_valides(etud, referentiel_competence_id)
row = {
"nom": etud.code_ine or etud.code_nip or etud.id
"nom_pv": (
etud.code_ine or etud.code_nip or etud.id
if anonymous # Mode anonyme: affiche INE ou sinon NIP, ou id
else etud.etat_civil_pv(
line_sep=line_sep, with_paragraph=with_paragraph_nom
)
),
"_nom_order": etud.sort_key,
"_nom_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_target": url_for(
"_nom_pv_order": etud.sort_key,
"_nom_pv_target_attrs": f'class="etudinfo" id="{etud.id}"',
"_nom_pv_td_attrs": f'id="{etud.id}" class="etudinfo"',
"_nom_pv_target": url_for(
"scolar.fiche_etud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
),
"cursus": _descr_cursus_but(etud),
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {but_ects_valides(etud, referentiel_competence_id ):g}""",
"ects": f"""{deca.ects_annee():g}<br><br>Tot. {ects_but_valides:g}""",
"_ects_xls": deca.ects_annee(),
"ects_but": ects_but_valides,
"ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-",
"niveaux": deca.descr_niveaux_validation(line_sep=line_sep)
if deca
else "-",
"niveaux": (
deca.descr_niveaux_validation(line_sep=line_sep) if deca else "-"
),
"decision_but": deca.code_valide if deca else "",
"devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
"devenir": (
", ".join([f"S{i}" for i in deca.get_autorisations_passage()])
if deca
else "",
else ""
),
# pour exports excel seulement:
"civilite": etud.civilite_etat_civil_str,
"nom": etud.nom,
"prenom": etud.prenom_etat_civil or etud.prenom or "",
"etudid": etud.id,
"code_nip": etud.code_nip,
"code_ine": etud.code_ine,
}
if deca.valide_diplome() or not only_diplome:
rows.append(row)
rows.sort(key=lambda x: x["_nom_order"])
rows.sort(key=lambda x: x["_nom_pv_order"])
return rows, titles

View File

@ -21,8 +21,6 @@ from app.but.jury_but import (
DecisionsProposeesRCUE,
DecisionsProposeesUE,
)
from app.comp import res_sem
from app.comp.res_but import ResultatsSemestreBUT
from app.models import (
ApcNiveau,
FormSemestre,
@ -33,11 +31,8 @@ from app.models import (
ScolarFormSemestreValidation,
ScolarNews,
)
from app.models.config import ScoDocSiteConfig
from app.scodoc import html_sco_header
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
@ -110,17 +105,20 @@ def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str:
)
ue_impair, ue_pair = rcue.ue_1, rcue.ue_2
# Les UEs à afficher : on regarde si read only et si dispense (non ré-inscription à l'UE)
# tuples (UniteEns, read_only, dispense)
ues_ro_dispense = [
(
ue_impair,
rcue.ue_cur_impair is None,
deca.res_impair
and ue_impair
and (deca.etud.id, ue_impair.id) in deca.res_impair.dispense_ues,
),
(
ue_pair,
rcue.ue_cur_pair is None,
deca.res_pair
and ue_pair
and (deca.etud.id, ue_pair.id) in deca.res_pair.dispense_ues,
),
]
@ -214,7 +212,7 @@ def _gen_but_niveau_ue(
scoplement = f"""<div class="scoplement">
<div>
<b>UE {ue.acronyme} capitalisée </b>
<span>le {dec_ue.ue_status["event_date"].strftime("%d/%m/%Y")}
<span>le {dec_ue.ue_status["event_date"].strftime(scu.DATE_FMT)}
</span>
</div>
<div>
@ -230,7 +228,7 @@ def _gen_but_niveau_ue(
<div>
<b>UE {ue.acronyme} antérieure </b>
<span>validée {dec_ue.validation.code}
le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
le {dec_ue.validation.event_date.strftime(scu.DATE_FMT)}
</span>
</div>
<div>Non reprise dans l'année en cours</div>
@ -248,9 +246,7 @@ def _gen_but_niveau_ue(
moy_ue_str = f"""<span>{scu.fmt_note(dec_ue.moy_ue)}</span>"""
if dec_ue.code_valide:
date_str = (
f"""enregistré le {dec_ue.validation.event_date.strftime("%d/%m/%Y")}
à {dec_ue.validation.event_date.strftime("%Hh%M")}
"""
f"""enregistré le {dec_ue.validation.event_date.strftime(scu.DATEATIME_FMT)}"""
if dec_ue.validation and dec_ue.validation.event_date
else ""
)

View File

@ -27,6 +27,7 @@
"""caches pour tables APC
"""
from flask import g
from app.scodoc import sco_cache
@ -47,3 +48,27 @@ class EvaluationsPoidsCache(sco_cache.ScoDocCache):
"""
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

@ -23,6 +23,7 @@ from app.models import (
)
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
class ValidationsSemestre(ResultatsCache):
@ -84,7 +85,7 @@ class ValidationsSemestre(ResultatsCache):
"code": decision.code,
"assidu": decision.assidu,
"compense_formsemestre_id": decision.compense_formsemestre_id,
"event_date": decision.event_date.strftime("%d/%m/%Y"),
"event_date": decision.event_date.strftime(scu.DATE_FMT),
}
self.decisions_jury = decisions_jury
@ -107,7 +108,7 @@ class ValidationsSemestre(ResultatsCache):
decisions_jury_ues[decision.etudid][decision.ue.id] = {
"code": decision.code,
"ects": ects, # 0. si UE non validée
"event_date": decision.event_date.strftime("%d/%m/%Y"),
"event_date": decision.event_date.strftime(scu.DATE_FMT),
}
self.decisions_jury_ues = decisions_jury_ues

View File

@ -45,7 +45,6 @@ from app.models import Evaluation, EvaluationUEPoids, ModuleImpl
from app.scodoc import sco_cache
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoBugCatcher
from app.scodoc.sco_utils import ModuleType
@ -113,6 +112,8 @@ class ModuleImplResults:
"""
self.evals_etudids_sans_note = {}
"""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.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"""
@ -164,7 +165,10 @@ class ModuleImplResults:
self.evaluations_completes = []
self.evaluations_completes_dict = {}
self.etudids_attente = set() # empty
self.evals_type = {}
evaluation: Evaluation
for evaluation in moduleimpl.evaluations:
self.evals_type[evaluation.id] = evaluation.evaluation_type
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi
# tous les inscrits (non dem) au module ont une note
@ -270,6 +274,24 @@ class ModuleImplResults:
* self.evaluations_completes
).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
def get_evaluations_completes(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"Liste des évaluations complètes"
@ -296,32 +318,26 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items()
}
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None:
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
def get_evaluations_rattrapage(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de rattrapage de ce module.
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
for e in moduleimpl.evaluations
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:
"""L'évaluation 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.
def get_evaluations_session2(self, moduleimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations de deuxième session de ce module, ou None s'il n'en a pas.
La moyenne des notes de Session 2 remplace la note de moyenne des autres évals.
"""
eval_list = [
return [
e
for e in moduleimpl.evaluations
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]:
"""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"
def compute_module_moy(
self,
evals_poids_df: pd.DataFrame,
self, evals_poids_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> pd.DataFrame:
"""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
= 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)
if nb_ues == 0:
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_poids = evals_poids_df.values * evals_coefs
# -> evals_poids shape : (nb_evals, nb_ues)
@ -398,6 +416,47 @@ class ModuleImplResultsAPC(ModuleImplResults):
) / np.sum(evals_poids_etuds, axis=1)
# 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:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
@ -405,47 +464,6 @@ class ModuleImplResultsAPC(ModuleImplResults):
evals_poids_df,
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(
etuds_moy_module,
index=self.evals_notes.index,
@ -453,6 +471,34 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
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(
self,
etuds_moy_module: pd.DataFrame,
@ -525,6 +571,7 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
return evals_poids, ues
# appelé par ModuleImpl.check_apc_conformity()
def moduleimpl_is_conforme(
moduleimpl, evals_poids: pd.DataFrame, modimpl_coefs_df: pd.DataFrame
) -> bool:
@ -546,12 +593,12 @@ def moduleimpl_is_conforme(
if len(modimpl_coefs_df) != nb_ues:
# il arrive (#bug) que le cache ne soit pas à jour...
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("moduleimpl_is_conforme: nb ue incoherent")
return app.critical_error("moduleimpl_is_conforme: err 1")
if moduleimpl.id not in modimpl_coefs_df:
# soupçon de bug cache coef ?
sco_cache.invalidate_formsemestre()
raise ScoBugCatcher("Erreur 454 - merci de ré-essayer")
return app.critical_error("moduleimpl_is_conforme: err 2")
module_evals_poids = evals_poids.transpose().sum(axis=1) != 0
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
) / np.sum(evals_coefs_etuds, axis=1)
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_notes_20,
)
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
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
# 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,
notes_session2 / (eval_session2.note_max / 20.0),
etuds_moy_module_s2,
etuds_moy_module,
)
self.etuds_use_session2 = pd.Series(
etuds_use_session2, index=self.evals_notes.index
)
else:
elif evals_rat:
# 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,
# Calcule la moyenne des évaluations de rattrapage
etuds_moy_module_rat = self._compute_moy_special(
modimpl, evals_notes_20, Evaluation.EVALUATION_RATTRAPAGE
)
# prend le max
etuds_use_rattrapage = notes_rat > etuds_moy_module
etuds_use_rattrapage = etuds_moy_module_rat > etuds_moy_module
etuds_moy_module = np.where(
etuds_use_rattrapage, notes_rat, etuds_moy_module
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:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_notes_20,
)
self.etuds_moy_module = pd.Series(
etuds_moy_module,
index=self.evals_notes.index,
@ -640,6 +684,28 @@ class ModuleImplResultsClassic(ModuleImplResults):
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(
self,
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)
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.
Charge toutes les notes (sql), calcule les moyennes des modules
et assemble le cube.
@ -207,8 +209,8 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple:
etudids, etudids_actifs = formsemestre.etudids_actifs()
for modimpl in formsemestre.modimpls_sorted:
mod_results = moy_mod.ModuleImplResultsAPC(modimpl, etudids, etudids_actifs)
evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id)
etuds_moy_module = mod_results.compute_module_moy(evals_poids)
evals_poids = modimpl.get_evaluations_poids()
etuds_moy_module = mod_results.compute_module_moy(evals_poids, modimpl_coefs_df)
modimpls_results[modimpl.id] = mod_results
modimpls_evals_poids[modimpl.id] = evals_poids
modimpls_notes.append(etuds_moy_module)

View File

@ -59,16 +59,17 @@ class ResultatsSemestreBUT(NotesTableCompat):
def compute(self):
"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.modimpls_evals_poids,
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.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
# modimpl_coefs_df.columns.get_loc(modimpl.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>
</div>
"""
""",
safe=True,
)

View File

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

View File

@ -5,12 +5,13 @@
# See LICENSE
##############################################################################
import datetime
from threading import Thread
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.config import ScoDocSiteConfig
from app.scodoc import sco_preferences
@ -19,7 +20,15 @@ from app.scodoc import sco_preferences
def send_async_email(app, msg):
"Send an email, async"
with app.app_context():
try:
mail.send(msg)
except BadHeaderError:
log(
f"""send_async_email: BadHeaderError
msg={msg}
"""
)
raise
def send_email(
@ -83,9 +92,12 @@ Adresses d'origine:
\n\n"""
+ msg.body
)
now = datetime.datetime.now()
formatted_time = now.strftime("%Y-%m-%d %H:%M:%S") + ".{:03d}".format(
now.microsecond // 1000
)
current_app.logger.info(
f"""email sent to{
f"""[{formatted_time}] email sent to{
' (mode test)' if email_test_mode_address else ''
}: {msg.recipients}
from sender {msg.sender}

View File

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

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

@ -48,13 +48,15 @@ class BonusConfigurationForm(FlaskForm):
for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list()
],
)
submit_bonus = SubmitField("Valider")
submit_bonus = SubmitField("Enregistrer ce bonus")
cancel_bonus = SubmitField("Annuler", render_kw={"formnovalidate": True})
class ScoDocConfigurationForm(FlaskForm):
"Panneau de configuration avancée"
enable_entreprises = BooleanField("activer le module <em>entreprises</em>")
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."""
)
month_debut_annee_scolaire = SelectField(
label="Mois de début des années scolaires",
description="""Date pivot. En France métropolitaine, août.
@ -83,7 +85,7 @@ class ScoDocConfigurationForm(FlaskForm):
disable_bul_pdf = BooleanField(
"interdire les exports des bulletins en PDF (déconseillé)"
)
submit_scodoc = SubmitField("Valider")
submit_scodoc = SubmitField("Enregistrer ces paramètres")
cancel_scodoc = SubmitField("Annuler", render_kw={"formnovalidate": True})
@ -98,6 +100,7 @@ def configuration():
form_scodoc = ScoDocConfigurationForm(
data={
"enable_entreprises": ScoDocSiteConfig.is_entreprises_enabled(),
"disable_passerelle": ScoDocSiteConfig.is_passerelle_disabled(),
"month_debut_annee_scolaire": ScoDocSiteConfig.get_month_debut_annee_scolaire(),
"month_debut_periode2": ScoDocSiteConfig.get_month_debut_periode2(),
"email_from_addr": ScoDocSiteConfig.get("email_from_addr"),
@ -123,12 +126,12 @@ def configuration():
flash("Fonction bonus inchangée.")
return redirect(url_for("scodoc.index"))
elif form_scodoc.submit_scodoc.data and form_scodoc.validate():
if ScoDocSiteConfig.enable_entreprises(
enabled=form_scodoc.data["enable_entreprises"]
if ScoDocSiteConfig.disable_passerelle(
disabled=form_scodoc.data["disable_passerelle"]
):
flash(
"Module entreprise "
+ ("activé" if form_scodoc.data["enable_entreprises"] else "désactivé")
"Fonction passerelle "
+ ("cachées" if form_scodoc.data["disable_passerelle"] else "montrées")
)
if ScoDocSiteConfig.set_month_debut_annee_scolaire(
int(form_scodoc.data["month_debut_annee_scolaire"])
@ -171,6 +174,7 @@ def configuration():
return render_template(
"configuration.j2",
is_entreprises_enabled=ScoDocSiteConfig.is_entreprises_enabled(),
form_bonus=form_bonus,
form_scodoc=form_scodoc,
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

@ -21,6 +21,7 @@ from app.scodoc import sco_abs_notification
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc import sco_utils as scu
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
@ -113,9 +114,9 @@ class Assiduite(ScoDocModel):
"entry_date": self.entry_date,
"user_id": None if user is None else user.id, # l'uid
"user_name": None if user is None else user.user_name, # le login
"user_nom_complet": None
if user is None
else user.get_nomcomplet(), # "Marie Dupont"
"user_nom_complet": (
None if user is None else user.get_nomcomplet()
), # "Marie Dupont"
"est_just": self.est_just,
"external_data": self.external_data,
}
@ -352,19 +353,19 @@ class Assiduite(ScoDocModel):
elif self.external_data is not None and "module" in self.external_data:
return (
"Tout module"
"Autre module (pas dans la liste)"
if self.external_data["module"] == "Autre"
else self.external_data["module"]
)
return "Non spécifié" if traduire else None
return "Module non spécifié" if traduire else None
def get_saisie(self) -> str:
"""
retourne le texte "saisie le <date> par <User>"
"""
date: str = self.entry_date.strftime("%d/%m/%Y à %H:%M")
date: str = self.entry_date.strftime(scu.DATEATIME_FMT)
utilisateur: str = ""
if self.user is not None:
self.user: User

View File

@ -8,16 +8,19 @@
from datetime import datetime
import functools
from operator import attrgetter
import yaml
from flask import g
from flask_sqlalchemy.query import Query
from sqlalchemy.orm import class_mapper
import sqlalchemy
from app import db
from app import db, log
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
@ -104,6 +107,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
def __repr__(self):
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:
"La version, normalement sous forme de date iso yyy-mm-dd"
if not self.version_orebut:
@ -124,9 +132,11 @@ class ApcReferentielCompetences(db.Model, XMLModel):
"type_departement": self.type_departement,
"type_titre": self.type_titre,
"version_orebut": self.version_orebut,
"scodoc_date_loaded": self.scodoc_date_loaded.isoformat() + "Z"
"scodoc_date_loaded": (
self.scodoc_date_loaded.isoformat() + "Z"
if self.scodoc_date_loaded
else "",
else ""
),
"scodoc_orig_filename": self.scodoc_orig_filename,
"competences": {
x.titre: x.to_dict(with_app_critiques=with_app_critiques)
@ -234,6 +244,92 @@ class ApcReferentielCompetences(db.Model, XMLModel):
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ê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
"""
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):
"Compétence"
@ -374,9 +470,11 @@ class ApcNiveau(db.Model, XMLModel):
"libelle": self.libelle,
"annee": self.annee,
"ordre": self.ordre,
"app_critiques": {x.code: x.to_dict() for x in self.app_critiques}
"app_critiques": (
{x.code: x.to_dict() for x in self.app_critiques}
if with_app_critiques
else {},
else {}
),
}
def to_dict_bul(self):
@ -464,9 +562,9 @@ class ApcNiveau(db.Model, XMLModel):
return []
if competence is None:
parcour_niveaux: list[
ApcParcoursNiveauCompetence
] = annee_parcour.niveaux_competences
parcour_niveaux: list[ApcParcoursNiveauCompetence] = (
annee_parcour.niveaux_competences
)
niveaux: list[ApcNiveau] = [
pn.competence.niveaux.filter_by(ordre=pn.niveau).first()
for pn in parcour_niveaux

View File

@ -10,6 +10,7 @@ from app.models.etudiants import Identite
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
class ApcValidationRCUE(db.Model):
@ -63,14 +64,13 @@ class ApcValidationRCUE(db.Model):
def __str__(self):
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}: {
self.code} enregistrée le {self.date.strftime("%d/%m/%Y")}"""
self.code} enregistrée le {self.date.strftime(scu.DATE_FMT)}"""
def html(self) -> str:
"description en HTML"
return f"""Décision sur RCUE {self.ue1.acronyme}/{self.ue2.acronyme}:
<b>{self.code}</b>
<em>enregistrée le {self.date.strftime("%d/%m/%Y")}
à {self.date.strftime("%Hh%M")}</em>"""
<em>enregistrée le {self.date.strftime(scu.DATEATIME_FMT)}</em>"""
def annee(self) -> str:
"""l'année BUT concernée: "BUT1", "BUT2" ou "BUT3" """
@ -164,7 +164,7 @@ class ApcValidationAnnee(db.Model):
def html(self) -> str:
"Affichage html"
date_str = (
f"""le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}"""
f"""le {self.date.strftime(scu.DATEATIME_FMT)}"""
if self.date
else "(sans date)"
)

View File

@ -92,6 +92,7 @@ class ScoDocSiteConfig(db.Model):
"INSTITUTION_CITY": str,
"DEFAULT_PDF_FOOTER_TEMPLATE": str,
"enable_entreprises": bool,
"disable_passerelle": bool, # remplace pref. bul_display_publication
"month_debut_annee_scolaire": int,
"month_debut_periode2": int,
"disable_bul_pdf": bool,
@ -244,6 +245,12 @@ class ScoDocSiteConfig(db.Model):
cfg = ScoDocSiteConfig.query.filter_by(name="enable_entreprises").first()
return cfg is not None and cfg.value
@classmethod
def is_passerelle_disabled(cls):
"""True si on doit cacher les fonctions passerelle ("oeil")."""
cfg = ScoDocSiteConfig.query.filter_by(name="disable_passerelle").first()
return cfg is not None and cfg.value
@classmethod
def is_user_require_email_institutionnel_enabled(cls) -> bool:
"""True si impose saisie email_institutionnel"""
@ -263,6 +270,11 @@ class ScoDocSiteConfig(db.Model):
"""Active (ou déactive) le module entreprises. True si changement."""
return cls.set("enable_entreprises", "on" if enabled else "")
@classmethod
def disable_passerelle(cls, disabled: bool = True) -> bool:
"""Désactive (ou active) les fonctions liées à la présence d'une passerelle. True si changement."""
return cls.set("disable_passerelle", "on" if disabled else "")
@classmethod
def disable_bul_pdf(cls, enabled=True) -> bool:
"""Interdit (ou autorise) les exports PDF. True si changement."""

View File

@ -297,11 +297,12 @@ class Identite(models.ScoDocModel):
else:
return self.nom
@cached_property
@property
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é.
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
prenom = self.prenom_str
@ -309,6 +310,12 @@ class Identite(models.ScoDocModel):
return f"{nom} {prenom}".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
def prenom_str(self):
"""Prénom à afficher. Par exemple: "Jean-Christophe" """
@ -481,7 +488,9 @@ class Identite(models.ScoDocModel):
"code_ine": self.code_ine or "",
"code_nip": self.code_nip or "",
"date_naissance": (
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""
self.date_naissance.strftime(scu.DATE_FMT)
if self.date_naissance
else ""
),
"dept_acronym": self.departement.acronym,
"dept_id": self.dept_id,
@ -549,7 +558,7 @@ class Identite(models.ScoDocModel):
.all()
)
def inscription_courante(self):
def inscription_courante(self) -> "FormSemestreInscription | None":
"""La première inscription à un formsemestre _actuellement_ en cours.
None s'il n'y en a pas (ou plus, ou pas encore).
"""
@ -733,7 +742,7 @@ class Identite(models.ScoDocModel):
"""
if with_paragraph:
return f"""{self.etat_civil}{line_sep}{self.code_nip or ""}{line_sep}{self.e} le {
self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{
self.date_naissance.strftime(scu.DATE_FMT) if self.date_naissance else ""}{
line_sep}à {self.lieu_naissance or ""}"""
return self.etat_civil

View File

@ -71,6 +71,15 @@ class Evaluation(models.ScoDocModel):
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):
return f"""<Evaluation {self.id} {
self.date_debut.isoformat() if self.date_debut else ''} "{
@ -207,7 +216,9 @@ class Evaluation(models.ScoDocModel):
e_dict["poids"] = self.get_ue_poids_dict() # { ue_id : poids }
# Deprecated
e_dict["jour"] = self.date_debut.strftime("%d/%m/%Y") if self.date_debut else ""
e_dict["jour"] = (
self.date_debut.strftime(scu.DATE_FMT) if self.date_debut else ""
)
return evaluation_enrich_dict(self, e_dict)
@ -315,10 +326,10 @@ class Evaluation(models.ScoDocModel):
def descr_heure(self) -> str:
"Description de la plage horaire pour affichages ('de 13h00 à 14h00')"
if self.date_debut and (not self.date_fin or self.date_fin == self.date_debut):
return f"""à {self.date_debut.strftime("%Hh%M")}"""
return f"""à {self.date_debut.strftime(scu.TIME_FMT)}"""
elif self.date_debut and self.date_fin:
return f"""de {self.date_debut.strftime("%Hh%M")
} à {self.date_fin.strftime("%Hh%M")}"""
return f"""de {self.date_debut.strftime(scu.TIME_FMT)
} à {self.date_fin.strftime(scu.TIME_FMT)}"""
else:
return ""
@ -345,7 +356,7 @@ class Evaluation(models.ScoDocModel):
def _h(dt: datetime.datetime) -> str:
if dt.minute:
return dt.strftime("%Hh%M")
return dt.strftime(scu.TIME_FMT)
return f"{dt.hour}h"
if self.date_fin is None:
@ -415,12 +426,13 @@ class Evaluation(models.ScoDocModel):
return modified
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})
def set_ue_poids_dict(self, ue_poids_dict: dict) -> None:
"""set poids vers les UE (remplace existants)
ue_poids_dict = { ue_id : poids }
Commit session.
"""
from app.models.ues import UniteEns
@ -430,9 +442,12 @@ class Evaluation(models.ScoDocModel):
if ue is None:
raise ScoValueError("poids vers une UE inexistante")
ue_poids = EvaluationUEPoids(evaluation=self, ue=ue, poids=poids)
L.append(ue_poids)
db.session.add(ue_poids)
L.append(ue_poids)
self.ue_poids = L # backref # pylint:disable=attribute-defined-outside-init
db.session.commit()
self.moduleimpl.invalidate_evaluations_poids() # inval cache
def update_ue_poids_dict(self, ue_poids_dict: dict) -> None:
@ -539,8 +554,8 @@ class EvaluationUEPoids(db.Model):
def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
"""add or convert some fields in an evaluation dict"""
# For ScoDoc7 compat
e_dict["heure_debut"] = e.date_debut.strftime("%Hh%M") if e.date_debut else ""
e_dict["heure_fin"] = e.date_fin.strftime("%Hh%M") if e.date_fin else ""
e_dict["heure_debut"] = e.date_debut.strftime(scu.TIME_FMT) if e.date_debut else ""
e_dict["heure_fin"] = e.date_fin.strftime(scu.TIME_FMT) if e.date_fin else ""
e_dict["jour_iso"] = e.date_debut.isoformat() if e.date_debut else ""
# Calcule durée en minutes
e_dict["descrheure"] = e.descr_heure()
@ -614,7 +629,7 @@ def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
):
raise ScoValueError(
f"""La date de début de l'évaluation ({
data["date_debut"].strftime("%d/%m/%Y")
data["date_debut"].strftime(scu.DATE_FMT)
}) n'est pas dans le semestre !""",
dest_url="javascript:history.back();",
)
@ -629,7 +644,7 @@ def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
):
raise ScoValueError(
f"""La date de fin de l'évaluation ({
data["date_fin"].strftime("%d/%m/%Y")
data["date_fin"].strftime(scu.DATE_FMT)
}) n'est pas dans le semestre !""",
dest_url="javascript:history.back();",
)

View File

@ -232,7 +232,9 @@ class ScolarNews(db.Model):
)
# 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)
# 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
"""
from flask import abort, g
from flask_sqlalchemy.query import Query
import app
@ -64,6 +66,21 @@ class Formation(db.Model):
"titre complet pour affichage"
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):
"""As a dict.
Si with_refcomp_attrs, ajoute attributs permettant de retrouver le ref. de comp.

View File

@ -208,7 +208,7 @@ class FormSemestre(models.ScoDocModel):
return cls.query.filter_by(id=formsemestre_id).first_or_404()
def sort_key(self) -> tuple:
"""clé pour tris par ordre alphabétique
"""clé pour tris par ordre de date_debut, le plus ancien en tête
(pour avoir le plus récent d'abord, sort avec reverse=True)"""
return (self.date_debut, self.semestre_id)
@ -224,12 +224,12 @@ class FormSemestre(models.ScoDocModel):
d["formsemestre_id"] = self.id
d["titre_num"] = self.titre_num()
if self.date_debut:
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
d["date_debut_iso"] = self.date_debut.isoformat()
else:
d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin:
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -255,12 +255,12 @@ class FormSemestre(models.ScoDocModel):
d["annee_scolaire"] = self.annee_scolaire()
d["bul_hide_xml"] = self.bul_hide_xml
if self.date_debut:
d["date_debut"] = self.date_debut.strftime("%d/%m/%Y")
d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
d["date_debut_iso"] = self.date_debut.isoformat()
else:
d["date_debut"] = d["date_debut_iso"] = ""
if self.date_fin:
d["date_fin"] = self.date_fin.strftime("%d/%m/%Y")
d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
d["date_fin_iso"] = self.date_fin.isoformat()
else:
d["date_fin"] = d["date_fin_iso"] = ""
@ -945,7 +945,7 @@ class FormSemestre(models.ScoDocModel):
ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
}
@cached_property
@property
def etuds_inscriptions(self) -> dict:
"""Map { etudid : inscription } (incluant DEM et DEF)"""
return {ins.etud.id: ins for ins in self.inscriptions}

View File

@ -6,6 +6,7 @@ from flask import abort, g
from flask_login import current_user
from flask_sqlalchemy.query import Query
import app
from app import db
from app.auth.models import User
from app.comp import df_cache
@ -78,7 +79,9 @@ class ModuleImpl(ScoDocModel):
] or self.module.get_edt_ids()
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)
if evaluations_poids is None:
from app.comp import moy_mod
@ -108,20 +111,37 @@ class ModuleImpl(ScoDocModel):
"""Invalide poids cachés"""
df_cache.EvaluationsPoidsCache.delete(self.id)
def check_apc_conformity(self, res: "ResultatsSemestreBUT") -> bool:
"""true si les poids des évaluations du module permettent de satisfaire
les coefficients du PN.
def check_apc_conformity(
self, res: "ResultatsSemestreBUT", evaluation_type=Evaluation.EVALUATION_NORMALE
) -> 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 (
self.module.module_type != scu.ModuleType.RESSOURCE
and self.module.module_type != scu.ModuleType.SAE
self.module.module_type
not in {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE}
):
return True # Non BUT, toujours conforme
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(
self,
self.get_evaluations_poids(),
selected_evaluations_poids,
res.modimpl_coefs_df,
)

View File

@ -340,6 +340,21 @@ class Module(models.ScoDocModel):
# Liste seulement les coefs définis:
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]:
"""Les codes Apogée (codés en base comme "VRT1,VRT2")"""
if self.code_apogee:

View File

@ -409,6 +409,14 @@ class UniteEns(models.ScoDocModel):
Renvoie (True, "") si ok, sinon (False, error_message)
"""
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
prev_niveau = self.niveau_competence
if (
@ -424,6 +432,7 @@ class UniteEns(models.ScoDocModel):
self.niveau_competence, parcours
)
if not ok:
self.formation.invalidate_cached_sems()
self.niveau_competence = prev_niveau # restore
return False, error_message

View File

@ -72,7 +72,7 @@ class ScolarFormSemestreValidation(db.Model):
return f"""décision sur UE {self.ue.acronyme if self.ue else self.ue_id
} ({self.ue_id}): {self.code}"""
return f"""décision sur semestre {self.formsemestre.titre_mois()} du {
self.event_date.strftime("%d/%m/%Y")}"""
self.event_date.strftime(scu.DATE_FMT)}"""
def delete(self):
"Efface cette validation"
@ -113,14 +113,14 @@ class ScolarFormSemestreValidation(db.Model):
if self.ue.parcours else ""}
{("émise par " + link)}
: <b>{self.code}</b>{moyenne}
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
le {self.event_date.strftime(scu.DATEATIME_FMT)}
"""
else:
return f"""Validation du semestre S{
self.formsemestre.semestre_id if self.formsemestre else "?"}
{self.formsemestre.html_link_status() if self.formsemestre else ""}
: <b>{self.code}</b>
le {self.event_date.strftime("%d/%m/%Y")} à {self.event_date.strftime("%Hh%M")}
le {self.event_date.strftime(scu.DATEATIME_FMT)}
"""
def ects(self) -> float:
@ -175,7 +175,7 @@ class ScolarAutorisationInscription(db.Model):
)
return f"""Autorisation de passage vers <b>S{self.semestre_id}</b> émise par
{link}
le {self.date.strftime("%d/%m/%Y")} à {self.date.strftime("%Hh%M")}
le {self.date.strftime(scu.DATEATIME_FMT)}
"""
@classmethod

View File

@ -48,6 +48,7 @@ from typing import Any
from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
from openpyxl.utils import get_column_letter
import reportlab
from reportlab.platypus import Paragraph, Spacer
from reportlab.platypus import Table, KeepInFrame
from reportlab.lib.colors import Color
@ -175,6 +176,7 @@ class GenTable:
self.xml_link = xml_link
# HTML parameters:
if not table_id: # random id
log("Warning: GenTable() called without table_id")
self.table_id = "gt_" + str(random.randint(0, 1000000))
else:
self.table_id = table_id
@ -263,16 +265,16 @@ class GenTable:
colspan_count -= 1
# if colspan_count > 0:
# continue # skip cells after a span
if pdf_mode:
content = row.get(f"_{cid}_pdf", False) or row.get(cid, "")
elif xls_mode:
content = row.get(f"_{cid}_xls", False) or row.get(cid, "")
if pdf_mode and f"_{cid}_pdf" in row:
content = row[f"_{cid}_pdf"]
elif xls_mode and f"_{cid}_xls" in row:
content = row[f"_{cid}_xls"]
else:
content = row.get(cid, "")
# Convert None to empty string ""
content = "" if content is None else content
colspan = row.get("_%s_colspan" % cid, 0)
colspan = row.get(f"_{cid}_colspan", 0)
if colspan > 1:
pdf_style_list.append(
(
@ -676,6 +678,7 @@ class GenTable:
fmt="html",
page_title="",
filename=None,
cssstyles=[],
javascripts=[],
with_html_headers=True,
publish=True,
@ -696,6 +699,7 @@ class GenTable:
H.append(
self.html_header
or html_sco_header.sco_header(
cssstyles=cssstyles,
page_title=page_title,
javascripts=javascripts,
init_qtip=init_qtip,
@ -721,7 +725,7 @@ class GenTable:
)
else:
return pdf_doc
elif fmt == "xls" or fmt == "xlsx": # dans les 2 cas retourne du xlsx
elif fmt in ("xls", "xlsx"): # dans les 2 cas retourne du xlsx
xls = self.excel()
if publish:
return scu.send_file(
@ -730,7 +734,6 @@ class GenTable:
suffix=scu.XLSX_SUFFIX,
mime=scu.XLSX_MIMETYPE,
)
else:
return xls
elif fmt == "text":
return self.text()
@ -811,7 +814,10 @@ if __name__ == "__main__":
document,
)
)
try:
document.build(objects)
except (ValueError, KeyError, reportlab.platypus.doctemplate.LayoutError) as exc:
raise ScoPDFFormatError(str(exc)) from exc
data = doc.getvalue()
with open("/tmp/gen_table.pdf", "wb") as f:
f.write(data)

View File

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

View File

@ -28,6 +28,7 @@
"""
Génération de la "sidebar" (marge gauche des pages HTML)
"""
from flask import render_template, url_for
from flask import g, request
from flask_login import current_user
@ -108,19 +109,27 @@ def sidebar_common():
</div>
{sidebar_dept()}
<h2 class="insidebar">Scolarité</h2>
<a href="{scu.ScoURL()}" class="sidebar">Semestres</a> <br>
<a href="{scu.NotesURL()}" class="sidebar">Formations</a> <br>
<a href="{
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):
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(
Permission.UsersAdmin
) or current_user.has_permission(Permission.UsersView):
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):
@ -141,7 +150,9 @@ def sidebar(etudid: int = None):
params = {}
H = [
f"""<div class="sidebar">
f"""
<!-- sidebar py -->
<div class="sidebar" id="sidebar">
{ sidebar_common() }
<div class="box-chercheetud">Chercher étudiant:<br>
<form method="get" id="form-chercheetud"
@ -180,10 +191,10 @@ def sidebar(etudid: int = None):
)
H.append(
f"""<span title="absences du {
formsemestre.date_debut.strftime("%d/%m/%Y")
formsemestre.date_debut.strftime(scu.DATE_FMT)
} au {
formsemestre.date_fin.strftime("%d/%m/%Y")
}">({
formsemestre.date_fin.strftime(scu.DATE_FMT)
}" data-tooltip>({
sco_preferences.get_preference("assi_metrique", None)})
<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',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Calendrier</a></li>
<li><a href="{ url_for('assiduites.liste_assiduites_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Liste</a></li>
<li><a href="{ url_for('assiduites.bilan_etud',
scodoc_dept=g.scodoc_dept, etudid=etudid)
}">Bilan</a></li>
}" title="Les pages bilan et liste ont été fusionnées">Liste/Bilan</a></li>
</ul>
"""
)

View File

@ -12,6 +12,7 @@ import psycopg2.extras
from app import log
from app.scodoc.sco_exceptions import ScoException, ScoValueError, NoteProcessError
from app.scodoc import sco_utils as scu
quote_html = html.escape
@ -475,7 +476,7 @@ def DateDMYtoISO(dmy: str, null_is_empty=False) -> str | None:
if not isinstance(dmy, str):
raise ScoValueError(f'Date (j/m/a) invalide: "{dmy}"')
try:
dt = datetime.datetime.strptime(dmy, "%d/%m/%Y")
dt = datetime.datetime.strptime(dmy, scu.DATE_FMT)
except ValueError:
try:
dt = datetime.datetime.fromisoformat(dmy)

View File

@ -34,6 +34,7 @@ from app.models.absences import BilletAbsence
from app.models.etudiants import Identite
from app.scodoc.gen_tables import GenTable
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
def query_billets_etud(etudid: int = None, etat: bool = None) -> Query:
@ -89,12 +90,12 @@ def table_billets(
m = " matin"
else:
m = " après-midi"
billet_dict["abs_begin_str"] = billet.abs_begin.strftime("%d/%m/%Y") + m
billet_dict["abs_begin_str"] = billet.abs_begin.strftime(scu.DATE_FMT) + m
if billet.abs_end.hour < 12:
m = " matin"
else:
m = " après-midi"
billet_dict["abs_end_str"] = billet.abs_end.strftime("%d/%m/%Y") + m
billet_dict["abs_end_str"] = billet.abs_end.strftime(scu.DATE_FMT) + m
if billet.etat == 0:
if billet.justified:
billet_dict["etat_str"] = "à traiter"
@ -156,5 +157,6 @@ def table_billets(
rows=rows,
html_sortable=True,
html_class="table_leftalign",
table_id="table_billets",
)
return tab

View File

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

View File

@ -515,11 +515,13 @@ class ApoEtud(dict):
# ne trouve pas de semestre impair
self.validation_annee_but = None
return
self.validation_annee_but: ApcValidationAnnee = ApcValidationAnnee.query.filter_by(
self.validation_annee_but: ApcValidationAnnee = (
ApcValidationAnnee.query.filter_by(
formsemestre_id=formsemestre.id,
etudid=self.etud["etudid"],
referentiel_competence_id=self.cur_res.formsemestre.formation.referentiel_competence_id,
).first()
)
self.is_nar = (
self.validation_annee_but and self.validation_annee_but.code == NAR
)
@ -915,6 +917,7 @@ class ApoData:
columns_ids=columns_ids,
titles=dict(zip(columns_ids, columns_ids)),
rows=rows,
table_id="build_cr_table",
xls_sheet_name="Decisions ScoDoc",
)
return T
@ -967,6 +970,7 @@ class ApoData:
"rcue": "RCUE",
},
rows=rows,
table_id="adsup_table",
xls_sheet_name="ADSUPs",
)
@ -1003,7 +1007,7 @@ def comp_apo_sems(etape_apogee, annee_scolaire: int) -> list[dict]:
def nar_etuds_table(apo_data, nar_etuds):
"""Liste les NAR -> excel table"""
code_etape = apo_data.etape_apogee
today = datetime.datetime.today().strftime("%d/%m/%y")
today = datetime.datetime.today().strftime(scu.DATE_FMT)
rows = []
nar_etuds.sort(key=lambda k: k["nom"])
for e in nar_etuds:
@ -1052,6 +1056,7 @@ def nar_etuds_table(apo_data, nar_etuds):
columns_ids=columns_ids,
titles=dict(zip(columns_ids, columns_ids)),
rows=rows,
table_id="nar_etuds_table",
xls_sheet_name="NAR ScoDoc",
)
return table.excel()

View File

@ -49,11 +49,13 @@
"""
import datetime
import glob
import gzip
import mimetypes
import os
import re
import shutil
import time
import zlib
import chardet
@ -62,7 +64,7 @@ from flask import g
import app.scodoc.sco_utils as scu
from config import Config
from app import log
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_exceptions import ScoException, ScoValueError
class BaseArchiver:
@ -241,11 +243,13 @@ class BaseArchiver:
filename: str,
data: str | bytes,
dept_id: int = None,
compress=False,
):
"""Store data in archive, under given filename.
Filename may be modified (sanitized): return used filename
The file is created or replaced.
data may be str or bytes
If compress, data is gziped and filename suffix ".gz" added.
"""
if isinstance(data, str):
data = data.encode(scu.SCO_ENCODING)
@ -255,6 +259,12 @@ class BaseArchiver:
try:
scu.GSL.acquire()
fname = os.path.join(archive_id, filename)
if compress:
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:
@ -274,6 +284,15 @@ class BaseArchiver:
fname = os.path.join(archive_id, filename)
log(f"reading archive file {fname}")
try:
if fname.endswith(".gz"):
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:
@ -288,6 +307,8 @@ class BaseArchiver:
"""
archive_id = self.get_id_from_name(oid, archive_name, dept_id=dept_id)
data = self.get(archive_id, filename)
if filename.endswith(".gz"):
filename = filename[:-3]
mime = mimetypes.guess_type(filename)[0]
if mime is None:
mime = "application/octet-stream"

View File

@ -68,7 +68,7 @@ PV_ARCHIVER = SemsArchiver()
def do_formsemestre_archive(
formsemestre_id,
formsemestre: FormSemestre,
group_ids: list[int] = None, # si indiqué, ne prend que ces groupes
description="",
date_jury="",
@ -92,19 +92,18 @@ def do_formsemestre_archive(
raise ScoValueError(
"do_formsemestre_archive: version de bulletin demandée invalide"
)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
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(
sem_archive_id, description, formsemestre.dept_id
)
date = PV_ARCHIVER.get_archive_date(archive_id).strftime("%d/%m/%Y à %H:%M")
date = PV_ARCHIVER.get_archive_date(archive_id).strftime(scu.DATEATIME_FMT)
if not group_ids:
# 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(
group_ids, formsemestre_id=formsemestre_id
group_ids, formsemestre_id=formsemestre.id
)
groups_filename = "-" + groups_infos.groups_filename
etudids = [m["etudid"] for m in groups_infos.members]
@ -142,19 +141,23 @@ def do_formsemestre_archive(
)
# 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)
if data:
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
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()
else: # formations classiques
data = sco_pv_forms.formsemestre_pvjury(
formsemestre_id, fmt="xls", publish=False
formsemestre.id, fmt="xls", publish=False
)
if data:
PV_ARCHIVER.store(
@ -165,7 +168,7 @@ def do_formsemestre_archive(
)
# Classeur bulletins (PDF)
data, _ = sco_bulletins_pdf.get_formsemestre_bulletins_pdf(
formsemestre_id, version=bul_version
formsemestre.id, version=bul_version
)
if data:
PV_ARCHIVER.store(
@ -173,10 +176,11 @@ def do_formsemestre_archive(
"Bulletins.pdf",
data,
dept_id=formsemestre.dept_id,
compress=True,
)
# Lettres individuelles (PDF):
data = sco_pv_lettres_inviduelles.pdf_lettres_individuelles(
formsemestre_id,
formsemestre.id,
etudids=etudids,
date_jury=date_jury,
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.
(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():
raise ScoPermissionDenied(
dest_url=url_for(
@ -320,7 +324,7 @@ enregistrés et non modifiables, on peut les retrouver ultérieurement.
else:
tf[2]["anonymous"] = False
do_formsemestre_archive(
formsemestre_id,
formsemestre,
group_ids=group_ids,
description=tf[2]["description"],
date_jury=tf[2]["date_jury"],
@ -352,7 +356,7 @@ def formsemestre_list_archives(formsemestre_id):
"""Page listing archives"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
sem_archive_id = formsemestre_id
L = []
archives_descr = []
for archive_id in PV_ARCHIVER.list_obj_archives(
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
),
}
L.append(a)
archives_descr.append(a)
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>")
else:
H.append("<ul>")
for a in L:
for a in archives_descr:
archive_name = PV_ARCHIVER.get_archive_name(a["archive_id"])
H.append(
'<li>%s : <em>%s</em> (<a href="formsemestre_delete_archive?formsemestre_id=%s&archive_name=%s">supprimer</a>)<ul>'
% (
a["date"].strftime("%d/%m/%Y %H:%M"),
a["description"],
formsemestre_id,
archive_name,
)
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,
formsemestre_id=formsemestre_id, archive_name=archive_name
)}">supprimer</a>)
<ul>"""
)
for filename in a["content"]:
H.append(
'<li><a href="formsemestre_get_archived_file?formsemestre_id=%s&archive_name=%s&filename=%s">%s</a></li>'
% (formsemestre_id, archive_name, filename, filename)
f"""<li><a href="{
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"]:
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):
"""Send file to client."""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
sem_archive_id = formsemestre.id
return PV_ARCHIVER.get_archived_file(
sem_archive_id, archive_name, filename, dept_id=formsemestre.dept_id

View File

@ -372,12 +372,38 @@ def str_to_time(time_str: str) -> time:
def get_assiduites_stats(
assiduites: Query, metric: str = "all", filtered: dict[str, object] = None
) -> dict[str, int | float]:
"""Compte les assiduités en fonction des filtres"""
# XXX TODO-assiduite : documenter !!!
# Que sont les filtres ? Quelles valeurs ?
# documenter permet de faire moins de bug: qualité du code non satisfaisante.
#
# + on se perd entre les clés en majuscules et en minuscules. Pourquoi
"""
Calcule les statistiques sur les assiduités
(nombre de jours, demi-journées et heures passées,
non justifiées, justifiées et total)
Les filtres :
- etat : filtre les assiduités par leur état
valeur : (absent, present, retard)
- date_debut/date_fin : prend les assiduités qui se trouvent entre les dates
valeur : datetime.datetime
- moduleimpl_id : filtre les assiduités en fonction du moduleimpl_id
valeur : int | None
- formsemestre : prend les assiduités du formsemestre donné
valeur : FormSemestre
- formsemestre_modimpls : prend les assiduités avec un moduleimpl du formsemestre
valeur : FormSemestre
- est_just : filtre les assiduités en fonction de si elles sont justifiées ou non
valeur : bool
- user_id : filtre les assiduités en fonction de l'utilisateur qui les a créées
valeur : int
- split : effectue un comptage par état d'assiduité
valeur : str (du moment que la clé est présente dans filtered)
Les métriques :
- journee : comptage en nombre de journée
- demi : comptage en nombre de demi journée
- heure : comptage en heure
- compte : nombre d'objets
- all : renvoi toute les métriques
"""
if filtered is not None:
deb, fin = None, None
@ -414,34 +440,71 @@ def get_assiduites_stats(
calculator: CountCalculator = CountCalculator()
calculator.compute_assiduites(assiduites)
# S'il n'y a pas de filtre ou que le filtre split n'est pas dans les filtres
if filtered is None or "split" not in filtered:
# On récupère le comptage total
# only_total permet de ne récupérer que le total
count: dict = calculator.to_dict(only_total=True)
# On ne garde que les métriques demandées
for key, val in count.items():
if key in metrics:
output[key] = val
# On renvoie le total si on a rien demandé (ou que metrics == ["all"])
return output if output else count
# Récupération des états
etats: list[str] = (
filtered["etat"].split(",") if "etat" in filtered else scu.EtatAssiduite.all()
)
# être sur que les états sont corrects
etats = [etat for etat in etats if etat.upper() in scu.EtatAssiduite.all()]
# Préparation du dictionnaire de retour avec les valeurs du calcul
count: dict = calculator.to_dict(only_total=False)
# Récupération des états depuis la saisie utilisateur
etats: list[str] = (
filtered["etat"].split(",") if "etat" in filtered else scu.EtatAssiduite.all()
)
for etat in etats:
# TODO-assiduite: on se perd entre les lower et upper.
# Pourquoi EtatAssiduite est en majuscules si tout le reste est en minuscules ?
etat = etat.lower()
# On vérifie que l'état est bien un état d'assiduité
# sinon on passe à l'état suivant
if not scu.EtatAssiduite.contains(etat):
continue
# On récupère le comptage pour chaque état
if etat != "present":
output[etat] = count[etat]
output[etat]["justifie"] = count[etat + "_just"]
output[etat]["non_justifie"] = count[etat + "_non_just"]
else:
output[etat] = count[etat]
output["total"] = count["total"]
# le dictionnaire devrait ressembler à :
# {
# "absent": {
# "journee": 1,
# "demi": 2,
# "heure": 3,
# "compte": 4,
# "justifie": {
# "journee": 1,
# "demi": 2,
# "heure": 3,
# "compte": 4
# },
# "non_justifie": {
# "journee": 1,
# "demi": 2,
# "heure": 3,
# "compte": 4
# }
# },
# ...
# "total": {
# "journee": 1,
# "demi": 2,
# "heure": 3,
# "compte": 4
# }
# }
return output

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

@ -126,7 +126,7 @@ def make_context_dict(formsemestre: FormSemestre, etud: dict) -> dict:
# ajoute date courante
t = time.localtime()
C["date_dmy"] = time.strftime("%d/%m/%Y", t)
C["date_dmy"] = time.strftime(scu.DATE_FMT, t)
C["date_iso"] = time.strftime("%Y-%m-%d", t)
return C
@ -446,7 +446,8 @@ def _ue_mod_bulletin(
):
"""Infos sur les modules (et évaluations) dans une UE
(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", formsemestre_id

View File

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

View File

@ -352,7 +352,7 @@ class BulletinGeneratorLegacy(sco_bulletins_generator.BulletinGenerator):
H.append(
f"""<p>
<span class="bull_appreciations_date">{
appreciation.date.strftime("%d/%m/%y") if appreciation.date else ""
appreciation.date.strftime(scu.DATE_FMT) if appreciation.date else ""
}</span>
{appreciation.comment_safe()}
<span class="bull_appreciations_link">{mlink}</span>

View File

@ -106,6 +106,7 @@ def assemble_bulletins_pdf(
pagesbookmarks=pagesbookmarks,
filigranne=filigranne,
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)
@ -122,7 +123,8 @@ def replacement_function(match) -> str:
return r'<img %s src="%s"%s/>' % (match.group(2), logo.filepath, match.group(4))
raise ScoValueError(
'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_ignore_default=True,
html_with_td_classes=True,
table_id="std_bul_table",
)
return T.gen(fmt=fmt)
@ -182,7 +183,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
H.append(
f"""<p>
<span class="bull_appreciations_date">{
appreciation.date.strftime("%d/%m/%Y")
appreciation.date.strftime(scu.DATE_FMT)
if appreciation.date else ""}</span>
{appreciation.comment_safe()}
<span class="bull_appreciations_link">{mlink}</span>

View File

@ -55,7 +55,6 @@ from flask import g
import app
from app import db, log
from app.scodoc import notesdb as ndb
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoException
@ -174,17 +173,15 @@ class EvaluationCache(ScoDocCache):
@classmethod
def invalidate_all_sems(cls):
"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 = [
x[0]
for x in ndb.SimpleQuery(
"""SELECT e.id
FROM notes_evaluation e, notes_moduleimpl mi, notes_formsemestre s
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},
)
e.id
for e in Evaluation.query.join(ModuleImpl)
.join(FormSemestre)
.filter_by(dept_id=g.scodoc_dept_id)
]
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é).
Si pdfonly, n'expire que les bulletins pdf cachés.
"""
from app.comp import df_cache
from app.models.formsemestre import FormSemestre
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
):
del g.formsemestre_results_cache[fid]
df_cache.EvaluationsPoidsCache.invalidate_sem(formsemestre_id)
else:
# optimization when we invalidate all evaluations:
EvaluationCache.invalidate_all_sems()
df_cache.EvaluationsPoidsCache.invalidate_all()
if hasattr(g, "formsemestre_results_cache"):
del g.formsemestre_results_cache
SemInscriptionsCache.delete_many(formsemestre_ids)
ResultatsSemestreCache.delete_many(formsemestre_ids)
ValidationsSemestreCache.delete_many(formsemestre_ids)

View File

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

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()}""",
filename=f"EstimCout-S{formsemestre.semestre_id}",
table_id="formsemestre_table_estim_cost",
)
return tab

View File

@ -350,11 +350,13 @@ class SituationEtudCursusClassic(SituationEtudCursus):
l'étudiant (quelle que soit la formation), le plus ancien en tête"""
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, ..."
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_formation_code = self.sem["formation_code"]
p = []
for s in self.sems:
if s["ins"]["etat"] == scu.DEMISSION:
@ -363,12 +365,14 @@ class SituationEtudCursusClassic(SituationEtudCursus):
dem = ""
if filter_futur and s["dateord"] > cur_begin_date:
continue # skip semestres demarrant apres le courant
SA = self.parcours.SESSION_ABBRV # 'S' ou 'A'
if filter_formation_code and s["formation_code"] != cur_formation_code:
continue # restreint aux semestres de la formation courante (pour les PV)
session_abbrv = self.parcours.SESSION_ABBRV # 'S' ou 'A'
if s["semestre_id"] < 0:
SA = "A" # force, cas des DUT annuels par exemple
p.append("%s%d%s" % (SA, -s["semestre_id"], dem))
session_abbrv = "A" # force, cas des DUT annuels par exemple
p.append("%s%d%s" % (session_abbrv, -s["semestre_id"], dem))
else:
p.append("%s%d%s" % (SA, s["semestre_id"], dem))
p.append("%s%d%s" % (session_abbrv, s["semestre_id"], dem))
return ", ".join(p)
def get_parcours_decisions(self):

View File

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

View File

@ -35,7 +35,7 @@ from flask_sqlalchemy.query import Query
import app
from app import log
from app.models import FormSemestre, ScolarNews
from app.models import FormSemestre, ScolarNews, ScoDocSiteConfig
import app.scodoc.sco_utils as scu
from app.scodoc.gen_tables import GenTable
from app.scodoc.sco_permissions import Permission
@ -79,21 +79,32 @@ def index_html(showcodes=0, showsemtable=0, export_table_formsemestres=False):
else:
html_table_formsemestres = None
current_formsemestres_by_modalite, modalites = (
sco_modalites.group_formsemestres_by_modalite(current_formsemestres)
)
passerelle_disabled = ScoDocSiteConfig.is_passerelle_disabled()
return render_template(
"scolar/index.j2",
current_user=current_user,
current_formsemestres=current_formsemestres,
current_formsemestres_by_modalite=current_formsemestres_by_modalite,
dept_name=sco_preferences.get_preference("DeptName"),
formsemestres=formsemestres,
html_current_formsemestres=_show_current_formsemestres(
current_formsemestres, showcodes
emptygroupicon=scu.icontag(
"emptygroupicon_img", title="Pas d'inscrits", border="0"
),
formsemestres=formsemestres,
groupicon=scu.icontag("groupicon_img", title="Inscrits", border="0"),
html_table_formsemestres=html_table_formsemestres,
icon_hidden="" if passerelle_disabled else scu.ICON_HIDDEN,
icon_published="" if passerelle_disabled else scu.ICON_PUBLISHED,
locked_formsemestres=locked_formsemestres,
modalites=modalites,
nb_locked=locked_formsemestres.count(),
nb_user_accounts=sco_users.get_users_count(dept=g.scodoc_dept),
page_title=f"ScoDoc {g.scodoc_dept}",
Permission=Permission,
scolar_news_summary=ScolarNews.scolar_news_summary_html(),
showcodes=showcodes,
showsemtable=showsemtable,
sco=ScoData(),
)
@ -116,6 +127,7 @@ def _convert_formsemestres_to_dicts(
lockicon = "X"
# génère liste de dict
sems = []
formsemestre: FormSemestre
for formsemestre in formsemestres:
nb_inscrits = len(formsemestre.inscriptions)
formation = formsemestre.formation
@ -136,7 +148,7 @@ def _convert_formsemestres_to_dicts(
),
"formsemestre_id": formsemestre.id,
"groupicon": groupicon if nb_inscrits > 0 else emptygroupicon,
"lockimg": lockicon,
"lockimg": "" if formsemestre.etat else lockicon,
"modalite": formsemestre.modalite,
"mois_debut": formsemestre.mois_debut(),
"mois_fin": formsemestre.mois_fin(),
@ -151,61 +163,6 @@ def _convert_formsemestres_to_dicts(
return sems
def _show_current_formsemestres(formsemestres: Query, showcodes: bool) -> str:
"""html div avec les formsemestres courants de la page d'accueil"""
H = []
if formsemestres.count():
H.append("""<div class="scobox-title">Sessions en cours</div>""")
H.append(_sem_table(_convert_formsemestres_to_dicts(formsemestres, showcodes)))
else:
# aucun semestre courant: affiche aide
H.append(
"""
<div class="scobox-title">Aucune session en cours !</div>
<p>Pour ajouter une session, aller dans <a href="Notes" id="link-programmes">Formations</a>,
choisissez une formation, puis suivez le lien "<em>UE, modules, semestres</em>".
</p>
<p>, en bas de page, suivez le lien
"<em>Mettre en place un nouveau semestre de formation...</em>"
</p>"""
)
return "\n".join(H)
def _sem_table(sems: list[dict]) -> str:
"""Affiche liste des semestres, utilisée pour semestres en cours"""
tmpl = f"""<tr class="%(trclass)s">%(tmpcode)s
<td class="semicon">%(lockimg)s <a href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept)}?formsemestre_id=%(formsemestre_id)s#groupes">%(groupicon)s</a></td>
<td class="datesem">%(mois_debut)s <a title="%(session_id)s">-</a> %(mois_fin)s</td>
<td class="titresem"><a class="stdlink" href="{url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept)}?formsemestre_id=%(formsemestre_id)s">%(titre_num)s</a>
<span class="respsem">(%(responsable_name)s)</span>
</td>
</tr>
"""
# Liste des semestres, groupés par modalités
sems_by_mod, modalites = sco_modalites.group_sems_by_modalite(sems)
H = ['<table class="listesems">']
for modalite in modalites:
if len(modalites) > 1:
H.append('<tr><th colspan="3">%s</th></tr>' % modalite["titre"])
if sems_by_mod[modalite["modalite"]]:
cur_idx = sems_by_mod[modalite["modalite"]][0]["semestre_id"]
for sem in sems_by_mod[modalite["modalite"]]:
if cur_idx != sem["semestre_id"]:
sem["trclass"] = "firstsem" # separe les groupes de semestres
cur_idx = sem["semestre_id"]
else:
sem["trclass"] = ""
H.append(tmpl % sem)
H.append("</table>")
return "\n".join(H)
def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable:
"""Table des semestres
Utilise une datatables.
@ -220,9 +177,10 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
s["modalite"],
)
)
columns_ids = (
"lockimg",
"published",
columns_ids = ["lockimg"]
if not ScoDocSiteConfig.is_passerelle_disabled():
columns_ids.append("published")
columns_ids += [
"dash_mois_fin",
"semestre_id_n",
"modalite",
@ -232,14 +190,26 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
"etapes_apo_str",
"elt_annee_apo",
"elt_sem_apo",
)
]
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"
if current_user.has_permission(Permission.EditApogee):
html_class += " apo_editable"
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={
"formsemestre_id": "id",
"semestre_id_n": "S#",
@ -253,19 +223,7 @@ def _sem_table_gt(formsemestres: Query, showcodes=False, fmt="html") -> GenTable
"elt_sem_apo": "Elt. sem. Apo.",
"formation": "Formation",
},
columns_ids=columns_ids,
rows=sems,
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

View File

@ -67,7 +67,7 @@ SCO_DUMP_LOCK = "/tmp/scodump.lock"
def sco_dump_and_send_db(
message: str = "", request_url: str = "", traceback_str_base64: str = ""
):
) -> requests.Response:
"""Dump base de données et l'envoie anonymisée pour debug"""
traceback_str = base64.urlsafe_b64decode(traceback_str_base64).decode(
scu.SCO_ENCODING
@ -97,7 +97,6 @@ def sco_dump_and_send_db(
# Send
r = _send_db(ano_db_name, message, request_url, traceback_str=traceback_str)
code = r.status_code
finally:
# Drop anonymized database
@ -107,7 +106,7 @@ def sco_dump_and_send_db(
log("sco_dump_and_send_db: done.")
return code
return r
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")
return
cmd = ["dropdb", ano_db_name]
log("sco_dump_and_send_db: {}".format(cmd))
log(f"sco_dump_and_send_db: {cmd}")
try:
_ = subprocess.check_output(cmd)
except subprocess.CalledProcessError as e:
log("sco_dump_and_send_db: exception dropdb {}".format(e))
except subprocess.CalledProcessError as exc:
log(f"sco_dump_and_send_db: exception dropdb {exc}")
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"),
f"""<h2>Suppression de la formation {formation.titre} ({formation.acronyme})</h2>""",
]
sems = sco_formsemestre.do_formsemestre_list({"formation_id": formation_id})
if sems:
formsemestres = formation.formsemestres.all()
if formsemestres:
H.append(
"""<p class="warning">Impossible de supprimer cette formation,
car les sessions suivantes l'utilisent:</p>
<ul>"""
)
for sem in sems:
for formsemestre in formsemestres:
H.append(f"""<li>{formsemestre.html_link_status()}</li>""")
H.append(
'<li><a class="stdlink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titremois)s</a></li>'
% sem
)
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:
if not dialog_confirmed:
@ -85,14 +84,16 @@ def formation_delete(formation_id=None, dialog_confirmed=False):
</p>
""",
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},
)
else:
do_formation_delete(formation_id)
H.append(
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())
@ -252,7 +253,7 @@ def formation_edit(formation_id=None, create=False):
if tf[0] == 0:
return "\n".join(H) + tf[1] + html_sco_header.sco_footer()
elif tf[0] == -1:
return flask.redirect(scu.NotesURL())
return flask.redirect(url_for("notes.index_html", scodoc_dept=g.scodoc_dept))
else:
# check unicity : constraint UNIQUE(acronyme,titre,version)
if create:
@ -325,6 +326,7 @@ def do_formation_create(args: dict) -> Formation:
scodoc_dept=g.scodoc_dept,
formation_id=formation.id,
),
safe=True,
) from exc
ScolarNews.add(

View File

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

View File

@ -298,27 +298,6 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
cursus = formation.get_cursus()
is_apc = cursus.APC_SAE
semestres_indices = list(range(1, cursus.NB_SEM + 1))
H = [
html_sco_header.sco_header(page_title=title, javascripts=["js/edit_ue.js"]),
"<h2>" + title,
f" (formation {formation.acronyme}, version {formation.version})</h2>",
"""
<p class="help">Les UE sont des groupes de modules dans une formation donnée,
utilisés pour la validation (on calcule des moyennes par UE et applique des
seuils ("barres")).
</p>
<p class="help">Note: sauf exception, l'UE n'a pas de coefficient associé.
Seuls les <em>modules</em> ont des coefficients.
</p>""",
(
f"""
<h4>UE du semestre S{ue.semestre_idx}</h4>
"""
if is_apc and ue
else ""
),
]
ue_types = cursus.ALLOWED_UE_TYPES
ue_types.sort()
@ -489,7 +468,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
if ue and is_apc:
ue_parcours_div = apc_edit_ue.form_ue_choix_parcours(ue)
if ue and ue.modules.count() and ue.semestre_idx is not None:
modules_div = f"""<div id="ue_list_modules">
modules_div = f"""<div class="scobox" id="ue_list_modules">
<div><b>{ue.modules.count()} modules sont rattachés
à cette UE</b> du semestre S{ue.semestre_idx},
elle ne peut donc pas être changée de semestre.</div>
@ -511,18 +490,34 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No
"""
else:
clone_form = ""
bonus_div = """<div id="bonus_description"></div>"""
ue_div = """<div id="ue_list_code" class="sco_box sco_green_bg"></div>"""
return (
"\n".join(H)
+ tf[1]
+ clone_form
+ ue_parcours_div
+ modules_div
+ bonus_div
+ ue_div
+ html_sco_header.sco_footer()
)
return f"""
{html_sco_header.sco_header(page_title=title, javascripts=["js/edit_ue.js"])}
<h2>{title}, (formation {formation.acronyme}, version {formation.version})</h2>
<p class="help">Les UEs sont des groupes de modules dans une formation donnée,
utilisés pour la validation (on calcule des moyennes par UE et applique des
seuils ("barres")).
</p>
<p class="help">Note: sauf exception, l'UE n'a pas de coefficient associé.
Seuls les <em>modules</em> ont des coefficients.
</p>
<div class="scobox">
<div class="scobox-title">
Édition de l'UE {('du semestre S'+str(ue.semestre_idx)) if is_apc and ue else ''}
</div>
{tf[1]}
</div>
{clone_form}
{ue_parcours_div}
{modules_div}
<div id="bonus_description"></div>
<div id="ue_list_code" class="sco_box sco_green_bg"></div>
{html_sco_header.sco_footer()}
"""
elif tf[0] == 1:
if create:
if not tf[2]["ue_code"]:
@ -842,8 +837,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
<a href="{url_for('notes.refcomp_show',
scodoc_dept=g.scodoc_dept, refcomp_id=formation.referentiel_competence.id)}"
class="stdlink">
{formation.referentiel_competence.type_titre}
{formation.referentiel_competence.specialite_long}
{formation.referentiel_competence.get_title()}
</a>&nbsp;"""
msg_refcomp = "changer"
H.append(f"""<ul><li>{descr_refcomp}""")
@ -1170,14 +1164,17 @@ def _ue_table_ues(
if has_perm_change:
H.append(
f"""<a class="stdlink" href="{
url_for("notes.ue_set_internal", scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"])
url_for("notes.ue_set_internal",
scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"])
}">transformer en UE ordinaire</a>&nbsp;"""
)
H.append("</span>")
ue_editable = editable and not ue_is_locked(ue["ue_id"])
if ue_editable:
H.append(
'<a class="stdlink" href="ue_edit?ue_id=%(ue_id)s">modifier</a>' % ue
f"""<a class="stdlink" href="{
url_for("notes.ue_edit", scodoc_dept=g.scodoc_dept, ue_id=ue["ue_id"])
}">modifier</a>"""
)
else:
H.append('<span class="locked">[verrouillé]</span>')

View File

@ -30,6 +30,7 @@
Lecture et conversion des ics.
"""
from datetime import timezone
import glob
import os
@ -229,7 +230,7 @@ def translate_calendar(
heure_deb=event["heure_deb"],
heure_fin=event["heure_fin"],
moduleimpl_id=modimpl.id,
jour=event["jour"],
day=event["jour"],
)
if modimpl and group
else None
@ -478,11 +479,11 @@ def convert_ics(
"heure_deb": event.decoded("dtstart")
.replace(tzinfo=timezone.utc)
.astimezone(tz=None)
.strftime("%H:%M"),
.strftime(scu.TIME_FMT),
"heure_fin": event.decoded("dtend")
.replace(tzinfo=timezone.utc)
.astimezone(tz=None)
.strftime("%H:%M"),
.strftime(scu.TIME_FMT),
"jour": event.decoded("dtstart").date().isoformat(),
"start": event.decoded("dtstart").isoformat(),
"end": event.decoded("dtend").isoformat(),

View File

@ -452,7 +452,7 @@ def table_apo_csv_list(semset):
apo_data = sco_apogee_csv.ApoData(csv_data, periode=semset["sem_id"])
t["filename"] = apo_data.apo_csv.titles["apoC_Fichier_Exp"]
t["nb_etuds"] = len(apo_data.etuds)
t["date_str"] = t["date"].strftime("%d/%m/%Y à %H:%M")
t["date_str"] = t["date"].strftime(scu.DATEATIME_FMT)
view_link = url_for(
"notes.view_apo_csv",
scodoc_dept=g.scodoc_dept,
@ -490,6 +490,7 @@ def table_apo_csv_list(semset):
# base_url = '%s?formsemestre_id=%s' % (request.base_url, formsemestre_id),
# caption='Maquettes enregistrées',
preferences=sco_preferences.SemPreferences(),
table_id="apo_csv_list",
)
return tab
@ -582,6 +583,7 @@ def _view_etuds_page(
html_class="table_leftalign",
filename="students_apo",
preferences=sco_preferences.SemPreferences(),
table_id="view_etuds_page",
)
if fmt != "html":
return tab.make_page(fmt=fmt)
@ -798,6 +800,7 @@ def view_apo_csv(etape_apo="", semset_id="", fmt="html"):
filename="students_" + etape_apo,
caption="Étudiants Apogée en " + etape_apo,
preferences=sco_preferences.SemPreferences(),
table_id="view_apo_csv",
)
if fmt != "html":

View File

@ -666,7 +666,9 @@ class EtapeBilan:
col_ids,
self.titres,
html_class="repartition",
html_sortable=True,
html_with_td_classes=True,
table_id="apo-repartition",
).gen(fmt="html")
)
return "\n".join(H)
@ -762,9 +764,9 @@ class EtapeBilan:
rows,
col_ids,
titles,
table_id="detail",
html_class="table_leftalign",
html_sortable=True,
table_id="apo-detail",
).gen(fmt="html")
)
return "\n".join(H)

View File

@ -135,7 +135,7 @@ def evaluation_check_absences_html(
f"""<h2 class="eval_check_absences">{
evaluation.description or "évaluation"
} du {
evaluation.date_debut.strftime("%d/%m/%Y") if evaluation.date_debut else ""
evaluation.date_debut.strftime(scu.DATE_FMT) if evaluation.date_debut else ""
} """
]
if (

View File

@ -90,7 +90,7 @@ def evaluation_create_form(
raise ValueError("missing moduleimpl_id parameter")
numeros = [(e.numero or 0) for e in modimpl.evaluations]
initvalues = {
"jour": time.strftime("%d/%m/%Y", time.localtime()),
"jour": time.strftime(scu.DATE_FMT, time.localtime()),
"note_max": 20,
"numero": (max(numeros) + 1) if numeros else 0,
"publish_incomplete": is_malus,
@ -144,7 +144,7 @@ def evaluation_create_form(
if edit:
initvalues["blocked"] = evaluation.is_blocked()
initvalues["blocked_until"] = (
evaluation.blocked_until.strftime("%d/%m/%Y")
evaluation.blocked_until.strftime(scu.DATE_FMT)
if evaluation.blocked_until
and evaluation.blocked_until < Evaluation.BLOCKED_FOREVER
else ""
@ -231,7 +231,13 @@ def evaluation_create_form(
{
"input_type": "boolcheckbox",
"title": "Prise en compte immédiate",
"explanation": "notes utilisées même si incomplètes",
"explanation": """notes utilisées même si incomplètes (dangereux,
à n'utiliser que dans des cas particuliers
<a target="_blank" rel="noopener noreferrer"
href="https://scodoc.org/Evaluation/#pourquoi-eviter-dutiliser-prise-en-compte-immediate"
>voir la documentation</a>
)
""",
},
),
(
@ -361,6 +367,9 @@ def evaluation_create_form(
+ "\n".join(H)
+ "\n"
+ tf[1]
+ render_template(
"scodoc/forms/evaluation_edit.j2",
)
+ render_template(
"scodoc/help/evaluations.j2", is_apc=is_apc, modimpl=modimpl
)
@ -399,7 +408,7 @@ def evaluation_create_form(
if args.get("blocked_until"):
try:
args["blocked_until"] = datetime.datetime.strptime(
args["blocked_until"], "%d/%m/%Y"
args["blocked_until"], scu.DATE_FMT
)
except ValueError as exc:
raise ScoValueError("Date déblocage (j/m/a) invalide") from exc

View File

@ -70,8 +70,8 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
Colonnes:
- code (UE ou module),
- titre
- type évaluation
- complete
- publiée
- inscrits (non dem. ni def.)
- nb notes manquantes
- nb ATT
@ -81,9 +81,10 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
rows = []
titles = {
"type": "",
"code": "Code",
"code": "Module",
"titre": "",
"date": "Date",
"type_evaluation": "Type",
"complete": "Comptée",
"inscrits": "Inscrits",
"manquantes": "Manquantes", # notes eval non entrées
@ -114,7 +115,9 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
rows.append(row)
line_idx += 1
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]
row = {
"type": "",
@ -126,13 +129,16 @@ def evaluations_recap_table(formsemestre: FormSemestre) -> list[dict]:
evaluation_id=evaluation_id,
),
"_titre_target_attrs": 'class="discretelink"',
"date": e.date_debut.strftime("%d/%m/%Y") 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 "",
"type_evaluation": e.type_abbrev(),
"complete": "oui" if eval_etat.is_complete else "non",
"_complete_target": "#",
"_complete_target_attrs": 'class="bull_link" title="prise en compte dans les moyennes"'
"_complete_target_attrs": (
'class="bull_link" title="prise en compte dans les moyennes"'
if eval_etat.is_complete
else 'class="bull_link incomplete" title="il manque des notes"',
else 'class="bull_link incomplete" title="il manque des notes"'
),
"manquantes": len(modimpl_results.evals_etudids_sans_note[e.id]),
"inscrits": modimpl_results.nb_inscrits_module,
"nb_abs": sum(modimpl_results.evals_notes[e.id] == scu.NOTES_ABSENCE),

View File

@ -25,8 +25,8 @@
#
##############################################################################
"""Evaluations
"""
"""Evaluations"""
import collections
import datetime
import operator
@ -50,6 +50,7 @@ from app.scodoc import sco_cal
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
from app.scodoc import sco_gen_cal
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
from app.scodoc import sco_users
@ -360,6 +361,106 @@ def do_evaluation_etat_in_mod(nt, modimpl: ModuleImpl):
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.description or e.moduleimpl.module.titre_str()}"
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):
"""Page avec calendrier de toutes les evaluations de ce semestre"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
@ -368,56 +469,9 @@ def formsemestre_evaluations_cal(formsemestre_id):
evaluations = formsemestre.get_evaluations()
nb_evals = len(evaluations)
color_incomplete = "#FF6060"
color_complete = "#A0FFA0"
color_futur = "#70E0FF"
year = formsemestre.annee_scolaire()
events = {} # (day, halfday) : event
for e in evaluations:
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("%Hh%M") if e.date_debut else "?"
heure_fin_txt = e.date_fin.strftime("%Hh%M") 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
)
cal = CalendrierEval(year, evaluations, nt)
cal_html = cal.get_html()
return f"""
{
@ -433,15 +487,15 @@ def formsemestre_evaluations_cal(formsemestre_id):
</p>
<ul>
<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
</li>
<li>en <span style=
"background-color: {color_complete}">vert</span>
"background-color: {JourEval.COLOR_COMPLETE}">vert</span>
les évaluations déjà notées
</li>
<li>en <span style=
"background-color: {color_futur}">bleu</span>
"background-color: {JourEval.COLOR_FUTUR}">bleu</span>
les évaluations futures
</li>
</ul>
@ -526,7 +580,7 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"):
"date_first_complete": date_first_complete,
"delai_correction": delai_correction,
"jour": (
e.date_debut.strftime("%d/%m/%Y") if e.date_debut else "sans date"
e.date_debut.strftime(scu.DATE_FMT) if e.date_debut else "sans date"
),
"_jour_target": url_for(
"notes.evaluation_listenotes",
@ -539,7 +593,9 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"):
scodoc_dept=g.scodoc_dept,
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_nomplogin": sco_users.user_info(
e.moduleimpl.responsable_id
@ -577,6 +633,7 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"):
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()}""",
filename=scu.make_filename("evaluations_delais_" + formsemestre.titre_annee()),
table_id="formsemestre_evaluations_delai_correction",
)
return tab.make_page(fmt=fmt)

View File

@ -72,7 +72,7 @@ def xldate_as_datetime(xldate, datemode=0):
Peut lever une ValueError
"""
try:
return datetime.datetime.strptime(xldate, "%d/%m/%Y")
return datetime.datetime.strptime(xldate, scu.DATE_FMT)
except:
return openpyxl.utils.datetime.from_ISO8601(xldate)

View File

@ -45,13 +45,17 @@ class ScoInvalidCSRF(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
# d'erreur gentille.
def __init__(self, msg, dest_url=None):
def __init__(self, msg, dest_url=None, safe=False):
super().__init__(msg)
self.dest_url = dest_url
self.safe = safe # utilisé par template sco_value_error.j2
class ScoPermissionDenied(ScoValueError):
@ -103,7 +107,7 @@ class ScoPDFFormatError(ScoValueError):
super().__init__(
f"""Erreur dans un format pdf:
<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.
</p>
""",

View File

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

View File

@ -32,8 +32,7 @@ from flask import url_for, g, request
from flask_login import current_user
import app
from app.models import Departement
import app.scodoc.sco_utils as scu
from app.models import Departement, Identite
import app.scodoc.notesdb as ndb
from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header
@ -55,7 +54,9 @@ def form_search_etud(
"form recherche par nom"
H = []
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>
<input type="text" name="expnom" class="in-expnom" width="12" spellcheck="false" value="">
<input type="submit" value="Chercher">
@ -100,9 +101,9 @@ def form_search_etud(
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:
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:
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:
etudid = None
if etudid is not None:
etuds = sco_etud.get_etud_info(filled=True, etudid=expnom)
if len(etuds) == 1:
return etuds
etud = Identite.query.filter_by(dept_id=g.scodoc_dept_id, id=etudid).first()
if etud:
return [etud]
expnom_str = str(expnom)
if scu.is_valid_code_nip(expnom_str):
return search_etuds_infos(code_nip=expnom_str)
return search_etuds_infos(expnom=expnom_str)
etuds = Identite.query.filter_by(
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=""):
@ -152,7 +162,7 @@ def search_etud_in_dept(expnom=""):
if len(etuds) == 1:
# 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))
H = [
@ -179,14 +189,39 @@ def search_etud_in_dept(expnom=""):
)
if len(etuds) > 0:
# Choix dans la liste des résultats:
rows = []
e: Identite
for e in etuds:
url_args["etudid"] = e["etudid"]
url_args["etudid"] = e.id
target = url_for(endpoint, **url_args)
e["_nomprenom_target"] = target
e["inscription_target"] = target
e["_nomprenom_td_attrs"] = 'id="%s" class="etudinfo"' % (e["etudid"])
sco_groups.etud_add_group_infos(
e, e["cursem"]["formsemestre_id"] if e["cursem"] else None
cur_inscription = e.inscription_courante()
inscription = (
e.inscription_descr().get("inscription_str", "")
if cur_inscription
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(
@ -197,10 +232,11 @@ def search_etud_in_dept(expnom=""):
"inscription": "Inscription",
"groupes": "Groupes",
},
rows=etuds,
rows=rows,
html_sortable=True,
html_class="table_leftalign",
preferences=sco_preferences.SemPreferences(),
table_id="search_etud_in_dept",
)
H.append(tab.html())
if len(etuds) > 20: # si la page est grande
@ -213,15 +249,16 @@ def search_etud_in_dept(expnom=""):
)
)
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(
"""<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()
# 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
et ramene liste de mappings utilisables en DTML.
"""
@ -264,7 +301,7 @@ def search_etud_by_name(term: str) -> list:
FROM identite
WHERE
dept_id = %(dept_id)s
AND code_nip LIKE %(beginning)s
AND code_nip ILIKE %(beginning)s
ORDER BY nom
""",
{"beginning": term + "%", "dept_id": g.scodoc_dept_id},
@ -283,7 +320,7 @@ def search_etud_by_name(term: str) -> list:
FROM identite
WHERE
dept_id = %(dept_id)s
AND nom LIKE %(beginning)s
AND nom ILIKE %(beginning)s
ORDER BY nom
""",
{"beginning": term + "%", "dept_id": g.scodoc_dept_id},
@ -348,6 +385,7 @@ def table_etud_in_accessible_depts(expnom=None):
rows=etuds,
html_sortable=True,
html_class="table_leftalign",
table_id="etud_in_accessible_depts",
)
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)
T = []
rows = []
for etuds in result:
if etuds:
dept_id = etuds[0]["dept"]
for e in etuds:
for sem in e["sems"]:
T.append(
rows.append(
{
"dept": dept_id,
"etudid": e["etudid"],
@ -414,6 +452,6 @@ def search_inscr_etud_by_nip(code_nip, fmt="json"):
"date_debut_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)

View File

@ -489,9 +489,10 @@ def formation_import_xml(doc: str, import_tags=True, use_local_refcomp=False):
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
and listing associated semestres
and listing associated semestres.
If detail, add column with more details.
returns a table
"""
formations: list[Formation] = Formation.query.filter_by(dept_id=g.scodoc_dept_id)
@ -507,6 +508,7 @@ def formation_list_table() -> GenTable:
)
editable = current_user.has_permission(Permission.EditFormation)
can_implement = current_user.has_permission(Permission.EditFormSemestre)
# Traduit/ajoute des champs à afficher:
rows = []
@ -533,6 +535,15 @@ def formation_list_table() -> GenTable:
if formation.referentiel_competence
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:
row["formsemestres"] = formation.formsemestres.order_by(
@ -547,20 +558,28 @@ def formation_list_table() -> GenTable:
)}">{s.session_id()}</a>"""
for s in row["formsemestres"]
]
+ [
f"""<a class="stdlink" id="add-semestre-{
formation.acronyme.lower().replace(" ", "-")}"
+ (
[
f"""<a class="stdlink"
href="{ url_for("notes.formsemestre_createwithmodules",
scodoc_dept=g.scodoc_dept, formation_id=formation.id, semestre_id=1
)
}">ajouter</a>
"""
]
if can_implement
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"]:
row["date_fin_dernier_sem"] = (
row["formsemestres"][-1].date_fin.isoformat(),
)
row["date_fin_dernier_sem"] = row["formsemestres"][-1].date_fin.isoformat()
row["annee_dernier_sem"] = row["formsemestres"][-1].date_fin.year
else:
row["date_fin_dernier_sem"] = ""
@ -613,6 +632,8 @@ def formation_list_table() -> GenTable:
"commentaire",
"sems_list_txt",
)
if detail:
columns_ids += ("annee_dernier_sem", "semestres_ues")
titles = {
"buttons": "",
"commentaire": "Commentaire",
@ -623,22 +644,25 @@ def formation_list_table() -> GenTable:
"formation_code": "Code",
"sems_list_txt": "Semestres",
"referentiel": "Réf.",
"date_fin_dernier_sem": "Fin dernier sem.",
"annee_dernier_sem": "Année dernier sem.",
"semestres_ues": "Semestres avec UEs",
}
return GenTable(
columns_ids=columns_ids,
rows=rows,
titles=titles,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
base_url=f"{request.base_url}" + ("?detail=on" if detail else ""),
caption=title,
columns_ids=columns_ids,
html_caption=title,
table_id="formation_list_table",
html_class="formation_list_table table_leftalign",
html_with_td_classes=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,
pdf_title=title,
preferences=sco_preferences.SemPreferences(),
rows=rows,
table_id="formation_list_table",
titles=titles,
)

View File

@ -527,15 +527,16 @@ def table_formsemestres(
preferences = sco_preferences.SemPreferences()
tab = GenTable(
columns_ids=columns_ids,
rows=sems,
titles=titles,
html_class="table_leftalign",
html_empty_element="<p><em>aucun résultat</em></p>",
html_next_section=html_next_section,
html_sortable=True,
html_title=html_title,
html_next_section=html_next_section,
html_empty_element="<p><em>aucun résultat</em></p>",
page_title="Semestres",
preferences=preferences,
rows=sems,
table_id="table_formsemestres",
titles=titles,
)
return tab

View File

@ -1431,18 +1431,25 @@ Ceci n'est possible que si :
def formsemestre_delete2(formsemestre_id, dialog_confirmed=False):
"""Delete a formsemestre (confirmation)"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# Confirmation dialog
if not dialog_confirmed:
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="",
cancel_url="formsemestre_status?formsemestre_id=%s" % formsemestre_id,
parameters={"formsemestre_id": formsemestre_id},
cancel_url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
parameters={"formsemestre_id": formsemestre.id},
)
# Bon, s'il le faut...
do_formsemestre_delete(formsemestre_id)
do_formsemestre_delete(formsemestre.id)
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(

View File

@ -521,7 +521,7 @@ def _record_ue_validations_and_coefs(
coef = _convert_field_to_float(coef)
if coef == "" or coef is False:
coef = None
now_dmy = time.strftime("%d/%m/%Y")
now_dmy = time.strftime(scu.DATE_FMT)
log(
f"_record_ue_validations_and_coefs: {formsemestre.id} etudid={etud.id} ue_id={ue.id} moy_ue={note} ue_coef={coef}"
)

View File

@ -106,7 +106,7 @@ def do_formsemestre_inscription_create(args, method=None):
cnx,
args={
"etudid": args["etudid"],
"event_date": time.strftime("%d/%m/%Y"),
"event_date": time.strftime(scu.DATE_FMT),
"formsemestre_id": args["formsemestre_id"],
"event_type": "INSCRIPTION",
},

View File

@ -64,14 +64,11 @@ from app.scodoc import sco_archives_formsemestre
from app.scodoc import sco_bulletins
from app.scodoc import codes_cursus
from app.scodoc import sco_compute_moy
from app.scodoc import sco_edit_ue
from app.scodoc import sco_evaluations
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_formations
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
from app.scodoc import sco_users
from app.scodoc.gen_tables import GenTable
@ -146,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"""
if formsemestre is None:
return ""
formsemestre_id = formsemestre.id
if formsemestre.etat:
change_lock_msg = "Verrouiller"
@ -635,7 +634,7 @@ def formsemestre_description_table(
"UE": modimpl.module.ue.acronyme,
"_UE_td_attrs": ue_info.get("_UE_td_attrs", ""),
"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",
"Inscrits": mod_nb_inscrits,
"Responsable": sco_users.user_info(modimpl.responsable_id)["nomprenom"],
@ -692,7 +691,7 @@ def formsemestre_description_table(
)
e["_date_evaluation_order"] = e["jour"].isoformat()
e["date_evaluation"] = (
e["jour"].strftime("%d/%m/%Y") if e["jour"] else ""
e["jour"].strftime(scu.DATE_FMT) if e["jour"] else ""
)
e["UE"] = row["UE"]
e["_UE_td_attrs"] = row["_UE_td_attrs"]
@ -727,20 +726,21 @@ def formsemestre_description_table(
rows.append(sums)
return GenTable(
columns_ids=columns_ids,
rows=rows,
titles=titles,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}&with_evals={with_evals}",
caption=title,
columns_ids=columns_ids,
html_caption=title,
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(
"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,
preferences=sco_preferences.SemPreferences(formsemestre_id),
rows=rows,
table_id="formsemestre_description_table",
titles=titles,
)
@ -822,6 +822,55 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
</div>
</div>
<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>
<a class="stdlink" href="{
url_for("assiduites.visu_assi_group",
@ -834,46 +883,18 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
</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>
)}" title="Page en cours de fusion et sera prochainement supprimée. Veuillez utiliser la page `Saisir l'assiduité`">
(Saisie différée)</a>
</div>
"""
)
@ -1187,17 +1208,7 @@ def formsemestre_tableau_modules(
mod_descr = "Module " + (mod.titre or "")
is_apc = mod.is_apc() # SAE ou ressource
if is_apc:
coef_descr = ", ".join(
[
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) "
mod_descr += " " + mod.get_ue_coefs_descr()
else:
mod_descr += ", coef. " + str(mod.coefficient)
mod_ens = sco_users.user_info(modimpl.responsable_id)["nomcomplet"]

View File

@ -1160,7 +1160,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
"input_type": "date",
"size": 9,
"explanation": "j/m/a",
"default": time.strftime("%d/%m/%Y"),
"default": time.strftime(scu.DATE_FMT),
},
),
(

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

@ -92,5 +92,6 @@ def groups_export_annotations(group_ids, formsemestre_id=None, fmt="html"):
html_sortable=True,
html_class="table_leftalign",
preferences=sco_preferences.SemPreferences(formsemestre_id),
table_id="groups_export_annotations",
)
return table.make_page(fmt=fmt)

View File

@ -26,7 +26,7 @@
##############################################################################
"""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)
@ -453,6 +453,10 @@ class DisplayedGroupsInfos:
for i in to_remove:
del T[i]
def get_etudids(self) -> set[int]:
"Les etudids des groupes choisis"
return {member["etudid"] for member in self.members}
def get_form_elem(self):
"""html hidden input with groups"""
H = []
@ -581,8 +585,8 @@ def groups_table(
etud_info["_nom_disp_order"] = etud_sort_key(etud_info)
etud_info["_prenom_target"] = fiche_url
etud_info["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % (
etud_info["etudid"]
etud_info["_nom_disp_td_attrs"] = (
'id="%s" class="etudinfo"' % (etud_info["etudid"])
)
etud_info["bourse_str"] = "oui" if etud_info["boursier"] else "non"
if etud_info["etat"] == "D":
@ -657,6 +661,7 @@ def groups_table(
text_fields_separator=prefs["moodle_csv_separator"],
text_with_titles=prefs["moodle_csv_with_headerline"],
preferences=prefs,
table_id="groups_table",
)
#
if fmt == "html":
@ -978,7 +983,7 @@ def form_choix_jour_saisie_hebdo(groups_infos, moduleimpl_id=None):
"assiduites.signal_assiduites_group",
scodoc_dept=g.scodoc_dept,
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,
moduleimpl_id="" if moduleimpl_id is None else moduleimpl_id
)
@ -993,12 +998,12 @@ def form_choix_saisie_semaine(groups_infos):
return ""
query_args = parse_qs(request.query_string)
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"""
<button onclick="window.location='{url_for(
"assiduites.signal_assiduites_diff",
"assiduites.signal_assiduites_hebdo",
group_ids=",".join(map(str,groups_infos.group_ids)),
semaine=semaine,
week=semaine,
scodoc_dept=g.scodoc_dept,
formsemestre_id=groups_infos.formsemestre_id,
moduleimpl_id=moduleimpl_id
@ -1024,10 +1029,9 @@ def export_groups_as_moodle_csv(formsemestre_id=None):
moodle_sem_name = sem["session_id"]
columns_ids = ("email", "semestre_groupe")
T = []
for partition_id in partitions_etud_groups:
rows = []
for partition_id, members in partitions_etud_groups.items():
partition = sco_groups.get_partition(partition_id)
members = partitions_etud_groups[partition_id]
for etudid in members:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
group_name = members[etudid]["group_name"]
@ -1036,16 +1040,17 @@ def export_groups_as_moodle_csv(formsemestre_id=None):
elts.append(partition["partition_name"])
if 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
prefs = sco_preferences.SemPreferences(formsemestre_id)
tab = GenTable(
rows=T,
columns_ids=("email", "semestre_groupe"),
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_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")

View File

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

View File

@ -513,7 +513,7 @@ def _build_page(
<div>{scu.EMO_WARNING}
<em>Seuls les semestres dont la date de fin est proche de la date de début
de ce semestre ({formsemestre.date_debut.strftime("%d/%m/%Y")}) sont pris en
de ce semestre ({formsemestre.date_debut.strftime(scu.DATE_FMT)}) sont pris en
compte.</em>
</div>
{etuds_select_boxes(auth_etuds_by_sem, inscrits_ailleurs)}
@ -704,7 +704,7 @@ def etuds_select_boxes(
elink += (
'<span class="finalisationinscription">'
+ " : inscription finalisée le "
+ etud["datefinalisationinscription"].strftime("%d/%m/%Y")
+ etud["datefinalisationinscription"].strftime(scu.DATE_FMT)
+ "</span>"
)
@ -747,10 +747,11 @@ def etuds_select_box_xls(src_cat):
else:
e["paiementinscription_str"] = "-"
tab = GenTable(
titles=titles,
columns_ids=columns_ids,
rows=etuds,
caption="%(title)s. %(help)s" % src_cat["infos"],
columns_ids=columns_ids,
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"])

View File

@ -599,20 +599,21 @@ def _make_table_notes(
)
# display
tab = GenTable(
titles=titles,
columns_ids=columns_ids,
rows=rows,
html_sortable=True,
base_url=base_url,
filename=filename,
origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}",
caption=caption,
html_next_section=html_next_section,
page_title="Notes de " + formsemestre.titre_mois(),
html_title=html_title,
pdf_title=pdf_title,
columns_ids=columns_ids,
filename=filename,
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),
rows=rows,
table_id="table-liste-notes",
titles=titles,
# html_generate_cells=False # la derniere ligne (moyennes) est incomplete
)
if fmt == "bordereau":
@ -827,7 +828,7 @@ def _add_eval_columns(
nb_notes,
evaluation.description,
(
evaluation.date_debut.strftime("%d/%m/%Y")
evaluation.date_debut.strftime(scu.DATE_FMT)
if evaluation.date_debut
else ""
),

View File

@ -34,22 +34,28 @@ from operator import itemgetter
from flask import url_for, g, request
import app
import app.scodoc.sco_utils as scu
from app.scodoc import html_sco_header
from app.scodoc import sco_formsemestre
from app.scodoc import sco_preferences
from app.scodoc import sco_report
from app.scodoc import sco_etud
import sco_version
from app.scodoc import (
html_sco_header,
sco_formsemestre,
sco_groups_view,
sco_preferences,
sco_report,
sco_etud,
)
from app.models import FormSemestre
from app.scodoc.gen_tables import GenTable
import app.scodoc.sco_utils as scu
import sco_version
def formsemestre_table_etuds_lycees(
formsemestre_id, group_lycees=True, only_primo=False
formsemestre: FormSemestre, groups_infos, group_lycees=True, only_primo=False
):
"""Récupère liste d'etudiants avec etat et decision."""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
etuds = sco_report.tsp_etud_list(formsemestre_id, only_primo=only_primo)[0]
sem = sco_formsemestre.get_formsemestre(formsemestre.id)
etuds = sco_report.tsp_etud_list(
formsemestre.id, groups_infos=groups_infos, only_primo=only_primo
)[0]
if only_primo:
primostr = "primo-entrants du "
else:
@ -59,7 +65,7 @@ def formsemestre_table_etuds_lycees(
etuds,
group_lycees,
title,
sco_preferences.SemPreferences(formsemestre_id),
sco_preferences.SemPreferences(formsemestre.id),
)
@ -174,19 +180,27 @@ def _table_etuds_lycees(etuds, group_lycees, title, preferences, no_links=False)
html_class="table_leftalign table_listegroupe",
bottom_titles=bottom_titles,
preferences=preferences,
table_id="table_etuds_lycees",
)
return tab, etuds_by_lycee
def formsemestre_etuds_lycees(
formsemestre_id,
group_ids: list[int] = None, # si indiqué, ne prend que ces groupes
fmt="html",
only_primo=False,
no_grouping=False,
):
"""Table des lycées d'origine"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
groups_infos = sco_groups_view.DisplayedGroupsInfos(
group_ids,
formsemestre_id=formsemestre.id,
select_all_when_unspecified=True,
)
tab, etuds_by_lycee = formsemestre_table_etuds_lycees(
formsemestre_id, only_primo=only_primo, group_lycees=not no_grouping
formsemestre, groups_infos, only_primo=only_primo, group_lycees=not no_grouping
)
tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id)
if only_primo:
@ -196,13 +210,19 @@ def formsemestre_etuds_lycees(
t = tab.make_page(fmt=fmt, with_html_headers=False)
if fmt != "html":
return t
F = [sco_report.tsp_form_primo_group(only_primo, no_grouping, formsemestre_id, fmt)]
F = [
sco_report.tsp_form_primo_group(
only_primo, no_grouping, formsemestre_id, fmt, groups_infos=groups_infos
)
]
H = [
html_sco_header.sco_header(
page_title=tab.page_title,
init_google_maps=True,
init_qtip=True,
javascripts=["js/etud_info.js", "js/map_lycees.js"],
cssstyles=sco_groups_view.CSSSTYLES,
javascripts=sco_groups_view.JAVASCRIPTS
+ ["js/etud_info.js", "js/map_lycees.js"],
),
"""<h2 class="formsemestre">Lycées d'origine des étudiants</h2>""",
"\n".join(F),

View File

@ -36,37 +36,45 @@ Elle n'est pas utilisée pour les parcours, ni pour rien d'autre
import collections
import app.scodoc.notesdb as ndb
from app import log
from app.models import FormSemestre
def list_formsemestres_modalites(sems):
def list_formsemestres_modalites(formsemestres: list[FormSemestre]) -> list[dict]:
"""Liste ordonnée des modalités présentes dans ces formsemestres"""
modalites = {}
for sem in sems:
if sem["modalite"] not in modalites:
m = do_modalite_list(args={"modalite": sem["modalite"]})[0]
for formsemestre in formsemestres:
if formsemestre.modalite not in modalites:
m = do_modalite_list(args={"modalite": formsemestre.modalite})[0]
modalites[m["modalite"]] = m
modalites = list(modalites.values())
modalites.sort(key=lambda x: x["numero"])
return modalites
def group_sems_by_modalite(sems: list[dict]):
def group_formsemestres_by_modalite(
formsemestres: list[FormSemestre],
) -> dict[str, list[FormSemestre]]:
"""Given the list of formsemestre, group them by modalite,
sorted in each one by semestre id and date
"""
sems_by_mod = collections.defaultdict(list)
modalites = list_formsemestres_modalites(sems)
for modalite in modalites:
for sem in sems:
if sem["semestre_id"] < 0: # formations en un semestre
sem["sortkey"] = (-100 * sem["semestre_id"], sem["dateord"])
else:
sem["sortkey"] = (sem["semestre_id"], sem["dateord"])
if sem["modalite"] == modalite["modalite"]:
sems_by_mod[modalite["modalite"]].append(sem)
modalites = list_formsemestres_modalites(formsemestres)
sems_by_mod = {
modalite["modalite"]: [
formsemestre
for formsemestre in formsemestres
if formsemestre.modalite == modalite["modalite"]
]
for modalite in modalites
}
# tri dans chaque modalité par indice de semestre et date debut
for modalite in modalites:
sems_by_mod[modalite["modalite"]].sort(key=lambda x: x["sortkey"])
sems_by_mod[modalite["modalite"]].sort(
key=lambda x: (
x.semestre_id if x.semestre_id > 0 else -1000 * x.semestre_id,
x.date_debut,
)
)
return sems_by_mod, modalites

View File

@ -50,8 +50,6 @@ from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_module
from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
@ -431,7 +429,7 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
)
if info["ue_status"]["event_date"]:
H.append(
f"""(cap. le {info["ue_status"]["event_date"].strftime("%d/%m/%Y")})"""
f"""(cap. le {info["ue_status"]["event_date"].strftime(scu.DATE_FMT)})"""
)
if is_apc:
is_inscrit_ue = (etud.id, ue.id) not in res.dispense_ues
@ -584,7 +582,7 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
validation = validations_ue[-1] if validations_ue else None
expl_validation = (
f"""Validée ({validation.code}) le {
validation.event_date.strftime("%d/%m/%Y")}"""
validation.event_date.strftime(scu.DATE_FMT)}"""
if validation
else ""
)

View File

@ -28,7 +28,6 @@
"""Tableau de bord module"""
import math
import time
import datetime
from flask import g, url_for
@ -48,13 +47,10 @@ from app.scodoc.sco_permissions import Permission
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_cal
from app.scodoc import sco_compute_moy
from app.scodoc import sco_evaluations
from app.scodoc import sco_formsemestre_status
from app.scodoc import sco_groups
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_permissions_check
from app.tables import list_etuds
@ -68,7 +64,7 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str:
can_edit_notes_ens = modimpl.can_edit_notes(current_user)
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:
sup_label = "Supprimer évaluation"
@ -150,29 +146,48 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str:
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
H = """
coefs_lst = modimpl.module.ue_coefs_list()
max_coef = max(x[1] for x in coefs_lst) if coefs_lst else 1.0
H = f"""
<div id="modimpl_coefs">
<div>Coefficients vers les UE</div>
<div>Coefficients vers les UEs
<span><a class="stdlink" href="{
url_for(
"notes.edit_modules_ue_coefs",
scodoc_dept=g.scodoc_dept,
formation_id=modimpl.module.formation.id,
semestre_idx=modimpl.formsemestre.semestre_id,
)
}">détail</a>
</span>
</div>
"""
if coefs_lst:
H += (
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>"
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:
H += """<div class="missing_value">non définis</div>"""
H += "</div>"
@ -199,12 +214,21 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
Evaluation.date_debut.desc(),
).all()
nb_evaluations = len(evaluations)
max_poids = max(
# Le poids max pour chaque catégorie d'évaluation
max_poids_by_type: dict[int, float] = {}
for eval_type in (
Evaluation.EVALUATION_NORMALE,
Evaluation.EVALUATION_RATTRAPAGE,
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]
or [0.0]
)
#
sem_locked = not formsemestre.etat
@ -262,14 +286,14 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
# 2ieme ligne: Semestre, Coef
H.append("""<tr><td class="fichetitre2">""")
if formsemestre.semestre_id >= 0:
H.append("""Semestre: </td><td>%s""" % formsemestre.semestre_id)
H.append(f"""Semestre: </td><td>{formsemestre.semestre_id}""")
else:
H.append("""</td><td>""")
if sem_locked:
H.append(scu.icontag("lock32_img", title="verrouillé"))
H.append("""</td><td class="fichetitre2">""")
if modimpl.module.is_apc():
H.append(_ue_coefs_html(modimpl.module.ue_coefs_list()))
H.append(_ue_coefs_html(modimpl))
else:
H.append(
f"""Coef. dans le semestre: {
@ -290,7 +314,8 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
)
if current_user.has_permission(Permission.EtudInscrit):
H.append(
f"""<a class="stdlink" style="margin-left:2em;" href="moduleimpl_inscriptions_edit?moduleimpl_id={modimpl.id}">modifier</a>"""
f"""<a class="stdlink" style="margin-left:2em;"
href="moduleimpl_inscriptions_edit?moduleimpl_id={modimpl.id}">modifier</a>"""
)
H.append("</td></tr>")
# Ligne: règle de calcul
@ -321,12 +346,28 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
f"""
<span class="moduleimpl_abs_link"><a class="stdlink" href="{
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()
}&formsemestre_id={formsemestre.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(
@ -338,8 +379,8 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
group_ids=group_id,
formsemestre_id=formsemestre.id,
moduleimpl_id="" if moduleimpl_id is None else moduleimpl_id
)}"
>Saisie Absences Différée</a></span>
)}" title="Page en cours de fusion et sera prochainement supprimée. Veuillez utiliser la page `Saisie Absences`"
>(Saisie Absences Différée)</a></span>
"""
)
@ -347,9 +388,34 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
#
if not modimpl.check_apc_conformity(nt):
H.append(
"""<div class="warning conformite">Les poids des évaluations de ce module ne sont
pas encore conformes au PN.
Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE.
"""<div class="warning conformite">Les poids des évaluations de ce
module ne permettent pas d'évaluer toutes les UEs (compétences)
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>"""
)
@ -440,7 +506,7 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
eval_index=eval_index,
nb_evals=nb_evaluations,
is_apc=nt.is_apc,
max_poids=max_poids,
max_poids=max_poids_by_type.get(evaluation.evaluation_type, 10000.0),
)
)
eval_index -= 1
@ -614,7 +680,7 @@ def _ligne_evaluation(
if evaluation.is_blocked():
etat_txt = f"""évaluation bloquée {
"jusqu'au " + evaluation.blocked_until.strftime("%d/%m/%Y")
"jusqu'au " + evaluation.blocked_until.strftime(scu.DATE_FMT)
if evaluation.blocked_until < Evaluation.BLOCKED_FOREVER
else "" }
"""
@ -657,7 +723,8 @@ def _ligne_evaluation(
<th class="moduleimpl_evaluations">Notes</th>
<th class="moduleimpl_evaluations">Abs</th>
<th class="moduleimpl_evaluations">N</th>
<th class="moduleimpl_evaluations moduleimpl_evaluation_moy" colspan="2"><span>{etat_txt}</span></th>
<th class="moduleimpl_evaluations moduleimpl_evaluation_moy"
colspan="2"><span>{etat_txt}</span></th>
</tr>
<tr class="{tr_class} mievr_in">
<td class="mievr">"""
@ -758,27 +825,27 @@ def _ligne_evaluation(
#
if etat["nb_notes"] == 0:
H.append(f"""<tr class="{tr_class}"><td></td>""")
if modimpl.module.is_apc():
H.append(
f"""<td colspan="8" class="eval_poids">{
evaluation.get_ue_poids_str()}</td>"""
)
else:
H.append('<td colspan="8"></td>')
# if modimpl.module.is_apc():
# H.append(
# f"""<td colspan="8" class="eval_poids">{
# evaluation.get_ue_poids_str()}</td>"""
# )
# else:
# H.append('<td colspan="8"></td>')
H.append("""</tr>""")
else: # il y a deja des notes saisies
gr_moyennes = etat["gr_moyennes"]
first_group = True
# first_group = True
for gr_moyenne in gr_moyennes:
H.append(f"""<tr class="{tr_class}"><td>&nbsp;</td>""")
if first_group and modimpl.module.is_apc():
H.append(
f"""<td class="eval_poids" colspan="4">{
evaluation.get_ue_poids_str()}</td>"""
)
else:
# if first_group and modimpl.module.is_apc():
# H.append(
# f"""<td class="eval_poids" colspan="4">{
# evaluation.get_ue_poids_str()}</td>"""
# )
# else:
H.append("""<td colspan="4"></td>""")
first_group = False
# first_group = False
if gr_moyenne["group_name"] is None:
name = "Tous" # tous
else:
@ -834,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 }
if not ue_poids:
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 ""
H = (
"""<div class="evaluation_poids">"""
return (
f"""<div class="hinton_map {" ".join(classes)}"
style="--size:{size}px;"
title="{title}"
data-tooltip>"""
+ "\n".join(
[
f"""<div title="poids vers {ue.acronyme}: {poids:g}">
<div style="--size:{math.sqrt(poids*(evaluation.coefficient)/max_poids*144)}px;
{'background-color: ' + ue.color + ';' if ue.color else ''}
f"""<div>
<div style="--boxsize:{size*math.sqrt(value/max_val)}px;
{'background-color: ' + color + ';' if color else ''}
"></div>
</div>"""
for ue, poids in (
(db.session.get(UniteEns, ue_id), poids)
for ue_id, poids in ue_poids.items()
)
for value, color in zip(values, colors)
]
)
+ "</div>"
)
return H
def _html_modimpl_etuds_attente(res: ResultatsSemestre, modimpl: ModuleImpl) -> str:

View File

@ -180,7 +180,7 @@ def fiche_etud(etudid=None):
)
else:
info["etat_civil"] = ""
info["ScoURL"] = scu.ScoURL()
info["ScoURL"] = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept)
info["authuser"] = current_user
if restrict_etud_data:
info["info_naissance"] = ""
@ -730,7 +730,7 @@ def get_html_annotations_list(etud: Identite) -> list[str]:
author = User.query.filter_by(user_name=annot.author).first()
html_annotations_list.append(
f"""<tr><td><span class="annodate">Le {
annot.date.strftime("%d/%m/%Y") if annot.date else "?"}
annot.date.strftime(scu.DATE_FMT) if annot.date else "?"}
par {author.get_prenomnom() if author else "?"} :
</span><span class="annoc">{annot.comment or ""}</span></td>{del_link}</tr>
"""

View File

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

View File

@ -335,7 +335,7 @@ class PlacementRunner:
def _production_pdf(self):
pdf_title = "<br>".join(self.desceval)
pdf_title += f"""\nDate : {self.evaluation.date_debut.strftime("%d/%m/%Y")
pdf_title += f"""\nDate : {self.evaluation.date_debut.strftime(scu.DATE_FMT)
if self.evaluation.date_debut else '-'
} - Horaire : {self.evaluation.heure_debut()} à {self.evaluation.heure_fin()
}"""
@ -378,6 +378,7 @@ class PlacementRunner:
preferences=sco_preferences.SemPreferences(
self.moduleimpl_data["formsemestre_id"]
),
table_id="placement_pdf",
)
return tab.make_page(fmt="pdf", with_html_headers=False)
@ -485,7 +486,7 @@ class PlacementRunner:
worksheet.append_blank_row()
worksheet.append_single_cell_row(desceval, self.styles["titres"])
worksheet.append_single_cell_row(
f"""Date : {self.evaluation.date_debut.strftime("%d/%m/%Y")
f"""Date : {self.evaluation.date_debut.strftime(scu.DATE_FMT)
if self.evaluation.date_debut else '-'
} - Horaire : {self.evaluation.heure_debut()} à {self.evaluation.heure_fin()
}""",

View File

@ -517,7 +517,7 @@ def _normalize_apo_fields(infolist):
)
infos["datefinalisationinscription_str"] = infos[
"datefinalisationinscription"
].strftime("%d/%m/%Y")
].strftime(scu.DATE_FMT)
else:
infos["datefinalisationinscription"] = None
infos["datefinalisationinscription_str"] = ""

View File

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

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