Compare commits

...

132 Commits

Author SHA1 Message Date
aa85150f47 Merge branch 'scodoc-master' into pe-comp-avec-ues-multiples 2024-03-15 06:42:26 +01:00
dd98e19624 Amélioration nommencalature moyenne (état intermédiaire) 2024-03-13 19:39:05 +01:00
1712dcf8f7 Ajout de tests pour pe_moy et pe_moytag 2024-03-13 15:22:48 +01:00
dbe7f5d482 Ajout option pour désactiver l'édition de classeurs intermédiaires 2024-03-13 11:49:50 +01:00
5827a37a31 Ajoute nip au nom de l'HTML détaillant les résultats d'un étudiant 2024-03-13 11:39:25 +01:00
24237eb7b7 Mise en place de tests unitaires (à étoffer) 2024-03-13 11:20:33 +01:00
959a98d0a2 Fix bug: get_assiduites_count / feuille_preparation_jury 2024-03-10 04:44:42 +01:00
c91ab67951 Etat intermédiaire 2024-03-07 19:41:30 +01:00
1bed4bb720 Début de la mise en place des RCUEs 2024-03-06 18:32:41 +01:00
7026746385 Désactive UE si pas de notes 2024-03-06 17:19:47 +01:00
817b54d334 Coquille détail par étudiant (main>) 2024-03-06 16:56:09 +01:00
16a12dad59 Cas des moyennes par tag sans notes 2024-03-06 16:53:54 +01:00
99bb0f471b Moyenne avec UEs multiples pour une même comp ; Amélioration calcul coeffs + moyennes notamment pour démissionnaires 2024-03-06 13:53:47 +01:00
35a038fd3a code fmt 2024-03-03 23:27:29 +01:00
b46556c189 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-03-03 23:26:59 +01:00
Iziram
71f90f5261 Assiduité : annulation / suppression fichier justif 2024-03-01 16:35:39 +01:00
Iziram
1b037d6c7c Assiduité : fix format nbabs sidebar 2024-03-01 16:04:16 +01:00
60a97b7baf Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-03-01 15:55:03 +01:00
Iziram
0332553587 Assiduité : correction bug cache 2024-03-01 12:56:00 +01:00
Iziram
958cf435c8 Assiduité : ajout tests unitaire cache + cas justificatifs 2024-03-01 12:56:00 +01:00
Iziram
c69e9c34a0 Assiduité : fix format date 'absences du' 2024-03-01 12:56:00 +01:00
Iziram
17f8771b0b Assiduité : fix bug tableau (actualisation sur les lignes) 2024-03-01 12:56:00 +01:00
Iziram
7eb41fb2eb Assiduité : ajout test api manquant closes #689 2024-03-01 12:56:00 +01:00
Iziram
a79ca4a17d Assiduité : suppression ancien tableaux (inutilisé) 2024-03-01 12:56:00 +01:00
411ef8ae0d vocabulaire: portail > passerelle 2024-03-01 12:56:00 +01:00
169bf17fdd Ajout colonne référentiel à la table des formations 2024-03-01 12:56:00 +01:00
75d4c110a8 Améliore anonymisation (users) + lien contact + cosmetic 2024-03-01 12:56:00 +01:00
9003a2ca87 vocabulaire: portail > passerelle 2024-03-01 12:03:19 +01:00
55ecaa45a9 Ajout colonne référentiel à la table des formations 2024-03-01 12:03:00 +01:00
ab39454a0d Améliore anonymisation (users) + lien contact + cosmetic 2024-03-01 11:12:36 +01:00
Iziram
5158bd0c8f Assiduité : optimisation justification assiduités 2024-02-29 14:20:39 +01:00
Iziram
21b2e0f582 Assiduité : fix bug module selector signal_assiduites_group 2024-02-29 08:47:03 +01:00
e56cbfc5a2 Précision nb abs sur table recap 2024-02-28 23:22:15 +01:00
9cdab8d1ed Merge branch 'pe-but-v4' of https://scodoc.org/git/cleo/ScoDoc-PE 2024-02-28 12:12:40 +01:00
7cdba43e86 Déploie l'option min/max/moy aux tableurs intermédiaires 2024-02-28 11:59:54 +01:00
Iziram
079348bb87 Assiduité : ajout modif justi dans journal etud closes #814 2024-02-28 11:35:16 +01:00
Iziram
c882e0d6a0 Assiduité : couleur minitimeline calendrier fixes #817 2024-02-28 11:09:01 +01:00
9c7576154c Merge branch 'scodoc-master' into pe-but-v4 2024-02-28 11:02:31 +01:00
ce0d5ec9fd Corrige prise en compte des inscriptions aux UEs pour les moyennes par tag 2024-02-28 11:00:32 +01:00
Iziram
3d6be2f200 Assiduité : fix bug assi jour complet + affichage calendrier 2024-02-28 10:50:15 +01:00
e675064cae Corrige bug ressemtagbut sans notes 2024-02-28 10:36:27 +01:00
Iziram
185e061f01 Assiduité : msg erreur ajout_assiduite_etud 2024-02-28 10:23:47 +01:00
Iziram
e4c889ec8a Assiduité : calendrier scroll horizontal 2024-02-28 09:26:15 +01:00
7ef45e0bac Fix calendrier 2024-02-27 23:17:27 +01:00
Iziram
f242fee5ff Assiduité : calendrier fix str_to_time 2024-02-27 23:14:12 +01:00
c960d943d2 Merge assiduites 2024-02-27 21:51:43 +01:00
741168a065 Merge branch 'main96' of https://scodoc.org/git/iziram/ScoDoc 2024-02-27 21:40:51 +01:00
5c9126d263 Merge PE v4 2024-02-27 21:23:45 +01:00
ce63b7f2f5 Merge branch 'pe-but-v4' of https://scodoc.org/git/cleo/ScoDoc-PE into cleo 2024-02-27 21:15:38 +01:00
5e5cb015d0 cosmetic 2024-02-27 21:10:55 +01:00
5fc1800f70 Option nom des colonnes pour publipostage 2024-02-27 19:42:04 +01:00
2459356245 Règle le pb d'affichage de l'export excel final 2024-02-27 18:33:33 +01:00
b1602f0cf3 Neutralise une option 2024-02-27 18:23:31 +01:00
ba28d5f3c8 Ajout de l'option "Afficher les colonnes min/max/moy" 2024-02-27 18:16:25 +01:00
b9b9a172c7 Ajout de l'option "Générer les moyennes par RCUEs (compétences)" 2024-02-27 18:00:06 +01:00
c2a66b607f Ajout de l'option "Générer les moyennes des ressources et des SAEs par UEs" 2024-02-27 17:22:30 +01:00
802e8f4648 Ajout de l'option "Générer les moyennes sur les tags" 2024-02-27 17:11:00 +01:00
Iziram
3184d5d92e Assiduite : ordre options select module 2024-02-27 17:03:15 +01:00
cf7d7d2db8 Merge remote-tracking branch 'scodoc/cleo-pe-BUT-v2' into pe-but-options
# Conflicts:
#	app/pe/pe_view.py
#	app/templates/pe/pe_view_sem_recap.j2
2024-02-27 16:45:15 +01:00
5ea65433be Coquilles 2024-02-27 16:35:36 +01:00
35a20c3307 Coquille 2024-02-27 16:30:00 +01:00
8acd9a12d4 Merge branch 'scodoc-master' into pe-but-v4 2024-02-27 16:20:32 +01:00
2020114c1b Ajout des nvo fichiers du master 2024-02-27 16:20:23 +01:00
a93aa19449 Fin du calcul des moyennes par ressource/saes 2024-02-27 16:18:08 +01:00
Iziram
c620c3b0e1 Assiduité : ménage (linter) 2024-02-27 15:59:48 +01:00
Iziram
c2e77846b9 Assiduité : fix préférence limite année 2024-02-27 15:06:31 +01:00
28b25ad681 Check 2024-02-27 14:58:15 +01:00
5ea79c03a3 Ajoute les moyennes de ressources/saes 2024-02-27 14:39:14 +01:00
fdcf6388f5 Jury BUT: erreur si UE d'un RCUE sans niveau de comp. 2024-02-27 12:59:48 +01:00
Iziram
9dcaf70e18 Assiduité : fix bugs calendrier 2024-02-27 08:58:09 +01:00
20d4b4e1b3 cosmetic: avertissements jury 2024-02-26 21:53:45 +01:00
aaaf41250a Assiduité: formattage comptes marge gauche 2024-02-26 21:28:27 +01:00
Iziram
b3b47a755f Assiduite : Justif 24h + test unit 2024-02-26 18:26:04 +01:00
bc5292b165 Edition des évaluations, nettoyage code, fix #799. Tests OK. 2024-02-26 17:20:36 +01:00
ee601071f5 cosmetic: tableau bord semestre 2024-02-26 14:14:30 +01:00
0cf3b0a782 formsemestre_status: affiche modules avec évals bloquées 2024-02-26 13:55:04 +01:00
49a5ec488d get_etud_ue_status: ignore error if missing etud 2024-02-26 12:54:27 +01:00
a50bbe9223 Fin traitement coeffs 2024-02-26 12:03:19 +01:00
57d616da1a Traitement des coeffs (état intermédiaire) 2024-02-26 10:29:45 +01:00
c0a965d774 Bloque saisie jury si évaluation à paraitre. Modif icon warning. Closes #858 2024-02-25 22:35:48 +01:00
1c01d987be Evaluations bloquées jusqu'à une date. Implements #858 2024-02-25 16:58:59 +01:00
21a794a760 Diverses améliorations d'affichage 2024-02-25 16:25:28 +01:00
41944bcd29 Cache (redis): change timeout par défaut (rafraichissement évaluations chaque heure) 2024-02-25 13:04:50 +01:00
960f8a3462 Améliore les affichages de debug 2024-02-25 12:45:58 +01:00
6821a02956 Fiche par étudiant 2024-02-25 10:39:51 +01:00
47a42d897e Test unitaire évaluation bonus 2024-02-24 17:01:14 +01:00
7f32f1fb99 Evaluations de type bonus. Implements #848 2024-02-24 16:49:41 +01:00
eb56182407 Fiche par étudiant 2024-02-24 12:21:42 +01:00
02b057ca5a Finalisation des interclassements 2024-02-24 10:48:38 +01:00
eff28d64f9 Divers 2024-02-24 09:31:47 +01:00
81fab97018 2 small fixes 2024-02-23 19:03:02 +01:00
a8a711b30a 9.6.943 2024-02-22 18:33:51 +01:00
46cdaf75b8 Fix unit tests 2024-02-22 18:32:51 +01:00
d1d89cc427 Bulletin: détection erreur rare ? 2024-02-22 17:39:18 +01:00
61d35ddac0 Fix: création modules (parcours) 2024-02-22 17:22:56 +01:00
c492cf550a Fix: typo check_formation_ues 2024-02-22 16:50:25 +01:00
2dd7154036 Fix: missing UE.ects 2024-02-22 16:46:19 +01:00
13e7bd4512 Envoi bulletin, génération classeur: choix groupe étudiants 2024-02-22 16:43:00 +01:00
f1ce70e6de Envoi bulletin, génération classeur: choix groupe étudiants 2024-02-22 16:34:11 +01:00
a8ff540e95 Template base: inclusion multiselect + reorganisation 2024-02-22 16:31:42 +01:00
cc3f5d393f Fix: passage d'un semestre à l'autre sans décision de jury 2024-02-22 13:34:03 +01:00
7c794c01d1 Tableau bord semestre: avertissement modules non conformes 2024-02-21 22:39:12 +01:00
746314b2fb Etat intermédiaire sur les interclassements et la synthèse du jury (données sans notes ou tags à revoir) 2024-02-21 20:02:38 +01:00
624ea39edd Fix: edition coef UE null 2024-02-21 17:51:54 +01:00
853bc31422 Fix: traitement erreur si code étape Apo invalide + ajout total ECTS sur fiche 2024-02-21 17:48:19 +01:00
09d59848d6 Fix API unit tests (assoc niveaux formation test) 2024-02-21 15:57:38 +01:00
f31eca97bb Suppression ancien code jury BUT monosemestre inutile 2024-02-21 14:54:17 +01:00
3844ae46d1 Fix (imports, tests). API unit tests breaks on BUT config (bul. court). 2024-02-20 21:55:32 +01:00
fae9fbdd09 Diverses améliorations pour faciliter la config BUT. Voir #862 2024-02-20 21:30:08 +01:00
40a57a9b86 Etat intermédiaire 2024-02-20 21:12:18 +01:00
b5125fa3d7 Génère les RCSTag (mais sont-ils bons ??) 2024-02-20 20:52:44 +01:00
0f446fe0d3 Renomme RCs pour faciliter interprétation + corrige détection des RCSemX 2024-02-20 16:22:22 +01:00
5f656b431b Corrige modif non voulue dans tests/unit/yaml_setud_but.py 2024-02-20 09:18:03 +01:00
83059cd995 Relecture + améliorations diverses (dont tri systématique par etudids_sorted, acronymes_sorted, competences_sorted) des dataframes 2024-02-20 09:13:19 +01:00
8de1a44583 Corrige tri etuds/compétences dans traduction SxTag -> RSCTag 2024-02-19 20:12:49 +01:00
491d600bd4 Finalisation des SxTags avec situation dans lesquels éval du tag en cours 2024-02-19 20:00:11 +01:00
56aa5fbba3 Modernise code inscription/passage semestre. Closes #859 2024-02-19 19:10:20 +01:00
d6a75b176e Amélioration structure codes + mise en place des capitalisations dans les SxTag 2024-02-19 14:50:38 +01:00
e6d61fcd8a export nationalite. Closes #860 2024-02-19 14:10:55 +01:00
70f399e8b7 Coquilles (état intermédiaire) 2024-02-18 19:50:49 +01:00
68bd20f8de Mise en place des RCRCF + de l'agrégation des coeff pour les moyennes de RCSTag 2024-02-18 19:24:03 +01:00
1716daafde Améliorations diverses (suite) 2024-02-17 03:30:19 +01:00
5e49384a90 Améliorations diverses 2024-02-17 02:35:58 +01:00
828c619c74 Améliorations diverses 2024-02-17 02:35:43 +01:00
b8cb592ac9 Calcul des RCS de type Sx (avec sélection du max des UEs des redoublants) 2024-02-16 16:07:48 +01:00
d8381884dc Merge branch 'scodoc-master' into pe-moy-par-ue 2024-02-16 09:37:52 +01:00
883028216f Débute l'aggrégation des moyennes dans des RCS de type Sx (prise en compte de la meilleure des 2 UE en cas de redoublement) 2024-02-15 17:05:03 +01:00
d140240909 Code: modernisation (ue_list, ...) et nettoyage. Tests ok. 2024-02-14 21:45:58 +01:00
267dbb6460 Ajoute les moy par ue et par tag au semtag 2024-02-14 17:00:05 +01:00
02a73de04d Améliore l'analyse des abandons de formation (sans prise en compte du formsemestre_base) 2024-02-14 15:19:21 +01:00
e78a2d3ffe Corrige bug sur l'analyse des abandons de formation 2024-02-14 14:34:22 +01:00
bcb801662a WIP: PE : form paramétrage pe_view_sem_recap 2024-02-09 21:52:33 +01:00
136 changed files with 8354 additions and 5836 deletions

View File

@ -20,14 +20,8 @@ from app.api import api_bp as bp
from app.api import api_web_bp
from app.api import get_model_api_object, tools
from app.decorators import permission_required, scodoc
from app.models import (
Identite,
Justificatif,
Departement,
FormSemestre,
)
from app.models import Identite, Justificatif, Departement, FormSemestre, Scolog
from app.models.assiduites import (
compute_assiduites_justified,
get_formsemestre_from_data,
)
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
@ -315,7 +309,6 @@ def justif_create(etudid: int = None, nip=None, ine=None):
errors: list[dict] = []
success: list[dict] = []
justifs: list[Justificatif] = []
# énumération des justificatifs
for i, data in enumerate(create_list):
@ -327,11 +320,9 @@ def justif_create(etudid: int = None, nip=None, ine=None):
errors.append({"indice": i, "message": obj})
else:
success.append({"indice": i, "message": obj})
justifs.append(justi)
justi.justifier_assiduites()
scass.simple_invalidate_cache(data, etud.id)
# Actualisation des assiduités justifiées en fonction de tous les nouveaux justificatifs
compute_assiduites_justified(etud.etudid, justifs)
return {"errors": errors, "success": success}
@ -500,9 +491,16 @@ def justif_edit(justif_id: int):
return json_error(404, err)
# Mise à jour du justificatif
justificatif_unique.dejustifier_assiduites()
db.session.add(justificatif_unique)
db.session.commit()
Scolog.logdb(
method="edit_justificatif",
etudid=justificatif_unique.etudiant.id,
msg=f"justificatif modif: {justificatif_unique}",
)
# Génération du dictionnaire de retour
# La couverture correspond
# - aux assiduités précédemment justifiées par le justificatif
@ -510,11 +508,7 @@ def justif_edit(justif_id: int):
retour = {
"couverture": {
"avant": avant_ids,
"apres": compute_assiduites_justified(
justificatif_unique.etudid,
[justificatif_unique],
True,
),
"apres": justificatif_unique.justifier_assiduites(),
}
}
# Invalide le cache
@ -591,14 +585,10 @@ def _delete_one(justif_id: int) -> tuple[int, str]:
# On invalide le cache
scass.simple_invalidate_cache(justificatif_unique.to_dict())
# On actualise les assiduités justifiées de l'étudiant concerné
justificatif_unique.dejustifier_assiduites()
# On supprime le justificatif
db.session.delete(justificatif_unique)
# On actualise les assiduités justifiées de l'étudiant concerné
compute_assiduites_justified(
justificatif_unique.etudid,
Justificatif.query.filter_by(etudid=justificatif_unique.etudid).all(),
True,
)
return (200, "OK")
@ -699,7 +689,6 @@ def justif_export(justif_id: int | None = None, filename: str | None = None):
@as_json
@permission_required(Permission.AbsChange)
def justif_remove(justif_id: int = None):
# XXX TODO pas de test unitaire
"""
Supression d'un fichier ou d'une archive
{

View File

@ -104,9 +104,11 @@ class BulletinBUT:
"competence": None, # XXX TODO lien avec référentiel
"moyenne": None,
# Le bonus sport appliqué sur cette UE
"bonus": fmt_note(res.bonus_ues[ue.id][etud.id])
if res.bonus_ues is not None and ue.id in res.bonus_ues
else fmt_note(0.0),
"bonus": (
fmt_note(res.bonus_ues[ue.id][etud.id])
if res.bonus_ues is not None and ue.id in res.bonus_ues
else fmt_note(0.0)
),
"malus": fmt_note(res.malus[ue.id][etud.id]),
"capitalise": None, # "AAAA-MM-JJ" TODO #sco93
"ressources": self.etud_ue_mod_results(etud, ue, res.ressources),
@ -181,14 +183,16 @@ class BulletinBUT:
"is_external": ue_capitalisee.is_external,
"date_capitalisation": ue_capitalisee.event_date,
"formsemestre_id": ue_capitalisee.formsemestre_id,
"bul_orig_url": url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
formsemestre_id=ue_capitalisee.formsemestre_id,
)
if ue_capitalisee.formsemestre_id
else None,
"bul_orig_url": (
url_for(
"notes.formsemestre_bulletinetud",
scodoc_dept=g.scodoc_dept,
etudid=etud.id,
formsemestre_id=ue_capitalisee.formsemestre_id,
)
if ue_capitalisee.formsemestre_id
else None
),
"ressources": {}, # sans détail en BUT
"saes": {},
}
@ -227,13 +231,15 @@ class BulletinBUT:
"id": modimpl.id,
"titre": modimpl.module.titre,
"code_apogee": modimpl.module.code_apogee,
"url": url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
if has_request_context()
else "na",
"url": (
url_for(
"notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept,
moduleimpl_id=modimpl.id,
)
if has_request_context()
else "na"
),
"moyenne": {
# # moyenne indicative de module: moyenne des UE,
# # ignorant celles sans notes (nan)
@ -242,18 +248,20 @@ class BulletinBUT:
# "max": fmt_note(moyennes_etuds.max()),
# "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"]
)
]
if version != "short"
else [],
"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"]
)
]
if version != "short"
else []
),
}
return d
@ -274,35 +282,43 @@ class BulletinBUT:
poids = collections.defaultdict(lambda: 0.0)
d = {
"id": e.id,
"coef": fmt_note(e.coefficient)
if e.evaluation_type == scu.EVALUATION_NORMALE
else None,
"coef": (
fmt_note(e.coefficient)
if e.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,
"note": {
"value": fmt_note(
eval_notes[etud.id],
note_max=e.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),
},
"note": (
{
"value": fmt_note(
eval_notes[etud.id],
note_max=e.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),
}
if not e.is_blocked()
else {}
),
"poids": poids,
"url": url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.id,
)
if has_request_context()
else "na",
"url": (
url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
evaluation_id=e.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,
"heure_debut": (
e.date_debut.time().isoformat("minutes") if e.date_debut else None
),
"heure_fin": e.date_fin.time().isoformat("minutes") if e.date_fin else None,
}
return d
@ -343,7 +359,7 @@ class BulletinBUT:
"short" : ne descend pas plus bas que les modules.
- Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai
(bulletins non publiés).
(bulletins non publiés sur la passerelle).
"""
if version not in scu.BULLETINS_VERSIONS_BUT:
raise ScoValueError("bulletin_etud: version de bulletin demandée invalide")
@ -377,7 +393,7 @@ class BulletinBUT:
else:
etud_ues_ids = res.etud_ues_ids(etud.id)
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
nbabsnj, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
etud_groups = sco_groups.get_etud_formsemestre_groups(
etud, formsemestre, only_to_show=True
)
@ -392,7 +408,7 @@ class BulletinBUT:
}
if self.prefs["bul_show_abs"]:
semestre_infos["absences"] = {
"injustifie": nbabs - nbabsjust,
"injustifie": nbabsnj,
"total": nbabs,
"metrique": {
"H.": "Heure(s)",
@ -509,7 +525,7 @@ class BulletinBUT:
d["demission"] = ""
# --- Absences
d["nbabs"], d["nbabsjust"] = self.res.formsemestre.get_abs_count(etud.id)
_, d["nbabsjust"], d["nbabs"] = self.res.formsemestre.get_abs_count(etud.id)
# --- Decision Jury
infos, _ = sco_bulletins.etud_descr_situation_semestre(

View File

@ -119,6 +119,12 @@ def _build_bulletin_but_infos(
refcomp = formsemestre.formation.referentiel_competence
if refcomp is None:
raise ScoNoReferentielCompetences(formation=formsemestre.formation)
warn_html = cursus_but.formsemestre_warning_apc_setup(
formsemestre, bulletins_sem.res
)
if warn_html:
raise ScoValueError("<b>Formation mal configurée pour le BUT</b>" + warn_html)
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud
)

View File

@ -24,7 +24,7 @@ from reportlab.lib.colors import blue
from reportlab.lib.units import cm, mm
from reportlab.platypus import Paragraph, Spacer
from app.models import ScoDocSiteConfig
from app.models import Evaluation, ScoDocSiteConfig
from app.scodoc.sco_bulletins_standard import BulletinGeneratorStandard
from app.scodoc import gen_tables
from app.scodoc.codes_cursus import UE_SPORT
@ -422,7 +422,11 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
def evaluations_rows(self, rows, evaluations: list[dict], ue_acros=()):
"lignes des évaluations"
for e in evaluations:
coef = e["coef"] if e["evaluation_type"] == scu.EVALUATION_NORMALE else "*"
coef = (
e["coef"]
if e["evaluation_type"] == Evaluation.EVALUATION_NORMALE
else "*"
)
t = {
"titre": f"{e['description'] or ''}",
"moyenne": e["note"]["value"],
@ -431,7 +435,10 @@ class BulletinGeneratorStandardBUT(BulletinGeneratorStandard):
),
"coef": coef,
"_coef_pdf": Paragraph(
f"<para align=right fontSize={self.small_fontsize}><i>{coef}</i></para>"
f"""<para align=right fontSize={self.small_fontsize}><i>{
coef if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
else "bonus"
}</i></para>"""
),
"_pdf_style": [
(

View File

@ -38,14 +38,11 @@ import datetime
from xml.etree import ElementTree
from xml.etree.ElementTree import Element
from app import log
from app import db, log
from app.but import bulletin_but
from app.models import BulAppreciations, FormSemestre, Identite
from app.models import BulAppreciations, FormSemestre, Identite, UniteEns
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_ue
from app.scodoc import sco_etud
from app.scodoc import sco_photos
from app.scodoc import sco_preferences
from app.scodoc import sco_xml
@ -202,12 +199,12 @@ def bulletin_but_xml_compat(
if e.visibulletin or version == "long":
x_eval = Element(
"evaluation",
date_debut=e.date_debut.isoformat()
if e.date_debut
else "",
date_fin=e.date_fin.isoformat()
if e.date_debut
else "",
date_debut=(
e.date_debut.isoformat() if e.date_debut else ""
),
date_fin=(
e.date_fin.isoformat() if e.date_debut else ""
),
coefficient=str(e.coefficient),
# pas les poids en XML compat
evaluation_type=str(e.evaluation_type),
@ -215,9 +212,9 @@ def bulletin_but_xml_compat(
# notes envoyées sur 20, ceci juste pour garder trace:
note_max_origin=str(e.note_max),
# --- deprecated
jour=e.date_debut.isoformat()
if e.date_debut
else "",
jour=(
e.date_debut.isoformat() if e.date_debut else ""
),
heure_debut=e.heure_debut(),
heure_fin=e.heure_fin(),
)
@ -244,7 +241,7 @@ def bulletin_but_xml_compat(
# --- Absences
if sco_preferences.get_preference("bul_show_abs", formsemestre_id):
nbabs, nbabsjust = formsemestre.get_abs_count(etud.id)
_, nbabsjust, nbabs = formsemestre.get_abs_count(etud.id)
doc.append(Element("absences", nbabs=str(nbabs), nbabsjust=str(nbabsjust)))
# -------- LA SUITE EST COPIEE SANS MODIF DE sco_bulletins_xml.py ---------
@ -294,17 +291,18 @@ def bulletin_but_xml_compat(
"decisions_ue"
]: # and sco_preferences.get_preference( 'bul_show_uevalid', formsemestre_id): always publish (car utile pour export Apogee)
for ue_id in decision["decisions_ue"].keys():
ue = sco_edit_ue.ue_list({"ue_id": ue_id})[0]
doc.append(
Element(
"decision_ue",
ue_id=str(ue["ue_id"]),
numero=quote_xml_attr(ue["numero"]),
acronyme=quote_xml_attr(ue["acronyme"]),
titre=quote_xml_attr(ue["titre"]),
code=decision["decisions_ue"][ue_id]["code"],
ue = db.session.get(UniteEns, ue_id)
if ue:
doc.append(
Element(
"decision_ue",
ue_id=str(ue.id),
numero=quote_xml_attr(ue.numero),
acronyme=quote_xml_attr(ue.acronyme),
titre=quote_xml_attr(ue.titre or ""),
code=decision["decisions_ue"][ue_id]["code"],
)
)
)
for aut in decision["autorisations"]:
doc.append(

View File

@ -23,29 +23,21 @@ from app.comp.res_but import ResultatsSemestreBUT
from app.comp.res_compat import NotesTableCompat
from app.models.but_refcomp import (
ApcAnneeParcours,
ApcCompetence,
ApcNiveau,
ApcParcours,
ApcParcoursNiveauCompetence,
ApcReferentielCompetences,
)
from app.models import Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
)
from app.models.ues import UEParcours
from app.models.but_validations import ApcValidationRCUE
from app.models.etudiants import Identite
from app.models.formations import Formation
from app.models.formsemestre import FormSemestre, FormSemestreInscription
from app.models.formsemestre import FormSemestre
from app.models.ues import UniteEns
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD
from app.scodoc import sco_utils as scu
from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError
from app.scodoc import sco_cursus_dut
@ -122,10 +114,13 @@ class EtudCursusBUT:
validation_rcue: ApcValidationRCUE
for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud):
niveau = validation_rcue.niveau()
if (
niveau is None
or not niveau.competence.id in self.validation_par_competence_et_annee
):
if niveau is None:
raise ScoValueError(
"""UE d'un RCUE non associée à un niveau de compétence.
Vérifiez la formation et les associations de ses UEs.
"""
)
if not niveau.competence.id in self.validation_par_competence_et_annee:
self.validation_par_competence_et_annee[niveau.competence.id] = {}
previous_validation = self.validation_par_competence_et_annee.get(
niveau.competence.id
@ -440,11 +435,16 @@ def formsemestre_warning_apc_setup(
"""
if not formsemestre.formation.is_apc():
return ""
url_formation = url_for(
"notes.ue_table",
scodoc_dept=g.scodoc_dept,
formation_id=formsemestre.formation.id,
semestre_idx=formsemestre.semestre_id,
)
if formsemestre.formation.referentiel_competence is None:
return f"""<div class="formsemestre_status_warning">
La <a class="stdlink" href="{
url_for("notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id)
}">formation n'est pas associée à un référentiel de compétence.</a>
La <a class="stdlink" href="{url_formation}">formation
n'est pas associée à un référentiel de compétence.</a>
</div>
"""
H = []
@ -462,7 +462,9 @@ def formsemestre_warning_apc_setup(
)
if nb_ues_sans_parcours != nb_ues_tot:
H.append(
f"""Le semestre n'est associé à aucun parcours, mais les UEs de la formation ont des parcours"""
"""Le semestre n'est associé à aucun parcours,
mais les UEs de la formation ont des parcours
"""
)
# Vérifie les niveaux de chaque parcours
for parcour in formsemestre.parcours or [None]:
@ -489,7 +491,8 @@ def formsemestre_warning_apc_setup(
if not H:
return ""
return f"""<div class="formsemestre_status_warning">
Problème dans la configuration de la formation:
Problème dans la
<a class="stdlink" href="{url_formation}">configuration de la formation</a>:
<ul>
<li>{ '</li><li>'.join(H) }</li>
</ul>
@ -502,6 +505,79 @@ def formsemestre_warning_apc_setup(
"""
def formation_semestre_niveaux_warning(formation: Formation, semestre_idx: int) -> str:
"""Vérifie que tous les niveaux de compétences de cette année de formation
ont bien des UEs.
Afin de ne pas générer trop de messages, on ne considère que les parcours
du référentiel de compétences pour lesquels au moins une UE a été associée.
Renvoie fragment de html
"""
annee = (semestre_idx - 1) // 2 + 1 # année BUT
ref_comp: ApcReferentielCompetences = formation.referentiel_competence
if not ref_comp:
return "" # détecté ailleurs...
niveaux_sans_ue_by_parcour = {} # { parcour.code : [ ue ... ] }
parcours_ids = {
uep.parcours_id
for uep in UEParcours.query.join(UniteEns).filter_by(
formation_id=formation.id, type=UE_STANDARD
)
}
for parcour in ref_comp.parcours:
if parcour.id not in parcours_ids:
continue # saute parcours associés à aucune UE (tous semestres)
niveaux_sans_ue = []
niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp)
# print(f"\n# Parcours {parcour.code} : {len(niveaux)} niveaux")
for niveau in niveaux:
ues = [ue for ue in formation.ues if ue.niveau_competence_id == niveau.id]
if not ues:
niveaux_sans_ue.append(niveau)
# print( niveau.competence.titre + " " + str(niveau.ordre) + "\t" + str(ue) )
if niveaux_sans_ue:
niveaux_sans_ue_by_parcour[parcour.code] = niveaux_sans_ue
#
H = []
for parcour_code, niveaux in niveaux_sans_ue_by_parcour.items():
H.append(
f"""<li>Parcours {parcour_code} : {
len(niveaux)} niveaux sans UEs
<span>
{ ', '.join( f'{niveau.competence.titre} {niveau.ordre}'
for niveau in niveaux
)
}
</span>
</li>
"""
)
# Combien de compétences de tronc commun ?
_, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee)
nb_niveaux_tc = len(niveaux_by_parcours["TC"])
nb_ues_tc = len(
formation.query_ues_parcour(None)
.filter(UniteEns.semestre_idx == semestre_idx)
.all()
)
if nb_niveaux_tc != nb_ues_tc:
H.append(
f"""<li>{nb_niveaux_tc} niveaux de compétences de tronc commun,
mais {nb_ues_tc} UEs de tronc commun ! (c'est normal si
vous avez des UEs différenciées par parcours)</li>"""
)
if H:
return f"""<div class="formation_semestre_niveaux_warning">
<div>Problèmes détectés à corriger :</div>
<ul>
{"".join(H)}
</ul>
</div>
"""
return "" # no problem detected
def ue_associee_au_niveau_du_parcours(
ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S"
) -> UniteEns:

View File

@ -77,7 +77,7 @@ from app.models.but_refcomp import (
ApcNiveau,
ApcParcours,
)
from app.models import Scolog, ScolarAutorisationInscription
from app.models import Evaluation, Scolog, ScolarAutorisationInscription
from app.models.but_validations import (
ApcValidationAnnee,
ApcValidationRCUE,
@ -260,11 +260,11 @@ class DecisionsProposeesAnnee(DecisionsProposees):
else []
)
# ---- Niveaux et RCUEs
niveaux_by_parcours = (
formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
self.annee_but, [self.parcour] if self.parcour else None
)[1]
)
niveaux_by_parcours = formsemestre.formation.referentiel_competence.get_niveaux_by_parcours(
self.annee_but, [self.parcour] if self.parcour else None
)[
1
]
self.niveaux_competences = niveaux_by_parcours["TC"] + (
niveaux_by_parcours[self.parcour.id] if self.parcour else []
)
@ -358,13 +358,17 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# self.codes = [] # pas de décision annuelle sur semestres impairs
elif self.inscription_etat != scu.INSCRIT:
self.codes = [
sco_codes.DEM
if self.inscription_etat == scu.DEMISSION
else sco_codes.DEF,
(
sco_codes.DEM
if self.inscription_etat == scu.DEMISSION
else sco_codes.DEF
),
# propose aussi d'autres codes, au cas où...
sco_codes.DEM
if self.inscription_etat != scu.DEMISSION
else sco_codes.DEF,
(
sco_codes.DEM
if self.inscription_etat != scu.DEMISSION
else sco_codes.DEF
),
sco_codes.ABAN,
sco_codes.ABL,
sco_codes.EXCLU,
@ -595,11 +599,9 @@ class DecisionsProposeesAnnee(DecisionsProposees):
# Ordonne par numéro d'UE
niv_rcue = sorted(
self.rcue_by_niveau.items(),
key=lambda x: x[1].ue_1.numero
if x[1].ue_1
else x[1].ue_2.numero
if x[1].ue_2
else 0,
key=lambda x: (
x[1].ue_1.numero if x[1].ue_1 else x[1].ue_2.numero if x[1].ue_2 else 0
),
)
return {
niveau_id: DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
@ -816,9 +818,15 @@ class DecisionsProposeesAnnee(DecisionsProposees):
Return: True si au moins un code modifié et enregistré.
"""
modif = False
# Vérification notes en attente dans formsemestre origine
if only_validantes and self.has_notes_en_attente():
return False
if only_validantes:
if self.has_notes_en_attente():
# notes en attente dans formsemestre origine
return False
if Evaluation.get_evaluations_blocked_for_etud(
self.formsemestre, self.etud
):
# évaluation(s) qui seront débloquées dans le futur
return False
# Toujours valider dans l'ordre UE, RCUE, Année
annee_scolaire = self.formsemestre.annee_scolaire()
@ -1488,9 +1496,11 @@ class DecisionsProposeesUE(DecisionsProposees):
self.validation = None # cache toute validation
self.explanation = "non inscrit (dem. ou déf.)"
self.codes = [
sco_codes.DEM
if res.get_etud_etat(etud.id) == scu.DEMISSION
else sco_codes.DEF
(
sco_codes.DEM
if res.get_etud_etat(etud.id) == scu.DEMISSION
else sco_codes.DEF
)
]
return

View File

@ -331,250 +331,6 @@ def _gen_but_rcue(dec_rcue: DecisionsProposeesRCUE, niveau: ApcNiveau) -> str:
"""
def jury_but_semestriel(
formsemestre: FormSemestre,
etud: Identite,
read_only: bool,
navigation_div: str = "",
) -> str:
"""Page: formulaire saisie décision d'UE d'un semestre BUT isolé (pas jury annuel)."""
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour, ues = jury_but.list_ue_parcour_etud(formsemestre, etud, res)
inscription_etat = etud.inscription_etat(formsemestre.id)
semestre_terminal = (
formsemestre.semestre_id >= formsemestre.formation.get_cursus().NB_SEM
)
autorisations_passage = ScolarAutorisationInscription.query.filter_by(
etudid=etud.id,
origin_formsemestre_id=formsemestre.id,
).all()
# Par défaut: autorisé à passer dans le semestre suivant si sem. impair,
# ou si décision déjà enregistrée:
est_autorise_a_passer = (formsemestre.semestre_id % 2) or (
formsemestre.semestre_id + 1
) in (a.semestre_id for a in autorisations_passage)
decisions_ues = {
ue.id: DecisionsProposeesUE(etud, formsemestre, ue, inscription_etat)
for ue in ues
}
for dec_ue in decisions_ues.values():
dec_ue.compute_codes()
if request.method == "POST":
if not read_only:
for key in request.form:
code = request.form[key]
# Codes d'UE
code_match = re.match(r"^code_ue_(\d+)$", key)
if code_match:
ue_id = int(code_match.group(1))
dec_ue = decisions_ues.get(ue_id)
if not dec_ue:
raise ScoValueError(f"UE invalide ue_id={ue_id}")
dec_ue.record(code)
db.session.commit()
flash("codes enregistrés")
if not semestre_terminal:
if request.form.get("autorisation_passage"):
if not formsemestre.semestre_id + 1 in (
a.semestre_id for a in autorisations_passage
):
ScolarAutorisationInscription.delete_autorisation_etud(
etud.id, formsemestre.id
)
ScolarAutorisationInscription.autorise_etud(
etud.id,
formsemestre.formation.formation_code,
formsemestre.id,
formsemestre.semestre_id + 1,
)
db.session.commit()
flash(
f"""autorisation de passage en S{formsemestre.semestre_id + 1
} enregistrée"""
)
else:
if est_autorise_a_passer:
ScolarAutorisationInscription.delete_autorisation_etud(
etud.id, formsemestre.id
)
db.session.commit()
flash(
f"autorisation de passage en S{formsemestre.semestre_id + 1} annulée"
)
ScolarNews.add(
typ=ScolarNews.NEWS_JURY,
obj=formsemestre.id,
text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
url=url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
)
return flask.redirect(
url_for(
"notes.formsemestre_validation_but",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
etudid=etud.id,
)
)
# GET
if formsemestre.semestre_id % 2 == 0:
warning = f"""<div class="warning">
Cet étudiant de S{formsemestre.semestre_id} ne peut pas passer
en jury BUT annuel car il lui manque le semestre précédent.
</div>"""
else:
warning = ""
H = [
html_sco_header.sco_header(
page_title=f"Validation BUT S{formsemestre.semestre_id}",
formsemestre_id=formsemestre.id,
etudid=etud.id,
cssstyles=("css/jury_but.css",),
javascripts=("js/jury_but.js",),
),
f"""
<div class="jury_but">
<div>
<div class="bull_head">
<div>
<div class="titre_parcours">Jury BUT S{formsemestre.id}
- Parcours {(parcour.libelle if parcour else False) or "non spécifié"}
</div>
<div class="nom_etud">{etud.nomprenom}</div>
</div>
<div class="bull_photo"><a href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
</div>
</div>
<h3>Jury sur un semestre BUT isolé (ne concerne que les UEs)</h3>
{warning}
</div>
<form method="post" class="jury_but_box" id="jury_but">
""",
]
erase_span = ""
if not read_only:
# Requête toutes les validations (pas seulement celles du deca courant),
# au cas où: changement d'architecture, saisie en mode classique, ...
validations = ScolarFormSemestreValidation.query.filter_by(
etudid=etud.id, formsemestre_id=formsemestre.id
).all()
if validations:
erase_span = f"""<a href="{
url_for("notes.formsemestre_jury_but_erase",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id,
etudid=etud.id, only_one_sem=1)
}" class="stdlink">effacer les décisions enregistrées</a>"""
else:
erase_span = (
"Cet étudiant n'a aucune décision enregistrée pour ce semestre."
)
H.append(
f"""
<div class="but_section_annee">
</div>
<div><b>Unités d'enseignement de S{formsemestre.semestre_id}:</b></div>
"""
)
if not ues:
H.append(
"""<div class="warning">Aucune UE ! Vérifiez votre programme de
formation, et l'association UEs / Niveaux de compétences</div>"""
)
else:
H.append(
"""
<div class="but_annee">
<div class="titre"></div>
<div class="titre"></div>
<div class="titre"></div>
<div class="titre"></div>
"""
)
for ue in ues:
dec_ue = decisions_ues[ue.id]
H.append("""<div class="but_niveau_titre"><div></div></div>""")
H.append(
_gen_but_niveau_ue(
ue,
dec_ue,
disabled=read_only,
)
)
H.append(
"""<div style=""></div>
<div class=""></div>"""
)
H.append("</div>") # but_annee
div_autorisations_passage = (
f"""
<div class="but_autorisations_passage">
<span>Autorisé à passer en&nbsp;:</span>
{ ", ".join( ["S" + str(a.semestre_id or '') for a in autorisations_passage ] )}
</div>
"""
if autorisations_passage
else """<div class="but_autorisations_passage but_explanation">pas d'autorisations de passage enregistrées.</div>"""
)
H.append(div_autorisations_passage)
if read_only:
H.append(
f"""<div class="but_explanation">
{"Vous n'avez pas la permission de modifier ces décisions."
if formsemestre.etat
else "Semestre verrouillé."}
Les champs entourés en vert sont enregistrés.
</div>
"""
)
else:
if formsemestre.semestre_id < formsemestre.formation.get_cursus().NB_SEM:
H.append(
f"""
<div class="but_settings">
<input type="checkbox" name="autorisation_passage" value="1" {
"checked" if est_autorise_a_passer else ""}>
<em>autoriser à passer dans le semestre S{formsemestre.semestre_id+1}</em>
</input>
</div>
"""
)
else:
H.append("""<div class="help">dernier semestre de la formation.</div>""")
H.append(
f"""
<div class="but_buttons">
<span><input type="submit" value="Enregistrer ces décisions"></span>
<span>{erase_span}</span>
</div>
"""
)
H.append(navigation_div)
H.append("</div>")
H.append(
render_template(
"but/documentation_codes_jury.j2",
nom_univ=f"""Export {sco_preferences.get_preference("InstituteName")
or sco_preferences.get_preference("UnivName")
or "Apogée"}""",
codes=ScoDocSiteConfig.get_codes_apo_dict(),
)
)
return "\n".join(H)
# -------------
def infos_fiche_etud_html(etudid: int) -> str:
"""Section html pour fiche etudiant

View File

@ -35,7 +35,6 @@ moyenne générale d'une UE.
"""
import dataclasses
from dataclasses import dataclass
import numpy as np
import pandas as pd
import sqlalchemy as sa
@ -151,17 +150,18 @@ class ModuleImplResults:
self.evaluations_completes_dict = {}
for evaluation in moduleimpl.evaluations:
eval_df = self._load_evaluation_notes(evaluation)
# is_complete ssi tous les inscrits (non dem) au semestre ont une note
# ou évaluation déclarée "à prise en compte immédiate"
# Les évaluations de rattrapage et 2eme session sont toujours complètes
# is_complete ssi
# tous les inscrits (non dem) au module ont une note
# ou évaluation déclarée "à prise en compte immédiate"
# ou rattrapage, 2eme session, bonus
# ET pas bloquée par date (is_blocked)
etudids_sans_note = inscrits_module - set(eval_df.index) # sans les dem.
is_complete = (
(evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE)
or (evaluation.evaluation_type == scu.EVALUATION_SESSION2)
(evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
or (evaluation.publish_incomplete)
or (not etudids_sans_note)
)
) and not evaluation.is_blocked()
self.evaluations_completes.append(is_complete)
self.evaluations_completes_dict[evaluation.id] = is_complete
self.evals_etudids_sans_note[evaluation.id] = etudids_sans_note
@ -186,7 +186,7 @@ class ModuleImplResults:
].index
)
if evaluation.publish_incomplete:
# et en "imédiat", tous ceux sans note
# et en "immédiat", tous ceux sans note
eval_etudids_attente |= etudids_sans_note
# Synthèse pour état du module:
self.etudids_attente |= eval_etudids_attente
@ -240,19 +240,20 @@ class ModuleImplResults:
).formsemestre.inscriptions
]
def get_evaluations_coefs(self, moduleimpl: ModuleImpl) -> np.array:
def get_evaluations_coefs(self, modimpl: ModuleImpl) -> np.array:
"""Coefficients des évaluations.
Les coefs des évals incomplètes et non "normales" (session 2, rattrapage)
sont zéro.
Les coefs des évals incomplètes, rattrapage, session 2, bonus sont forcés à zéro.
Résultat: 2d-array of floats, shape (nb_evals, 1)
"""
return (
np.array(
[
e.coefficient
if e.evaluation_type == scu.EVALUATION_NORMALE
else 0.0
for e in moduleimpl.evaluations
(
e.coefficient
if e.evaluation_type == Evaluation.EVALUATION_NORMALE
else 0.0
)
for e in modimpl.evaluations
],
dtype=float,
)
@ -276,7 +277,7 @@ class ModuleImplResults:
) / [e.note_max / 20.0 for e in moduleimpl.evaluations]
def get_eval_notes_dict(self, evaluation_id: int) -> dict:
"""Notes d'une évaulation, brutes, sous forme d'un dict
"""Notes d'une évaluation, brutes, sous forme d'un dict
{ etudid : valeur }
avec les valeurs float, ou "ABS" ou EXC
"""
@ -285,7 +286,7 @@ class ModuleImplResults:
for (etudid, x) in self.evals_notes[evaluation_id].items()
}
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl):
def get_evaluation_rattrapage(self, moduleimpl: ModuleImpl) -> Evaluation | None:
"""L'évaluation de rattrapage de ce module, ou None s'il n'en a pas.
Rattrapage: la moyenne du module est la meilleure note entre moyenne
des autres évals et la note eval rattrapage.
@ -293,25 +294,41 @@ class ModuleImplResults:
eval_list = [
e
for e in moduleimpl.evaluations
if e.evaluation_type == scu.EVALUATION_RATTRAPAGE
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE
]
if eval_list:
return eval_list[0]
return None
def get_evaluation_session2(self, moduleimpl: ModuleImpl):
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.
"""
eval_list = [
e
for e in moduleimpl.evaluations
if e.evaluation_type == scu.EVALUATION_SESSION2
if e.evaluation_type == Evaluation.EVALUATION_SESSION2
]
if eval_list:
return eval_list[0]
return None
def get_evaluations_bonus(self, modimpl: ModuleImpl) -> list[Evaluation]:
"""Les évaluations bonus de ce module, ou liste vide s'il n'en a pas."""
return [
e
for e in modimpl.evaluations
if e.evaluation_type == Evaluation.EVALUATION_BONUS
]
def get_evaluations_bonus_idx(self, modimpl: ModuleImpl) -> list[int]:
"""Les indices des évaluations bonus"""
return [
i
for (i, e) in enumerate(modimpl.evaluations)
if e.evaluation_type == Evaluation.EVALUATION_BONUS
]
class ModuleImplResultsAPC(ModuleImplResults):
"Calcul des moyennes de modules à la mode BUT"
@ -356,7 +373,7 @@ class ModuleImplResultsAPC(ModuleImplResults):
# et dans dans evals_poids_etuds
# (rappel: la comparaison est toujours false face à un NaN)
# shape: (nb_etuds, nb_evals, nb_ues)
poids_stacked = np.stack([evals_poids] * nb_etuds)
poids_stacked = np.stack([evals_poids] * nb_etuds) # nb_etuds, nb_evals, nb_ues
evals_poids_etuds = np.where(
np.stack([self.evals_notes.values] * nb_ues, axis=2) > scu.NOTES_NEUTRALISE,
poids_stacked,
@ -364,10 +381,20 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
# Calcule la moyenne pondérée sur les notes disponibles:
evals_notes_stacked = np.stack([evals_notes_20] * nb_ues, axis=2)
# evals_notes_stacked shape: nb_etuds, nb_evals, nb_ues
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etuds_moy_module = np.sum(
evals_poids_etuds * evals_notes_stacked, axis=1
) / np.sum(evals_poids_etuds, axis=1)
# etuds_moy_module shape: nb_etuds x nb_ues
# Application des évaluations bonus:
etuds_moy_module = self.apply_bonus(
etuds_moy_module,
modimpl,
evals_poids_df,
evals_notes_stacked,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
@ -416,6 +443,30 @@ class ModuleImplResultsAPC(ModuleImplResults):
)
return self.etuds_moy_module
def apply_bonus(
self,
etuds_moy_module: pd.DataFrame,
modimpl: ModuleImpl,
evals_poids_df: pd.DataFrame,
evals_notes_stacked: np.ndarray,
):
"""Ajoute les points des évaluations bonus.
Il peut y avoir un nb quelconque d'évaluations bonus.
Les points sont directement ajoutés (ils peuvent être négatifs).
"""
evals_bonus = self.get_evaluations_bonus(modimpl)
if not evals_bonus:
return etuds_moy_module
poids_stacked = np.stack([evals_poids_df.values] * len(etuds_moy_module))
for evaluation in evals_bonus:
eval_idx = evals_poids_df.index.get_loc(evaluation.id)
etuds_moy_module += (
evals_notes_stacked[:, eval_idx, :] * poids_stacked[:, eval_idx, :]
)
# Clip dans [0,20]
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
return etuds_moy_module
def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]:
"""Charge poids des évaluations d'un module et retourne un dataframe
@ -532,6 +583,13 @@ 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,
)
# Session2 : quand elle existe, remplace la note de module
eval_session2 = self.get_evaluation_session2(modimpl)
if eval_session2:
@ -571,3 +629,22 @@ class ModuleImplResultsClassic(ModuleImplResults):
)
return self.etuds_moy_module
def apply_bonus(
self,
etuds_moy_module: np.ndarray,
modimpl: ModuleImpl,
evals_notes_20: np.ndarray,
):
"""Ajoute les points des évaluations bonus.
Il peut y avoir un nb quelconque d'évaluations bonus.
Les points sont directement ajoutés (ils peuvent être négatifs).
"""
evals_bonus_idx = self.get_evaluations_bonus_idx(modimpl)
if not evals_bonus_idx:
return etuds_moy_module
for eval_idx in evals_bonus_idx:
etuds_moy_module += evals_notes_20[:, eval_idx]
# Clip dans [0,20]
etuds_moy_module.clip(0, 20, out=etuds_moy_module)
return etuds_moy_module

View File

@ -205,11 +205,12 @@ class ResultatsSemestre(ResultatsCache):
"coefficient" : float, # 0 si None
"description" : str, # de l'évaluation, "" si None
"etat" {
"blocked" : bool, # vrai si prise en compte bloquée
"evalcomplete" : bool,
"last_modif" : datetime.datetime | None, # saisie de note la plus récente
"nb_notes" : int, # nb notes d'étudiants inscrits
},
"evaluatiuon_id" : int,
"evaluation_id" : int,
"jour" : datetime.datetime, # e.date_debut or datetime.datetime(1900, 1, 1)
"publish_incomplete" : bool,
}
@ -230,15 +231,16 @@ class ResultatsSemestre(ResultatsCache):
date_modif = cursor.one_or_none()
last_modif = date_modif[0] if date_modif else None
return {
"coefficient": evaluation.coefficient or 0.0,
"description": evaluation.description or "",
"evaluation_id": evaluation.id,
"jour": evaluation.date_debut or datetime.datetime(1900, 1, 1),
"coefficient": evaluation.coefficient,
"description": evaluation.description,
"etat": {
"blocked": evaluation.is_blocked(),
"evalcomplete": etat.is_complete,
"nb_notes": etat.nb_notes,
"last_modif": last_modif,
},
"evaluation_id": evaluation.id,
"jour": evaluation.date_debut or datetime.datetime(1900, 1, 1),
"publish_incomplete": evaluation.publish_incomplete,
}
@ -432,9 +434,24 @@ class ResultatsSemestre(ResultatsCache):
ue_cap_dict["compense_formsemestre_id"] = None
return ue_cap_dict
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict:
def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict | None:
"""L'état de l'UE pour cet étudiant.
Result: dict, ou None si l'UE n'est pas dans ce semestre.
{
"is_capitalized": # vrai si la version capitalisée est celle prise en compte (meilleure)
"was_capitalized":# si elle a été capitalisée (meilleure ou pas)
"is_external": # si UE externe
"coef_ue": 0.0,
"cur_moy_ue": 0.0, # moyenne de l'UE courante
"moy": 0.0, # moyenne prise en compte
"event_date": # date de la capiltalisation éventuelle (ou None)
"ue": ue_dict, # l'UE, comme un dict
"formsemestre_id": None,
"capitalized_ue_id": None, # l'id de l'UE capitalisée, ou None
"ects_pot": 0.0, # deprecated (les ECTS liés à cette UE)
"ects": 0.0, # les ECTS acquis grace à cette UE
"ects_ue": # les ECTS liés à cette UE
}
"""
ue: UniteEns = db.session.get(UniteEns, ue_id)
ue_dict = ue.to_dict()
@ -455,7 +472,7 @@ class ResultatsSemestre(ResultatsCache):
"ects": 0.0,
"ects_ue": ue.ects,
}
if not ue_id in self.etud_moy_ue:
if not ue_id in self.etud_moy_ue or not etudid in self.etud_moy_ue[ue_id]:
return None
if not self.validations:
self.validations = res_sem.load_formsemestre_validations(self.formsemestre)
@ -512,11 +529,13 @@ class ResultatsSemestre(ResultatsCache):
"is_external": ue_cap["is_external"] if is_capitalized else ue.is_external,
"coef_ue": coef_ue,
"ects_pot": ue.ects or 0.0,
"ects": self.validations.decisions_jury_ues.get(etudid, {})
.get(ue.id, {})
.get("ects", 0.0)
if self.validations.decisions_jury_ues
else 0.0,
"ects": (
self.validations.decisions_jury_ues.get(etudid, {})
.get(ue.id, {})
.get("ects", 0.0)
if self.validations.decisions_jury_ues
else 0.0
),
"ects_ue": ue.ects,
"cur_moy_ue": cur_moy_ue,
"moy": moy_ue,

View File

@ -58,7 +58,6 @@ class NotesTableCompat(ResultatsSemestre):
self.moy_moy = "NA"
self.moy_gen_rangs_by_group = {} # { group_id : (Series, Series) }
self.ue_rangs_by_group = {} # { ue_id : {group_id : (Series, Series)}}
self.expr_diagnostics = ""
self.parcours = self.formsemestre.formation.get_cursus()
self._modimpls_dict_by_ue = {} # local cache
@ -217,9 +216,9 @@ class NotesTableCompat(ResultatsSemestre):
# Rangs / UEs:
for ue in ues:
group_moys_ue = self.etud_moy_ue[ue.id][group_members]
self.ue_rangs_by_group.setdefault(ue.id, {})[
group.id
] = moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
self.ue_rangs_by_group.setdefault(ue.id, {})[group.id] = (
moy_sem.comp_ranks_series(group_moys_ue * mask_inscr)
)
def get_etud_rang(self, etudid: int) -> str:
"""Le rang (classement) de l'étudiant dans le semestre.

View File

@ -126,6 +126,7 @@ class AjoutAssiOrJustForm(FlaskForm):
class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'une assiduité pour un étudiant"
description = TextAreaField(
"Description",
render_kw={
@ -152,6 +153,7 @@ class AjoutAssiduiteEtudForm(AjoutAssiOrJustForm):
class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
"Formulaire de saisie d'un justificatif pour un étudiant"
raison = TextAreaField(
"Raison",
render_kw={
@ -176,6 +178,12 @@ class AjoutJustificatifEtudForm(AjoutAssiOrJustForm):
class ChoixDateForm(FlaskForm):
"""
Formulaire de choix de date
(utilisé par la page de choix de date
si la date courante n'est pas dans le semestre)
"""
def __init__(self, *args, **kwargs):
"Init form, adding a filed for our error messages"
super().__init__(*args, **kwargs)

View File

@ -0,0 +1,70 @@
##############################################################################
#
# 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 options génération table poursuite études (PE)
"""
from flask_wtf import FlaskForm
from wtforms import BooleanField, HiddenField, SubmitField
class ParametrageClasseurPE(FlaskForm):
"Formulaire paramétrage génération classeur PE"
# cohorte_restreinte = BooleanField(
# "Restreindre aux étudiants inscrits dans le semestre (sans interclassement de promotion) (à venir)"
# )
moyennes_tags = BooleanField(
"Générer les moyennes sur les tags de modules personnalisés (cf. programme de formation)",
default=True,
render_kw={"checked": ""},
)
moyennes_ue_res_sae = BooleanField(
"Générer les moyennes des ressources et des SAEs",
default=True,
render_kw={"checked": ""},
)
moyennes_ues_rcues = BooleanField(
"Générer les moyennes par RCUEs (compétences) et leurs synthèses HTML étudiant par étudiant",
default=True,
render_kw={"checked": ""},
)
min_max_moy = BooleanField("Afficher les colonnes min/max/moy")
# synthese_individuelle_etud = BooleanField(
# "Générer (suppose les RCUES)"
# )
publipostage = BooleanField(
"Nomme les moyennes pour publipostage",
# default=False,
# render_kw={"checked": ""},
)
classeurs_detailles = BooleanField(
"Générer des classeurs intermédiaires (pour debuggage)", default=False
)
submit = SubmitField("Générer les classeurs poursuites d'études")
cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})

View File

@ -5,7 +5,6 @@ from datetime import datetime
from flask_login import current_user
from flask_sqlalchemy.query import Query
from sqlalchemy.exc import DataError
from app import db, log, g, set_sco_dept
from app.models import (
@ -89,6 +88,8 @@ class Assiduite(ScoDocModel):
lazy="select",
)
# Argument "restrict" obligatoire car on override la fonction "to_dict" de ScoDocModel
# pylint: disable-next=unused-argument
def to_dict(self, format_api=True, restrict: bool | None = None) -> dict:
"""Retourne la représentation json de l'assiduité
restrict n'est pas utilisé ici.
@ -307,6 +308,9 @@ class Assiduite(ScoDocModel):
def supprime(self):
"Supprime l'assiduité. Log et commit."
# Obligatoire car import circulaire sinon
# pylint: disable-next=import-outside-toplevel
from app.scodoc import sco_assiduites as scass
if g.scodoc_dept is None and self.etudiant.dept_id is not None:
@ -356,7 +360,7 @@ class Assiduite(ScoDocModel):
date: str = self.entry_date.strftime("%d/%m/%Y à %H:%M")
utilisateur: str = ""
if self.user != None:
if self.user is not None:
self.user: User
utilisateur = f"par {self.user.get_prenomnom()}"
@ -515,6 +519,8 @@ class Justificatif(ScoDocModel):
def create_justificatif(
cls,
etudiant: Identite,
# On a besoin des arguments mais on utilise "locals" pour les récupérer
# pylint: disable=unused-argument
date_debut: datetime,
date_fin: datetime,
etat: EtatJustificatif,
@ -538,8 +544,10 @@ class Justificatif(ScoDocModel):
def supprime(self):
"Supprime le justificatif. Log et commit."
# Obligatoire car import circulaire sinon
# pylint: disable-next=import-outside-toplevel
from app.scodoc import sco_assiduites as scass
from app.scodoc.sco_archives_justificatifs import JustificatifArchiver
# Récupération de l'archive du justificatif
archive_name: str = self.fichier
@ -566,11 +574,7 @@ class Justificatif(ScoDocModel):
db.session.delete(self)
db.session.commit()
# On actualise les assiduités justifiées de l'étudiant concerné
compute_assiduites_justified(
self.etudid,
Justificatif.query.filter_by(etudid=self.etudid).all(),
True,
)
self.dejustifier_assiduites()
def get_fichiers(self) -> tuple[list[str], int]:
"""Renvoie la liste des noms de fichiers justicatifs
@ -592,6 +596,64 @@ class Justificatif(ScoDocModel):
accessible_filenames.append(filename[0])
return accessible_filenames, len(filenames)
def justifier_assiduites(
self,
) -> list[int]:
"""Justifie les assiduités sur la période de validité du justificatif"""
log(f"justifier_assiduites: {self}")
assiduites_justifiees: list[int] = []
if self.etat != EtatJustificatif.VALIDE:
return []
# On récupère les assiduités de l'étudiant sur la période donnée
assiduites: Query = self.etudiant.assiduites.filter(
Assiduite.date_debut >= self.date_debut,
Assiduite.date_fin <= self.date_fin,
Assiduite.etat != EtatAssiduite.PRESENT,
)
# Pour chaque assiduité, on la justifie
for assi in assiduites:
assi.est_just = True
assiduites_justifiees.append(assi.assiduite_id)
db.session.add(assi)
db.session.commit()
return assiduites_justifiees
def dejustifier_assiduites(self) -> list[int]:
"""
Déjustifie les assiduités sur la période du justificatif
"""
assiduites_dejustifiees: list[int] = []
# On récupère les assiduités de l'étudiant sur la période donnée
assiduites: Query = self.etudiant.assiduites.filter(
Assiduite.date_debut >= self.date_debut,
Assiduite.date_fin <= self.date_fin,
Assiduite.etat != EtatAssiduite.PRESENT,
)
assi: Assiduite
for assi in assiduites:
# On récupère les justificatifs qui justifient l'assiduité `assi`
assi_justifs: list[int] = get_justifs_from_date(
self.etudiant.etudid,
assi.date_debut,
assi.date_fin,
long=False,
valid=True,
)
# Si il n'y a pas d'autre justificatif valide, on déjustifie l'assiduité
if len(assi_justifs) == 0 or (
len(assi_justifs) == 1 and assi_justifs[0] == self.justif_id
):
assi.est_just = False
assiduites_dejustifiees.append(assi.assiduite_id)
db.session.add(assi)
db.session.commit()
return assiduites_dejustifiees
def is_period_conflicting(
date_debut: datetime,
@ -615,72 +677,6 @@ def is_period_conflicting(
return count > 0
def compute_assiduites_justified(
etudid: int, justificatifs: list[Justificatif] = None, reset: bool = False
) -> list[int]:
"""
Args:
etudid (int): l'identifiant de l'étudiant
justificatifs (list[Justificatif]): La liste des justificatifs qui seront utilisés
reset (bool, optional): remet à false les assiduites non justifiés. Defaults to False.
Returns:
list[int]: la liste des assiduités qui ont été justifiées.
"""
# TODO à optimiser (car très long avec 40000 assiduités)
# On devrait :
# - récupérer uniquement les assiduités qui sont sur la période des justificatifs donnés
# - Pour chaque assiduité trouvée, il faut récupérer les justificatifs qui la justifie
# - Si au moins un justificatif valide couvre la période de l'assiduité alors on la justifie
# Si on ne donne pas de justificatifs on prendra par défaut tous les justificatifs de l'étudiant
if justificatifs is None:
justificatifs: list[Justificatif] = Justificatif.query.filter_by(
etudid=etudid
).all()
# On ne prend que les justificatifs valides
justificatifs = [j for j in justificatifs if j.etat == EtatJustificatif.VALIDE]
# On récupère les assiduités de l'étudiant
assiduites: Assiduite = Assiduite.query.filter_by(etudid=etudid)
assiduites_justifiees: list[int] = []
for assi in assiduites:
# On ne justifie pas les Présences
if assi.etat == EtatAssiduite.PRESENT:
continue
# On récupère les justificatifs qui justifient l'assiduité `assi`
assi_justificatifs = Justificatif.query.filter(
Justificatif.etudid == assi.etudid,
Justificatif.date_debut <= assi.date_debut,
Justificatif.date_fin >= assi.date_fin,
Justificatif.etat == EtatJustificatif.VALIDE,
).all()
# Si au moins un justificatif possède une période qui couvre l'assiduité
if any(
assi.date_debut >= j.date_debut and assi.date_fin <= j.date_fin
for j in justificatifs + assi_justificatifs
):
# On justifie l'assiduité
# On ajoute l'id de l'assiduité à la liste des assiduités justifiées
assi.est_just = True
assiduites_justifiees.append(assi.assiduite_id)
db.session.add(assi)
elif reset:
# Si le paramètre reset est Vrai alors les assiduités non justifiées
# sont remise en "non justifiée"
assi.est_just = False
db.session.add(assi)
# On valide la session
db.session.commit()
# On renvoie la liste des assiduite_id des assiduités justifiées
return assiduites_justifiees
def get_assiduites_justif(assiduite_id: int, long: bool) -> list[int | dict]:
"""
get_assiduites_justif Récupération des justificatifs d'une assiduité

View File

@ -125,7 +125,7 @@ class Identite(models.ScoDocModel):
)
# Champs "protégés" par ViewEtudData (RGPD)
protected_attrs = {"boursier"}
protected_attrs = {"boursier", "nationalite"}
def __repr__(self):
return (

View File

@ -10,6 +10,7 @@ from flask_login import current_user
import sqlalchemy as sa
from app import db, log
from app import models
from app.models.etudiants import Identite
from app.models.events import ScolarNews
from app.models.notes import NotesNotes
@ -23,10 +24,8 @@ MAX_EVALUATION_DURATION = datetime.timedelta(days=365)
NOON = datetime.time(12, 00)
DEFAULT_EVALUATION_TIME = datetime.time(8, 0)
VALID_EVALUATION_TYPES = {0, 1, 2}
class Evaluation(db.Model):
class Evaluation(models.ScoDocModel):
"""Evaluation (contrôle, examen, ...)"""
__tablename__ = "notes_evaluation"
@ -38,9 +37,9 @@ class Evaluation(db.Model):
)
date_debut = db.Column(db.DateTime(timezone=True), nullable=True)
date_fin = db.Column(db.DateTime(timezone=True), nullable=True)
description = db.Column(db.Text)
note_max = db.Column(db.Float)
coefficient = db.Column(db.Float)
description = db.Column(db.Text, nullable=False)
note_max = db.Column(db.Float, nullable=False)
coefficient = db.Column(db.Float, nullable=False)
visibulletin = db.Column(
db.Boolean, nullable=False, default=True, server_default="true"
)
@ -48,15 +47,30 @@ class Evaluation(db.Model):
publish_incomplete = db.Column(
db.Boolean, nullable=False, default=False, server_default="false"
)
# type d'evaluation: 0 normale, 1 rattrapage, 2 "2eme session"
"prise en compte immédiate"
evaluation_type = db.Column(
db.Integer, nullable=False, default=0, server_default="0"
)
"type d'evaluation: 0 normale, 1 rattrapage, 2 2eme session, 3 bonus"
blocked_until = db.Column(db.DateTime(timezone=True), nullable=True)
"date de prise en compte"
BLOCKED_FOREVER = datetime.datetime(2666, 12, 31, tzinfo=scu.TIME_ZONE)
# ordre de presentation (par défaut, le plus petit numero
# est la plus ancienne eval):
numero = db.Column(db.Integer, nullable=False, default=0)
ues = db.relationship("UniteEns", secondary="evaluation_ue_poids", viewonly=True)
EVALUATION_NORMALE = 0 # valeurs stockées en base, ne pas changer !
EVALUATION_RATTRAPAGE = 1
EVALUATION_SESSION2 = 2
EVALUATION_BONUS = 3
VALID_EVALUATION_TYPES = {
EVALUATION_NORMALE,
EVALUATION_RATTRAPAGE,
EVALUATION_SESSION2,
EVALUATION_BONUS,
}
def __repr__(self):
return f"""<Evaluation {self.id} {
self.date_debut.isoformat() if self.date_debut else ''} "{
@ -70,15 +84,17 @@ class Evaluation(db.Model):
date_fin: datetime.datetime = None,
description=None,
note_max=None,
blocked_until=None,
coefficient=None,
visibulletin=None,
publish_incomplete=None,
evaluation_type=None,
numero=None,
**kw, # ceci pour absorber les éventuel arguments excedentaires
):
) -> "Evaluation":
"""Create an evaluation. Check permission and all arguments.
Ne crée pas les poids vers les UEs.
Add to session, do not commit.
"""
if not moduleimpl.can_edit_evaluation(current_user):
raise AccessDenied(
@ -87,13 +103,15 @@ class Evaluation(db.Model):
args = locals()
del args["cls"]
del args["kw"]
check_convert_evaluation_args(moduleimpl, args)
check_and_convert_evaluation_args(args, moduleimpl)
# Check numeros
Evaluation.moduleimpl_evaluation_renumber(moduleimpl, only_if_unumbered=True)
if not "numero" in args or args["numero"] is None:
args["numero"] = cls.get_new_numero(moduleimpl, args["date_debut"])
#
evaluation = Evaluation(**args)
db.session.add(evaluation)
db.session.flush()
sco_cache.invalidate_formsemestre(formsemestre_id=moduleimpl.formsemestre_id)
url = url_for(
"notes.moduleimpl_status",
@ -196,6 +214,10 @@ class Evaluation(db.Model):
def to_dict_api(self) -> dict:
"Représentation dict pour API JSON"
return {
"blocked": self.is_blocked(),
"blocked_until": (
self.blocked_until.isoformat() if self.blocked_until else ""
),
"coefficient": self.coefficient,
"date_debut": self.date_debut.isoformat() if self.date_debut else "",
"date_fin": self.date_fin.isoformat() if self.date_fin else "",
@ -210,9 +232,9 @@ class Evaluation(db.Model):
"visibulletin": self.visibulletin,
# Deprecated (supprimer avant #sco9.7)
"date": self.date_debut.date().isoformat() if self.date_debut else "",
"heure_debut": self.date_debut.time().isoformat()
if self.date_debut
else "",
"heure_debut": (
self.date_debut.time().isoformat() if self.date_debut else ""
),
"heure_fin": self.date_fin.time().isoformat() if self.date_fin else "",
}
@ -232,15 +254,6 @@ class Evaluation(db.Model):
return e_dict
def from_dict(self, data):
"""Set evaluation attributes from given dict values."""
check_convert_evaluation_args(self.moduleimpl, data)
if data.get("numero") is None:
data["numero"] = Evaluation.get_max_numero(self.moduleimpl.id) + 1
for k in self.__dict__:
if k != "_sa_instance_state" and k != "id" and k in data:
setattr(self, k, data[k])
@classmethod
def get_evaluation(
cls, evaluation_id: int | str, dept_id: int = None
@ -358,19 +371,6 @@ class Evaluation(db.Model):
Chaine vide si non renseignée."""
return self.date_fin.time().isoformat("minutes") if self.date_fin else ""
def clone(self, not_copying=()):
"""Clone, not copying the given attrs
Attention: la copie n'a pas d'id avant le prochain commit
"""
d = dict(self.__dict__)
d.pop("id") # get rid of id
d.pop("_sa_instance_state") # get rid of SQLAlchemy special attr
for k in not_copying:
d.pop(k)
copy = self.__class__(**d)
db.session.add(copy)
return copy
def is_matin(self) -> bool:
"Evaluation commençant le matin (faux si pas de date)"
if not self.date_debut:
@ -383,6 +383,14 @@ class Evaluation(db.Model):
return False
return self.date_debut.time() >= NOON
def is_blocked(self, now=None) -> bool:
"True si prise en compte bloquée"
if self.blocked_until is None:
return False
if now is None:
now = datetime.datetime.now(scu.TIME_ZONE)
return self.blocked_until > now
def set_default_poids(self) -> bool:
"""Initialize les poids vers les UE à leurs valeurs par défaut
C'est à dire à 1 si le coef. module/UE est non nul, 0 sinon.
@ -471,6 +479,29 @@ class Evaluation(db.Model):
"""
return NotesNotes.query.filter_by(etudid=etud.id, evaluation_id=self.id).first()
@classmethod
def get_evaluations_blocked_for_etud(
cls, formsemestre, etud: Identite
) -> list["Evaluation"]:
"""Liste des évaluations de ce semestre avec note pour cet étudiant et date blocage
et date blocage < FOREVER.
Si non vide, une note apparaitra dans le futur pour cet étudiant: il faut
donc interdire la saisie du jury.
"""
now = datetime.datetime.now(scu.TIME_ZONE)
return (
Evaluation.query.filter(
Evaluation.blocked_until != None, # pylint: disable=C0121
Evaluation.blocked_until >= now,
)
.join(ModuleImpl)
.filter_by(formsemestre_id=formsemestre.id)
.join(ModuleImplInscription)
.filter_by(etudid=etud.id)
.join(NotesNotes)
.all()
)
class EvaluationUEPoids(db.Model):
"""Poids des évaluations (BUT)
@ -528,7 +559,7 @@ def evaluation_enrich_dict(e: Evaluation, e_dict: dict):
return e_dict
def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
def check_and_convert_evaluation_args(data: dict, moduleimpl: "ModuleImpl"):
"""Check coefficient, dates and duration, raises exception if invalid.
Convert date and time strings to date and time objects.
@ -543,7 +574,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
# --- evaluation_type
try:
data["evaluation_type"] = int(data.get("evaluation_type", 0) or 0)
if not data["evaluation_type"] in VALID_EVALUATION_TYPES:
if not data["evaluation_type"] in Evaluation.VALID_EVALUATION_TYPES:
raise ScoValueError("invalid evaluation_type value")
except ValueError as exc:
raise ScoValueError("invalid evaluation_type value") from exc
@ -568,7 +599,7 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
if coef < 0:
raise ScoValueError("invalid coefficient value (must be positive or null)")
data["coefficient"] = coef
# --- date de l'évaluation
# --- date de l'évaluation dans le semestre ?
formsemestre = moduleimpl.formsemestre
date_debut = data.get("date_debut", None)
if date_debut:
@ -609,6 +640,8 @@ def check_convert_evaluation_args(moduleimpl: "ModuleImpl", data: dict):
"Heures de l'évaluation incohérentes !",
dest_url="javascript:history.back();",
)
if "blocked_until" in data:
data["blocked_until"] = data["blocked_until"] or None
def heure_to_time(heure: str) -> datetime.time:
@ -638,3 +671,6 @@ def _moduleimpl_evaluation_insert_before(
db.session.add(e)
db.session.commit()
return n
from app.models.moduleimpls import ModuleImpl, ModuleImplInscription

View File

@ -93,6 +93,10 @@ class FormSemestre(db.Model):
db.Boolean(), nullable=False, default=False, server_default="false"
)
"Si vrai, la moyenne générale indicative BUT n'est pas calculée"
mode_calcul_moyennes = db.Column(
db.Integer, nullable=False, default=0, server_default="0"
)
"pour usage futur"
gestion_semestrielle = db.Column(
db.Boolean(), nullable=False, default=False, server_default="false"
)
@ -871,7 +875,7 @@ class FormSemestre(db.Model):
def get_abs_count(self, etudid):
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs, nb abs justifiées)
tuple (nb abs non just, nb abs justifiées, nb abs total)
Utilise un cache.
"""
from app.scodoc import sco_assiduites

View File

@ -1,17 +1,24 @@
"""ScoDoc 9 models : Modules
"""
from flask import current_app, g
from app import db
from app import models
from app.models import APO_CODE_STR_LEN
from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules
from app.models.but_refcomp import (
ApcParcours,
ApcReferentielCompetences,
app_critiques_modules,
parcours_modules,
)
from app.scodoc import sco_utils as scu
from app.scodoc.codes_cursus import UE_SPORT
from app.scodoc.sco_exceptions import ScoValueError
from app.scodoc.sco_utils import ModuleType
class Module(db.Model):
class Module(models.ScoDocModel):
"""Module"""
__tablename__ = "notes_modules"
@ -76,6 +83,55 @@ class Module(db.Model):
return f"""<Module{ModuleType(self.module_type or ModuleType.STANDARD).name
} id={self.id} code={self.code!r} semestre_id={self.semestre_id}>"""
@classmethod
def convert_dict_fields(cls, args: dict) -> dict:
"""Convert fields in the given dict. No other side effect.
returns: dict to store in model's db.
"""
# s'assure que ects etc est non ''
fs_empty_stored_as_nulls = {
"coefficient",
"ects",
"heures_cours",
"heures_td",
"heures_tp",
}
args_dict = {}
for key, value in args.items():
if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
if key in fs_empty_stored_as_nulls and value == "":
value = None
args_dict[key] = value
return args_dict
@classmethod
def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
"""Returns a copy of dict with only the keys belonging to the Model and not in excluded.
Add 'id' to excluded."""
# on ne peut pas affecter directement parcours
return super().filter_model_attributes(data, (excluded or set()) | {"parcours"})
@classmethod
def create_from_dict(cls, data: dict) -> "Module":
"""Create from given dict, add parcours"""
mod = super().create_from_dict(data)
for p in data.get("parcours", []) or []:
if isinstance(p, ApcParcours):
parcour: ApcParcours = p
else:
pid = int(p)
query = ApcParcours.query.filter_by(id=pid)
if g.scodoc_dept:
query = query.join(ApcReferentielCompetences).filter_by(
dept_id=g.scodoc_dept_id
)
parcour: ApcParcours = query.first()
if parcour is None:
raise ScoValueError("Parcours invalide")
mod.parcours.append(parcour)
return mod
def clone(self):
"""Create a new copy of this module."""
mod = Module(

View File

@ -126,7 +126,7 @@ class ScolarFormSemestreValidation(db.Model):
def ects(self) -> float:
"Les ECTS acquis par cette validation. (0 si ce n'est pas une validation d'UE)"
return (
self.ue.ects
self.ue.ects or 0.0
if (self.ue is not None) and (self.code in CODES_UE_VALIDES)
else 0.0
)

0
app/pe/moys/__init__.py Normal file
View File

View File

@ -0,0 +1,343 @@
##############################################################################
#
# 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Thu Sep 8 09:36:33 2016
@author: barasc
"""
import pandas as pd
import numpy as np
from app.models import Identite
from app.pe import pe_affichage
from app.pe.moys import pe_tabletags, pe_moy, pe_moytag, pe_sxtag
from app.pe.rcss import pe_rcs
import app.pe.pe_comp as pe_comp
from app.scodoc.sco_utils import ModuleType
class InterClassTag(pe_tabletags.TableTag):
"""
Interclasse l'ensemble des étudiants diplômés à une année
donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S'), qu'il soit
de type SemX ou RCSemX,
en reportant les moyennes obtenues sur à la version tagguée
du RCS (de type SxTag ou RCSTag).
Sont ensuite calculés les classements (uniquement)
sur les étudiants diplômes.
Args:
nom_rcs: Le nom de l'aggrégat
type_interclassement: Le type d'interclassement (par UE ou par compétences)
etudiants_diplomes: L'identité des étudiants diplômés
rcss: Un dictionnaire {(nom_rcs, fid_final): RCS} donnant soit
les SemX soit les RCSemX recencés par le jury PE
rcstag: Un dictionnaire {(nom_rcs, fid_final): RCSTag} donnant
soit les SxTag (associés aux SemX)
soit les RCSTags (associés au RCSemX) calculés par le jury PE
suivis: Un dictionnaire associé à chaque étudiant son rcss
(de la forme ``{etudid: {nom_rcs: RCS_suivi}}``)
"""
def __init__(
self,
nom_rcs: str,
type_interclassement: str,
etudiants_diplomes: dict[int, Identite],
rcss: dict[(str, int) : pe_rcs.RCS],
rcstags: dict[(str, int) : pe_tabletags.TableTag],
suivis: dict[int:dict],
):
pe_tabletags.TableTag.__init__(self)
self.nom_rcs: str = nom_rcs
"""Le nom du RCS interclassé"""
# Le type d'interclassement
self.type = type_interclassement
pe_affichage.pe_print(
f"*** Interclassement par 🗂️{type_interclassement} pour le RCS ⏯️{nom_rcs}"
)
# Les informations sur les étudiants diplômés
self.etuds: list[Identite] = list(etudiants_diplomes.values())
"""Identités des étudiants diplômés"""
self.add_etuds(self.etuds)
self.diplomes_ids = set(etudiants_diplomes.keys())
"""Etudids des étudiants diplômés"""
# Les RCS de l'aggrégat (SemX ou RCSemX)
self.rcss: dict[(str, int), pe_rcs.RCS] = {}
"""Ensemble des SemX ou des RCSemX associés à l'aggrégat"""
for (nom, fid), rcs in rcss.items():
if nom == nom_rcs:
self.rcss[(nom, fid)] = rcss
# Les données tagguées
self.rcstags: dict[(str, int), pe_tabletags.TableTag] = {}
"""Ensemble des SxTag ou des RCSTags associés à l'aggrégat"""
for rcs_id in self.rcss:
self.rcstags[rcs_id] = rcstags[rcs_id]
# Les RCS (SemX ou RCSemX) suivis par les étudiants du jury,
# en ne gardant que ceux associés aux diplomés
self.suivis: dict[int, pe_rcs.RCS] = {}
"""Association entre chaque étudiant et le SxTag ou RCSTag à prendre
pour l'aggrégat"""
for etudid in self.diplomes_ids:
self.suivis[etudid] = suivis[etudid][nom_rcs]
# Les données sur les tags
self.tags_sorted = self._do_taglist()
"""Liste des tags (triés par ordre alphabétique)"""
aff = pe_affichage.repr_tags(self.tags_sorted)
pe_affichage.pe_print(f"--> Tags : {aff}")
# Les données sur les UEs (si SxTag) ou compétences (si RCSTag)
self.champs_sorted = self._do_ues_ou_competences_list()
"""Les champs (UEs ou compétences) de l'interclassement"""
if self.type == pe_moytag.CODE_MOY_UE:
pe_affichage.pe_print(
f"--> UEs : {pe_affichage.aff_UEs(self.champs_sorted)}"
)
else:
pe_affichage.pe_print(
f"--> Compétences : {pe_affichage.aff_competences(self.champs_sorted)}"
)
# Etudids triés
self.etudids_sorted = sorted(list(self.diplomes_ids))
self.nom = self.get_repr()
"""Représentation textuelle de l'interclassement"""
# Synthétise les moyennes/classements par tag
self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {}
for tag in self.tags_sorted:
# Les moyennes tous modules confondus
notes_gen = self.compute_notes_matrice(tag)
# Les coefficients de la moyenne générale
coeffs = self.compute_coeffs_matrice(tag)
aff = pe_affichage.repr_profil_coeffs(coeffs, with_index=True)
pe_affichage.pe_print(f"--> Moyenne 👜{tag} avec coeffs: {aff} ")
infos = {"aggregat": self.nom_rcs, "cohorte": pe_moytag.CHAMP_PROMO}
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
tag,
self.type,
notes_gen,
coeffs, # limite les moyennes aux étudiants de la promo
infos,
)
def get_repr(self) -> str:
"""Une représentation textuelle"""
return f"{self.nom_rcs} par {self.type}"
def _do_taglist(self):
"""Synthétise les tags à partir des TableTags (SXTag ou RCSTag)
Returns:
Une liste de tags triés par ordre alphabétique
"""
tags = []
for rcstag in self.rcstags.values():
tags.extend(rcstag.tags_sorted)
return sorted(set(tags))
def compute_notes_matrice(self, tag) -> pd.DataFrame:
"""Construit la matrice de notes (etudids x champs) en
reportant les moyennes obtenues par les étudiants
aux semestres de l'aggrégat pour le tag visé.
Les champs peuvent être des acronymes d'UEs ou des compétences.
Args:
tag: Le tag visé
Return:
Le dataFrame (etudids x champs)
reportant les moyennes des étudiants aux champs
"""
# etudids_sorted: Les etudids des étudiants (diplômés) triés
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
# Partant d'un dataframe vierge
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
for rcstag in self.rcstags.values():
# Charge les moyennes au tag d'un RCStag
if tag in rcstag.moyennes_tags:
moytag = rcstag.moyennes_tags[tag]
notes = moytag.matrice_notes # dataframe etudids x ues
# Etudiants/Champs communs entre le RCSTag et les données interclassées
(
etudids_communs,
champs_communs,
) = pe_comp.find_index_and_columns_communs(df, notes)
# Injecte les notes par tag
df.loc[etudids_communs, champs_communs] = notes.loc[
etudids_communs, champs_communs
]
return df
def compute_coeffs_matrice(self, tag) -> pd.DataFrame:
"""Idem que compute_notes_matrices mais pour les coeffs
Args:
tag: Le tag visé
Return:
Le dataFrame (etudids x champs)
reportant les moyennes des étudiants aux champs
"""
# etudids_sorted: Les etudids des étudiants (diplômés) triés
# champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
# Partant d'un dataframe vierge
df = pd.DataFrame(np.nan, index=self.etudids_sorted, columns=self.champs_sorted)
for rcstag in self.rcstags.values():
if tag in rcstag.moyennes_tags:
# Charge les coeffs au tag d'un RCStag
coeffs: pd.DataFrame = rcstag.moyennes_tags[tag].matrice_coeffs
# Etudiants/Champs communs entre le RCSTag et les données interclassées
(
etudids_communs,
champs_communs,
) = pe_comp.find_index_and_columns_communs(df, coeffs)
# Injecte les coeffs par tag
df.loc[etudids_communs, champs_communs] = coeffs.loc[
etudids_communs, champs_communs
]
return df
def _do_ues_ou_competences_list(self) -> list[str]:
"""Synthétise les champs (UEs ou compétences) sur lesquels
sont calculés les moyennes.
Returns:
Un dictionnaire {'acronyme_ue' : 'compétences'}
"""
dict_champs = []
for rcstag in self.rcstags.values():
if isinstance(rcstag, pe_sxtag.SxTag):
champs = rcstag.acronymes_sorted
else: # pe_rcstag.RCSTag
champs = rcstag.competences_sorted
dict_champs.extend(champs)
return sorted(set(dict_champs))
def has_tags(self):
"""Indique si l'interclassement a des tags (cas d'un
interclassement sur un S5 qui n'a pas eu lieu)
"""
return len(self.tags_sorted) > 0
def _un_rcstag_significatif(self, rcsstags: dict[(str, int):pe_tabletags]):
"""Renvoie un rcstag significatif (ayant des tags et des notes aux tags)
parmi le dictionnaire de rcsstags"""
for rcstag_id, rcstag in rcsstags.items():
moystags: pe_moytag.MoyennesTag = rcstag.moyennes_tags
for tag, moystag in moystags.items():
tags_tries = moystag.get_all_significant_tags()
if tags_tries:
return moystag
return None
def compute_df_synthese_moyennes_tag(
self, tag, aggregat=None, type_colonnes=False, options={"min_max_moy": True}
) -> pd.DataFrame:
"""Construit le dataframe retraçant pour les données des moyennes
pour affichage dans la synthèse du jury PE. (cf. to_df())
Args:
etudids_sorted: Les etudids des étudiants (diplômés) triés
champs_sorted: Les champs (UE ou compétences) à faire apparaitre dans la matrice
Return:
Le dataFrame (etudids x champs)
reportant les moyennes des étudiants aux champs
"""
if aggregat:
assert (
aggregat == self.nom_rcs
), "L'interclassement ciblé ne correspond pas à l'aggrégat visé"
etudids_sorted = sorted(list(self.diplomes_ids))
if not self.rcstags:
return None
# Partant d'un dataframe vierge
initialisation = False
df = pd.DataFrame()
# Pour chaque rcs (suivi) associe la liste des etudids l'ayant suivi
asso_rcs_etudids = {}
for etudid in etudids_sorted:
rcs = self.suivis[etudid]
if rcs:
if rcs.rcs_id not in asso_rcs_etudids:
asso_rcs_etudids[rcs.rcs_id] = []
asso_rcs_etudids[rcs.rcs_id].append(etudid)
for rcs_id, etudids in asso_rcs_etudids.items():
# Charge ses moyennes au RCSTag suivi
rcstag = self.rcstags[rcs_id] # Le SxTag ou RCSTag
# Charge la moyenne
if tag in rcstag.moyennes_tags:
moytag: pd.DataFrame = rcstag.moyennes_tags[tag]
df_moytag = moytag.to_df(options=options)
# Modif les colonnes au regard du 1er df_moytag significatif lu
if not initialisation:
df = pd.DataFrame(
np.nan, index=etudids_sorted, columns=df_moytag.columns
)
colonnes = list(df_moytag.columns)
for col in colonnes:
if col.endswith("rang"):
df[col] = df[col].astype(str)
initialisation = True
# Injecte les notes des étudiants
df.loc[etudids, :] = df_moytag.loc[etudids, :]
return df

203
app/pe/moys/pe_moy.py Normal file
View File

@ -0,0 +1,203 @@
import numpy as np
import pandas as pd
from app.comp.moy_sem import comp_ranks_series
from app.pe import pe_affichage
class Moyenne:
COLONNES = [
"note",
"classement",
"rang",
"min",
"max",
"moy",
"nb_etuds",
"nb_inscrits",
]
"""Colonnes du df"""
@classmethod
def get_colonnes_synthese(cls, with_min_max_moy):
"""Renvoie le nom des colonnes à prendre en compte pour la génération
d'un dataFrame résumant les données d'un objet pe_moy.Moyenne"""
if with_min_max_moy:
return ["note", "rang", "min", "max", "moy"]
else:
return ["note", "rang"]
def __init__(self, notes: pd.Series, infos: dict[str]):
"""Classe centralisant la synthèse des moyennes/class/stat d'une série
de notes pour un groupe d'étudiants (déduits des notes).
Sont génerés des Séries/DataFrame donnant :
* les "notes" : notes (float),
* des "classements" : classements (float),
* des "min" : la note minimum sur tout le groupe d'étudiants,
* des "max" : la note maximum sur tout le groupe d'étudiants,
* des "moy" : la moyenne des notes sur tout le groupe d'étudiants,
* des "nb_inscrits" : le nombre d'étudiants ayant une note (non NaN)
Args:
notes: Une (pandas.)Série de notes
infos: Un dictionnaire donnant les informations sur la moyenne (aggrégat,
tag, intitule, cohorte, groupe)
"""
self.notes = notes
"""Les notes"""
self.etudids = list(notes.index) # calcul à venir
"""Les id des étudiants"""
self.inscrits_ids = notes[notes.notnull()].index.to_list()
"""Les id des étudiants dont la note est non nan/renseignée"""
self.df: pd.DataFrame = self.comp_moy_et_stat(self.notes)
"""Le dataframe retraçant les moyennes/classements/statistiques"""
self.infos = {
"aggregat": infos["aggregat"],
"tag": infos["tag"],
"intitule": infos["intitule"],
"cohorte": infos["cohorte"],
}
"""Dictionnaire donnant des informations sur la note (aggrégat, cohorte, tag, type_de_moyenne)"""
# self.synthese = self.to_dict()
# """La synthèse (dictionnaire) des notes/classements/statistiques"""
def __repr__(self):
"""Représentation textuelle d'un objet Moyenne
sur la base de ses `infos`.
"""
repr = get_repr(
self.infos["aggregat"],
self.infos["tag"],
self.infos["intitule"],
self.infos["cohorte"],
)
return f"Moyenne {repr}"
def comp_moy_et_stat(self, notes: pd.Series) -> dict:
"""Calcule et structure les données nécessaires au PE pour une série
de notes (pouvant être une moyenne d'un tag à une UE ou une moyenne générale
d'un tag) dans un dictionnaire spécifique.
Partant des notes, sont calculés les classements (en ne tenant compte
que des notes non nulles).
Args:
notes: Une série de notes (avec des éventuels NaN)
Returns:
Un dictionnaire stockant les notes, les classements, le min,
le max, la moyenne, le nb de notes (donc d'inscrits)
"""
df = pd.DataFrame(
np.nan,
index=self.etudids,
columns=Moyenne.COLONNES,
)
# Supprime d'éventuelles chaines de caractères dans les notes
notes = pd.to_numeric(notes, errors="coerce")
df["note"] = notes
# Les nb d'étudiants & nb d'inscrits
df["nb_etuds"] = len(self.etudids)
df["nb_etuds"] = df["nb_etuds"].astype(int)
# Les étudiants dont la note n'est pas nulle
inscrits_ids = notes[notes.notnull()].index.to_list()
df.loc[inscrits_ids, "nb_inscrits"] = len(inscrits_ids)
# df["nb_inscrits"] = df["nb_inscrits"].astype(int)
# Le classement des inscrits
notes_non_nulles = notes[inscrits_ids]
(class_str, class_int) = comp_ranks_series(notes_non_nulles)
df.loc[inscrits_ids, "classement"] = class_int
# df["classement"] = df["classement"].astype(int)
# Le rang (classement/nb_inscrit)
df["rang"] = df["rang"].astype(str)
df.loc[inscrits_ids, "rang"] = (
df.loc[inscrits_ids, "classement"].astype(int).astype(str)
+ "/"
+ df.loc[inscrits_ids, "nb_inscrits"].astype(int).astype(str)
)
# Les stat (des inscrits)
df.loc[inscrits_ids, "min"] = notes.min()
df.loc[inscrits_ids, "max"] = notes.max()
df.loc[inscrits_ids, "moy"] = notes.mean()
return df
def to_df(self, with_min_max_moy=None):
"""Renvoie le df de synthèse (tel qu'attendu dans les exports Excel),
en limitant les colonnes à celles attendues (dépendantes de l'option
``with_min_max_moy``)
"""
colonnes_synthese = Moyenne.get_colonnes_synthese(
with_min_max_moy=with_min_max_moy
)
# Copie le df modélisant les données
df = self.df[colonnes_synthese].copy()
df["rang"] = df["rang"].replace("nan", "")
# Remplace les noms de colonnes par leur intitulé dans le tableur excel
cols = []
for critere in colonnes_synthese:
nom_col = get_colonne_df(
self.infos["aggregat"],
self.infos["tag"],
self.infos["intitule"], # UEs ou compétences
self.infos["cohorte"],
critere,
)
cols += [nom_col]
df.columns = cols
return df
def to_json(self) -> dict:
"""Renvoie un dictionnaire de synthèse des moyennes/classements/statistiques générale (but)"""
df = self.to_df(with_min_max_moy=True)
resultat = df.to_json(orient="index")
return resultat
def has_notes(self) -> bool:
"""Indique si la moyenne est significative (c'est-à-dire à des notes) et/ou des inscrits"""
return len(self.inscrits_ids) > 0
def get_repr(aggregat, tag, intitule, cohorte):
"""Renvoie une représentation textuelle "aggregat|tag|intitule|cohorte"
pour représenter une moyenne
"""
liste_champs = []
if aggregat != None:
liste_champs += [aggregat]
liste_champs += [tag, intitule]
if cohorte != None:
liste_champs += [cohorte]
return "|".join(liste_champs)
def get_colonne_df(aggregat, tag, intitule, cohorte, critere):
"""Renvoie la chaine de caractère "aggregat|tag|intitule|cohorte|critere"
utilisé pour désigner les colonnes du df.
Args:
aggregat: Un nom d'aggrégat (généralement "S1" ou "3S")
pouvant être optionnel (si `None`)
tag: Un nom de tags (par ex. "maths")
intitule: Un nom d'UE ou de compétences ou "Général"
cohorte: Une cohorte pour les interclassements (généralement
Groupe ou Promo
pouvant être optionnel (si `None`)
critere: Un critère correspondant à l'une des colonnes
d'une pe_moy.Moyenne
Returns:
Une chaine de caractères indiquant les champs séparés par
un ``"|"``, généralement de la forme
"S1|maths|UE|Groupe|note"
"""
liste_champs = [get_repr(aggregat, tag, intitule, cohorte), critere]
return "|".join(liste_champs)

210
app/pe/moys/pe_moytag.py Normal file
View File

@ -0,0 +1,210 @@
import numpy as np
import pandas as pd
from app import comp
from app.comp.moy_sem import comp_ranks_series
from app.pe.moys import pe_moy
from app.pe.moys.pe_moy import get_colonne_df
import re
CODE_MOY_UE = "UEs"
CODE_MOY_COMPETENCES = "Compétences"
CHAMP_GENERAL = "Général" # Nom du champ de la moyenne générale
CHAMP_GROUPE = "groupe"
CHAMP_PROMO = "promo"
class MoyennesTag:
def __init__(
self,
tag: str,
type_moyenne: str,
matrice_notes: pd.DataFrame, # etudids x UEs|comp
matrice_coeffs: pd.DataFrame, # etudids x UEs|comp
infos: dict,
):
"""Classe centralisant un ensemble de moyennes/class/stat,
obtenu par un groupe d'étudiants, à un tag donné,
en stockant les moyennes aux UEs|Compétences
et la moyenne générale (toutes UEs confondues).
Args:
tag: Un tag
matrice_notes: Les moyennes (etudid x acronymes_ues|compétences)
aux différentes UEs ou compétences
matrice_coeffs: Les coeffs (etudid x acronymes_ues|compétences)
aux différentes UEs ou compétences
infos: Informations (aggrégat, cohorte ayant servi à calculer les moyennes)
"""
self.tag = tag
"""Le tag associé aux moyennes"""
self.type = type_moyenne
"""Le type de moyennes (par UEs ou par compétences)"""
self.infos = {
"aggregat": infos["aggregat"],
"tag": tag,
"cohorte": infos["cohorte"],
}
"""Info sur les éléments (aggrégat, cohorte) ayant servi à calculer les moyennes"""
# Les moyennes par UE/compétences (ressources/SAEs confondues)
self.matrice_notes: pd.DataFrame = matrice_notes
"""Les notes par UEs ou Compétences (DataFrame etudids x UEs|comp)"""
self.matrice_coeffs: pd.DataFrame = matrice_coeffs
"""Les coeffs à appliquer pour le calcul des moyennes générales
(toutes UE ou compétences confondues). NaN si étudiant non inscrit"""
self.intitules: list[str] = list(self.matrice_notes.columns)
"""Les intitules (acronymes d'UE ou compétences) renseignés dans les moyennes"""
assert len(self.intitules) == len(
set(self.intitules)
), "Des champs de moyennes en doublons"
self.etudids: list[int] = list(self.matrice_notes.index)
"""Les étudids renseignés dans les moyennes"""
self.moyennes_dict: dict[str, pe_moy.Moyenne] = {}
"""Dictionnaire associant à chaque UE|Compétence ses données moyenne/class/stat"""
for col in self.intitules: # if ue.type != UE_SPORT:
# Les moyennes tous modules confondus
notes = matrice_notes[col]
infos = self.infos | {"intitule": col}
self.moyennes_dict[col] = pe_moy.Moyenne(notes, infos)
# Les moyennes générales (toutes UEs confondues)
self.notes_gen = pd.Series(np.nan, index=self.matrice_notes.index)
"""Notes de la moyenne générale (toutes UEs|Comp confondues)"""
if self.has_notes():
self.notes_gen = self.compute_moy_gen(
self.matrice_notes, self.matrice_coeffs
)
infos = self.infos | {"intitule": CHAMP_GENERAL}
self.moyenne_gen = pe_moy.Moyenne(self.notes_gen, infos)
"""Dataframe retraçant les moyennes/classements/statistiques général (toutes UESs confondues et modules confondus)"""
def has_notes(self):
"""Détermine si les moyennes (aux UEs ou aux compétences)
ont des notes
Returns:
True si la moytag a des notes, False sinon
"""
for col, moy in self.moyennes_dict.items():
if not moy.has_notes():
return False
return True
# notes = self.matrice_notes
# nbre_nan = notes.isna().sum().sum()
# nbre_notes_potentielles = len(notes.index) * len(notes.columns)
# if nbre_nan == nbre_notes_potentielles:
# return False
# else:
# return True
def compute_moy_gen(self, moys: pd.DataFrame, coeffs: pd.DataFrame) -> pd.Series:
"""Calcule la moyenne générale (toutes UE/compétences confondus), en pondérant
les notes obtenues aux UEs|Compétences par les coeff (ici les crédits ECTS).
Args:
moys: Les moyennes (etudids x acronymes_ues/compétences)
coeff: Les coeff (etudids x acronymes_ues/compétences)
"""
# Calcule la moyenne générale dans le semestre (pondérée par le ECTS)
try:
moy_gen_tag = comp.moy_sem.compute_sem_moys_apc_using_ects(
moys,
coeffs.fillna(0.0),
# formation_id=self.formsemestre.formation_id,
skip_empty_ues=True,
)
return moy_gen_tag
except TypeError as e:
raise TypeError(
"Pb dans le calcul de la moyenne toutes UEs/compétences confondues"
)
def to_df(self, options={"min_max_moy": True}) -> pd.DataFrame:
"""Renvoie le df synthétisant l'ensemble des données connues.
Adapte :
* les noms des colonnes aux données fournies dans l'attribut
``infos`` (nom d'aggrégat, type de cohorte).
* à l'option ``min_max_moy`` (limitant les colonnes)
"""
if "min_max_moy" not in options or options["min_max_moy"]:
with_min_max_moy = True
else:
with_min_max_moy = False
# Les étudiants triés par etudid
etudids_sorted = sorted(self.etudids)
# Le dataFrame à générer
df = pd.DataFrame(index=etudids_sorted)
# Ajout des notes pour tous les champs
champs = list(self.intitules)
for champ in champs:
moy: pe_moy.Moyenne = self.moyennes_dict[champ]
df_champ = moy.to_df(
with_min_max_moy=with_min_max_moy
) # le dataframe (les colonnes ayant été renommées)
colonnes_renommees = ajout_numero_a_colonnes(
list(df.columns), list(df_champ.columns)
)
if colonnes_renommees:
df_champ.columns = colonnes_renommees
df = df.join(df_champ)
# Ajoute la moy générale
df_moy_gen = self.moyenne_gen.to_df(with_min_max_moy=with_min_max_moy)
colonnes_renommees = ajout_numero_a_colonnes(
list(df.columns), list(df_moy_gen.columns)
)
if colonnes_renommees:
df_moy_gen.columns = colonnes_renommees
df = df.join(df_moy_gen)
return df
def ajout_numero_a_colonnes(colonnes, colonnes_a_ajouter):
"""Partant d'une liste de noms de colonnes, vérifie si les noms des colonnes_a_ajouter
n'entre pas en conflit (aka ne sont pas déjà présent dans colonnes).
Si nom, renvoie `None`.
Si oui, propose une liste de noms de colonnes_a_ajouter dans laquelle chaque nom
est suivi d'un `"(X)"` (où X est un numéro choisi au regard des noms de colonnes).
Les noms des colonnes sont de la forme "S1|maths|UE|Groupe|note (1)"
Devrait être supprimé à terme, car les noms des colonnes sont théoriquement prévus pour être
unique/sans doublons.
"""
assert len(colonnes) == len(set(colonnes)), "Il y a déjà des doublons dans colonnes"
colonnes_sans_numero = [col.split(" (")[0] for col in colonnes]
conflits = set(colonnes_sans_numero).intersection(colonnes_a_ajouter)
if not conflits:
# Pas de conflit
return None
pattern = r"\((\d*)\)"
p = re.compile(pattern)
numeros = []
for col in colonnes:
numeros.extend(p.findall(col))
if numeros:
numeros = [int(num) for num in numeros]
num_max = max(numeros)
else:
num_max = 0
ajouts = [f"{col} ({num_max+1})" for col in colonnes_a_ajouter]
return ajouts

455
app/pe/moys/pe_rcstag.py Normal file
View File

@ -0,0 +1,455 @@
# -*- 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
from app.models import FormSemestre
from app.pe import pe_affichage
import pandas as pd
import numpy as np
from app.pe.rcss import pe_rcs, pe_rcsemx
import app.pe.moys.pe_sxtag as pe_sxtag
import app.pe.pe_comp as pe_comp
from app.pe.moys import pe_tabletags, pe_moytag
from app.scodoc.sco_utils import ModuleType
class RCSemXTag(pe_tabletags.TableTag):
def __init__(
self,
rcsemx: pe_rcsemx.RCSemX,
sxstags: dict[(str, int) : pe_sxtag.SxTag],
semXs_suivis: dict[int, dict],
):
"""Calcule les moyennes par tag (orientées compétences)
d'un regroupement de SxTag, pour extraire les classements par tag pour un
groupe d'étudiants donnés. Le groupe d'étudiants est formé par ceux ayant tous
participé au même semestre terminal.
Args:
rcsemx: Le RCSemX (identifié par un nom et l'id de son semestre terminal)
sxstags: Les données sur les SemX taggués
semXs_suivis: Les données indiquant quels SXTags sont à prendre en compte
pour chaque étudiant
"""
pe_tabletags.TableTag.__init__(self)
self.rcs_id: tuple(str, int) = rcsemx.rcs_id
"""Identifiant du RCSemXTag (identique au RCSemX sur lequel il s'appuie)"""
self.rcsemx: pe_rcsemx.RCSemX = rcsemx
"""Le regroupement RCSemX associé au RCSemXTag"""
self.semXs_suivis = semXs_suivis
"""Les semXs suivis par les étudiants"""
self.nom = self.get_repr()
"""Représentation textuelle du RSCtag"""
# Les données du semestre final
self.formsemestre_final: FormSemestre = rcsemx.formsemestre_final
"""Le semestre final"""
self.fid_final: int = rcsemx.formsemestre_final.formsemestre_id
"""Le fid du semestre final"""
# Affichage pour debug
pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}")
# Les données aggrégés (RCRCF + SxTags)
self.semXs_aggreges: dict[(str, int) : pe_rcsemx.RCSemX] = rcsemx.semXs_aggreges
"""Les SemX aggrégés"""
self.sxstags_aggreges = {}
"""Les SxTag associés aux SemX aggrégés"""
try:
for rcf_id in self.semXs_aggreges:
self.sxstags_aggreges[rcf_id] = sxstags[rcf_id]
except:
raise ValueError("Semestres SxTag manquants")
self.sxtags_connus = sxstags # Tous les sxstags connus
# Les étudiants (etuds, états civils & etudis)
sems_dans_aggregat = rcsemx.noms_semestres_aggreges
sxtag_final = self.sxstags_aggreges[(sems_dans_aggregat[-1], self.rcs_id[1])]
self.etuds = sxtag_final.etuds
"""Les étudiants (extraits du semestre final)"""
self.add_etuds(self.etuds)
self.etudids_sorted = sorted(self.etudids)
"""Les étudids triés"""
# Les compétences (extraites de tous les Sxtags)
self.acronymes_ues_to_competences = self._do_acronymes_to_competences()
"""L'association acronyme d'UEs -> compétence (extraites des SxTag aggrégés)"""
self.competences_sorted = sorted(
set(self.acronymes_ues_to_competences.values())
)
"""Compétences (triées par nom, extraites des SxTag aggrégés)"""
aff = pe_affichage.repr_comp_et_ues(self.acronymes_ues_to_competences)
pe_affichage.pe_print(f"--> Compétences : {aff}")
# Les tags
self.tags_sorted = self._do_taglist()
"""Tags extraits de tous les SxTag aggrégés"""
aff_tag = ["👜" + tag for tag in self.tags_sorted]
pe_affichage.pe_print(f"--> Tags : {', '.join(aff_tag)}")
# Les moyennes
self.moyennes_tags: dict[str, pe_moytag.MoyennesTag] = {}
"""Synthétise les moyennes/classements par tag (qu'ils soient personnalisé ou de compétences)"""
for tag in self.tags_sorted:
pe_affichage.pe_print(f"--> Moyennes du tag 👜{tag}")
# Cubes d'inscription (etudids_sorted x compétences_sorted x sxstags),
# de notes et de coeffs pour la moyenne générale
# en "aggrégant" les données des sxstags, compétence par compétence
(
inscr_df,
inscr_cube, # données d'inscriptions
notes_df,
notes_cube, # notes
coeffs_df,
coeffs_cube, # coeffs pour la moyenne générale (par UEs)
coeffs_rcues_df,
coeffs_rcues_cube, # coeffs pour la moyenne de regroupement d'UEs
) = self.assemble_cubes(tag)
# Calcule les moyennes, et synthétise les coeffs
(
moys_competences,
matrice_coeffs_moy_gen,
) = self.compute_notes_et_coeffs_competences(
notes_cube, coeffs_cube, coeffs_rcues_cube, inscr_cube
)
# Affichage des coeffs
aff = pe_affichage.repr_profil_coeffs(
matrice_coeffs_moy_gen, with_index=True
)
pe_affichage.pe_print(f" > Moyenne calculée avec pour coeffs : {aff}")
# Mémorise les moyennes et les coeff associés
infos = {"aggregat": self.rcs_id[0], "cohorte": pe_moytag.CHAMP_GROUPE}
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
tag,
pe_moytag.CODE_MOY_COMPETENCES,
moys_competences,
matrice_coeffs_moy_gen,
infos,
)
def __eq__(self, other):
"""Egalité de 2 RCS taggués sur la base de leur identifiant"""
return self.rcs_id == other.sxtag_id
def get_repr(self, verbose=True) -> str:
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
est basée)"""
if verbose:
return f"{self.__class__.__name__} basé sur " + self.rcsemx.get_repr(
verbose=verbose
)
else:
return f"{self.__class__.__name__} {self.rcs_id}"
def assemble_cubes(self, tag):
"""Pour un tag donné, construit les cubes :
* d'inscriptions aux compétences (etudid x competences x SxTag)
* de notes (etudid x competences x SxTag)
* des coeffs pour le calcul des moyennes générales par UE (etudid x competences x SxTag)
* des coeffs pour le calcul des regroupements cohérents d'UE/compétences
nécessaires au calcul des moyennes, en :
* transformant les données des UEs en données de compétences (changement de noms)
* fusionnant les données d'un même semestre, lorsque plusieurs UEs traitent d'une même compétence (cas des RCSx = Sx)
* aggrégeant les données de compétences sur plusieurs semestres (cas des RCSx = xA ou xS)
Args:
tag: Le tag visé
"""
# etudids_sorted: list[int],
# competences_sorted: list[str],
# sxstags: dict[(str, int) : pe_sxtag.SxTag],
inscriptions_dfs = {}
notes_dfs = {}
coeffs_moy_gen_dfs = {}
coeffs_rcue_dfs = {}
for sxtag_id, sxtag in self.sxstags_aggreges.items():
# Partant de dataframes vierges
inscription_df = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
)
notes_df = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
)
coeffs_moy_gen_df = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
)
coeffs_rcue_df = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.competences_sorted
)
# Charge les données du semestre tag (copie car changement de nom de colonnes à venir)
if tag in sxtag.moyennes_tags: # si le tag est présent dans le semestre
moys_tag = sxtag.moyennes_tags[tag]
# Les inscr, les notes, les coeffs
acro_ues_inscr_parcours = sxtag.acro_ues_inscr_parcours
notes = moys_tag.matrice_notes
coeffs_moy_gen = moys_tag.matrice_coeffs # les coeffs
coeffs_rcues = sxtag.coefs_rcue # dictionnaire UE -> coeff
# Traduction des acronymes d'UE en compétences
# comp_to_ues = pe_comp.asso_comp_to_accronymes(self.acronymes_ues_to_competences)
acronymes_ues_columns = notes.columns
for acronyme in acronymes_ues_columns:
# La compétence visée
competence = self.acronymes_ues_to_competences[acronyme] # La comp
# Les étud inscrits à la comp reportés dans l'inscription au RCSemX
comp_inscr = acro_ues_inscr_parcours[
acro_ues_inscr_parcours.notnull()
].index
etudids_communs = list(
inscription_df.index.intersection(comp_inscr)
)
inscription_df.loc[
etudids_communs, competence
] = acro_ues_inscr_parcours.loc[etudids_communs, acronyme]
# Les étud ayant une note à l'acronyme de la comp (donc à la comp)
etuds_avec_notes = notes[notes[acronyme].notnull()].index
etudids_communs = list(
notes_df.index.intersection(etuds_avec_notes)
)
notes_df.loc[etudids_communs, competence] = notes.loc[
etudids_communs, acronyme
]
# Les coeffs pour la moyenne générale
etuds_avec_coeffs = coeffs_moy_gen[
coeffs_moy_gen[acronyme].notnull()
].index
etudids_communs = list(
coeffs_moy_gen_df.index.intersection(etuds_avec_coeffs)
)
coeffs_moy_gen_df.loc[
etudids_communs, competence
] = coeffs_moy_gen.loc[etudids_communs, acronyme]
# Les coeffs des RCUE reportés là où les étudiants ont des notes
etuds_avec_notes = notes[notes[acronyme].notnull()].index
etudids_communs = list(
notes_df.index.intersection(etuds_avec_notes)
)
coeffs_rcue_df.loc[etudids_communs, competence] = coeffs_rcues[
acronyme
]
# Supprime tout ce qui n'est pas numérique
# for col in notes_df.columns:
# notes_df[col] = pd.to_numeric(notes_df[col], errors="coerce")
# Stocke les dfs
inscriptions_dfs[sxtag_id] = inscription_df
notes_dfs[sxtag_id] = notes_df
coeffs_moy_gen_dfs[sxtag_id] = coeffs_moy_gen_df
coeffs_rcue_dfs[sxtag_id] = coeffs_rcue_df
# Réunit les inscriptions sous forme d'un cube etudids x competences x semestres
sxtag_x_etudids_x_comps = [
inscriptions_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
]
inscriptions_etudids_x_comps_x_sxtag = np.stack(
sxtag_x_etudids_x_comps, axis=-1
)
# Réunit les notes sous forme d'un cube etudids x competences x semestres
sxtag_x_etudids_x_comps = [
notes_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
]
notes_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1)
# Réunit les coeffs sous forme d'un cube etudids x competences x semestres
sxtag_x_etudids_x_comps = [
coeffs_moy_gen_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
]
coeffs_etudids_x_comps_x_sxtag = np.stack(sxtag_x_etudids_x_comps, axis=-1)
# Normalise les coeffs de rcue par année (pour que le poids des années soit le
# même)
# Réunit les coeffs sous forme d'un cube etudids x competences x semestres
sxtag_x_etudids_x_comps = [
coeffs_rcue_dfs[sxtag_id] for sxtag_id in self.sxstags_aggreges
]
coeffs_rcues_etudids_x_comps_x_sxtag = np.stack(
sxtag_x_etudids_x_comps, axis=-1
)
return (
inscriptions_dfs,
inscriptions_etudids_x_comps_x_sxtag,
notes_dfs,
notes_etudids_x_comps_x_sxtag,
coeffs_moy_gen_dfs,
coeffs_etudids_x_comps_x_sxtag,
coeffs_rcue_dfs,
coeffs_rcues_etudids_x_comps_x_sxtag,
)
def _do_taglist(self) -> list[str]:
"""Synthétise les tags à partir des Sxtags aggrégés.
Returns:
Liste de tags triés par ordre alphabétique
"""
tags = []
for frmsem_id in self.sxstags_aggreges:
tags.extend(self.sxstags_aggreges[frmsem_id].tags_sorted)
return sorted(set(tags))
def _do_acronymes_to_competences(self) -> dict[str:str]:
"""Synthétise l'association complète {acronyme_ue: competences}
extraite de toutes les données/associations des SxTags
aggrégés.
Returns:
Un dictionnaire {'acronyme_ue' : 'compétences'}
"""
dict_competences = {}
for sxtag_id, sxtag in self.sxstags_aggreges.items():
dict_competences |= sxtag.acronymes_ues_to_competences
return dict_competences
def compute_notes_et_coeffs_competences(
self,
notes_cube: np.array,
coeffs_cube: np.array,
coeffs_rcue: np.array,
inscr_mask: np.array,
):
"""Calcule la moyenne par UEs|Compétences en moyennant sur les semestres et renvoie les résultats (notes
et coeffs) sous la forme de DataFrame"""
(etud_moy_tag, coeff_tag) = compute_moyennes_par_RCS(
notes_cube, coeffs_cube, coeffs_rcue, inscr_mask
)
etud_moy_tag_df = pd.DataFrame(
etud_moy_tag,
index=self.etudids_sorted, # les etudids
columns=self.competences_sorted, # les competences
)
coeffs_df = pd.DataFrame(
coeff_tag, index=self.etudids_sorted, columns=self.competences_sorted
)
return etud_moy_tag_df, coeffs_df
def compute_moyennes_par_RCS(
notes_cube: np.array,
coeffs_cube: np.array,
coeffs_rcue: np.array,
inscr_mask: np.array,
):
"""Partant d'une série de notes (fournie sous forme d'un cube
etudids_sorted x UEs|Compétences x semestres)
chaque note étant pondérée par un coeff dépendant du semestre (fourni dans coeffs_rcue),
et pondérée par un coeff dépendant de l'UE|Compétence pour calculer une moyenne générale
(fourni dans coeffs_cube),
calcule :
* la moyenne par UEs|Compétences sur plusieurs semestres (partant du set_cube).
* les coeffs "cumulés" à appliquer pour le calcul de la moyenne générale
La moyenne est un nombre (note/20), ou NaN si pas de notes disponibles
Args:
notes_cube: notes moyennes aux compétences ndarray
(etuds_sorted x UEs|compétences x sxtags),
des floats avec des NaN
coeffs_cube: coeffs appliqués aux compétences dans le calcul des moyennes générales,
(etuds_sorted x UEs|compétences x sxtags),
des floats avec des NaN
coeffs_rcue_cube: coeffs des RCUE appliqués dans les moyennes de RCS
inscr_mask: inscriptions aux compétences ndarray
(etuds_sorted x UEs|compétences x sxtags),
des 1.0 (si inscrit) et des NaN (si non inscrit)
Returns:
Un DataFrame avec pour columns les moyennes par tags,
et pour rows les etudid
"""
# Applique le masque d'inscriptions aux notes et aux coeffs
notes_significatives = notes_cube * inscr_mask
coeffs_moy_gen_significatifs = coeffs_cube * inscr_mask
coeffs_rcues_significatifs = coeffs_rcue * inscr_mask
# Enlève les NaN des cubes pour les entrées manquantes
notes_no_nan = np.nan_to_num(notes_significatives, nan=0.0)
coeffs_no_nan = np.nan_to_num(coeffs_moy_gen_significatifs, nan=0.0)
# Les moyennes par tag
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
# Quelles entrées contiennent des notes et des coeffs?
mask = ~np.isnan(notes_significatives) & ~np.isnan(coeffs_rcues_significatifs)
# La moyenne (pondérée) sur le regroupement cohérent de semestres
coeffs_rcues_non_nan = np.nan_to_num(coeffs_rcues_significatifs * mask, nan=0.0)
notes_ponderes = notes_no_nan * coeffs_rcues_non_nan
etud_moy_tag = np.sum(notes_ponderes, axis=2) / np.sum(
coeffs_rcues_non_nan, axis=2
)
# Les coeffs pour la moyenne générale
coeffs_pris_en_compte = coeffs_moy_gen_significatifs * mask
coeffs_no_nan = np.nan_to_num(coeffs_pris_en_compte, nan=0.0)
coeff_tag = np.sum(coeffs_no_nan, axis=2)
# Le masque des inscriptions prises en compte
inscr_prise_en_compte = inscr_mask * mask
inscr_prise_en_compte = np.nan_to_num(inscr_prise_en_compte, nan=-1.0)
inscr_tag = np.max(inscr_prise_en_compte, axis=2)
inscr_tag[inscr_tag < 0] = np.NaN # fix les max non calculés (-1) -> Na?
# Le dataFrame des notes moyennes, en réappliquant le masque des inscriptions
etud_moy_tag = etud_moy_tag * inscr_tag
# Le dataFrame des coeffs pour la moyenne générale, en réappliquant le masque des inscriptions
coeff_tag = coeff_tag * inscr_tag # Réapplique le masque des inscriptions
return (etud_moy_tag, coeff_tag)

538
app/pe/moys/pe_ressemtag.py Normal file
View File

@ -0,0 +1,538 @@
# -*- pole: 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 Generfal 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
import pandas as pd
from app import ScoValueError
from app import comp
from app.comp.res_but import ResultatsSemestreBUT
from app.models import FormSemestre, UniteEns
import app.pe.pe_affichage as pe_affichage
import app.pe.pe_etudiant as pe_etudiant
from app.pe.moys import pe_tabletags, pe_moytag
from app.scodoc import sco_tag_module
from app.scodoc import codes_cursus as sco_codes
from app.scodoc.sco_utils import *
class ResSemBUTTag(ResultatsSemestreBUT, pe_tabletags.TableTag):
"""
Un ResSemBUTTag représente les résultats des étudiants à un semestre, en donnant
accès aux moyennes par tag.
Il s'appuie principalement sur un ResultatsSemestreBUT.
"""
def __init__(
self,
formsemestre: FormSemestre,
options={"moyennes_tags": True, "moyennes_ue_res_sae": False},
):
"""
Args:
formsemestre: le ``FormSemestre`` sur lequel il se base
options: Un dictionnaire d'options
"""
ResultatsSemestreBUT.__init__(self, formsemestre)
pe_tabletags.TableTag.__init__(self)
# Le nom du res_semestre taggué
self.nom = self.get_repr(verbose=True)
# Le rang du ResultatsSemestreBUT
self.semestre_id = self.formsemestre.semestre_id
"""Rang du ResultatsSemestreBUT"""
self.annee_id = (self.semestre_id - 1) // 2 + 1
"""Année du ResultatsSemestreBUT"""
# Les étudiants (etuds, états civils & etudis) ajouté
self.add_etuds(self.etuds)
self.etudids_sorted = sorted(self.etudids)
"""Les etudids des étudiants du ResultatsSemestreBUT triés"""
pe_affichage.pe_print(
f"*** ResSemBUTTag du {self.nom} => {len(self.etudids_sorted)} étudiants"
)
# Les UEs (et les dispenses d'UE)
self.ues_standards: list[UniteEns] = [
ue for ue in self.ues if ue.type == sco_codes.UE_STANDARD
]
"""Liste des UEs standards du ResultatsSemestreBUT"""
# Les parcours des étudiants à ce semestre (None si pas de parcours)
self.parcours = []
"""Parcours auxquels sont inscrits les étudiants"""
for etudid in self.etudids_sorted:
parcour = self.formsemestre.etuds_inscriptions[etudid].parcour
if parcour:
self.parcours += [parcour.libelle]
else:
self.parcours += [None]
# Les UEs en fonction des parcours
self.ues_inscr_parcours_df = self.load_ues_inscr_parcours()
"""Inscription des étudiants aux UEs des parcours (etudids x ue_ids)"""
# Les acronymes des UEs
self.ues_to_acronymes = {ue.id: ue.acronyme for ue in self.ues_standards}
self.acronymes_sorted = sorted(self.ues_to_acronymes.values())
"""Liste des acronymes des UE triés par ordre alphabétique"""
# Les compétences associées aux UEs (définies par les acronymes)
self.acronymes_ues_to_competences = {}
"""Association {acronyme d'UEs -> compétence}"""
for ue in self.ues_standards:
assert ue.niveau_competence, ScoValueError(
"Des UEs ne sont pas rattachées à des compétences"
)
nom = ue.niveau_competence.competence.titre
self.acronymes_ues_to_competences[ue.acronyme] = nom
self.competences_sorted = sorted(
list(set(self.acronymes_ues_to_competences.values()))
)
"""Liste de compétences triées par nom"""
aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences)
pe_affichage.pe_print(f"--> UEs/Compétences : {aff}")
# Les tags personnalisés et auto:
if "moyennes_tags" in options:
self.tags_dict = self._get_tags_dict(
avec_moyennes_tags=options["moyennes_tags"]
)
else:
self.tags_dict = self._get_tags_dict()
pe_affichage.pe_print(
f"""--> {pe_affichage.aff_tags_par_categories(self.tags_dict)}"""
)
self._check_tags(self.tags_dict)
# Les coefficients pour le calcul de la moyenne générale, donnés par
# acronymes d'UE
self.matrice_coeffs_moy_gen = self._get_matrice_coeffs(
self.ues_inscr_parcours_df, self.ues_standards
)
"""DataFrame indiquant les coeffs des UEs par ordre alphabétique d'acronyme"""
profils_aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen)
pe_affichage.pe_print(
f"--> Moyenne générale calculée avec pour coeffs d'UEs : {profils_aff}"
)
# Les inscriptions aux acronymes d'ues
self.acro_ues_inscr_parcours = self._get_acro_ues_inscr_parcours(
self.ues_inscr_parcours_df, self.ues_standards
)
"""DataFrame indiquant à quelles UEs (données par leurs acronymes) sont inscrits les étudiants)"""
# Les capitalisations (mask etuids x acronyme_ue valant True si capitalisée, False sinon)
self.capitalisations = self._get_capitalisations(self.ues_standards)
"""DataFrame indiquant les UEs capitalisables d'un étudiant (etudids x )"""
# Les coeffs des RCUE
self.coefs_rcue = {}
"""Coefs de RCUE par acronyme d'UEs"""
for ue in self.ues_standards:
self.coefs_rcue[ue.acronyme] = ue.coef_rcue
# Calcul des moyennes & les classements de chaque étudiant à chaque tag
self.moyennes_tags = {}
"""Moyennes par tags (personnalisés ou 'but')"""
for tag in self.tags_dict["personnalises"]:
# pe_affichage.pe_print(f" -> Traitement du tag {tag}")
info_tag = self.tags_dict["personnalises"][tag]
# Les moyennes générales par UEs
moy_ues_tag = self.compute_moy_ues_tag(
self.ues_inscr_parcours_df, info_tag=info_tag, pole=None
)
# Mémorise les moyennes
infos = {"aggregat": None, "cohorte": pe_moytag.CHAMP_GROUPE}
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
tag,
pe_moytag.CODE_MOY_UE,
moy_ues_tag,
self.matrice_coeffs_moy_gen,
infos,
)
# Ajoute les moyennes par UEs + la moyenne générale (but)
moy_gen = self.compute_moy_gen(self.acro_ues_inscr_parcours)
infos = {"aggregat": None, "cohorte": pe_moytag.CHAMP_GROUPE}
self.moyennes_tags["but"] = pe_moytag.MoyennesTag(
"but", pe_moytag.CODE_MOY_UE, moy_gen, self.matrice_coeffs_moy_gen, infos
)
# Ajoute la moyenne générale par ressources
if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]:
moy_res_gen = self.compute_moy_ues_tag(
self.ues_inscr_parcours_df, info_tag=None, pole=ModuleType.RESSOURCE
)
infos = {"aggregat": None, "cohorte": pe_moytag.CHAMP_GROUPE}
self.moyennes_tags["ressources"] = pe_moytag.MoyennesTag(
"ressources",
pe_moytag.CODE_MOY_UE,
moy_res_gen,
self.matrice_coeffs_moy_gen,
infos,
)
# Ajoute la moyenne générale par saes
if "moyennes_ue_res_sae" in options and options["moyennes_ue_res_sae"]:
moy_saes_gen = self.compute_moy_ues_tag(
self.ues_inscr_parcours_df, info_tag=None, pole=ModuleType.SAE
)
infos = {"aggregat": None, "cohorte": pe_moytag.CHAMP_GROUPE}
self.moyennes_tags["saes"] = pe_moytag.MoyennesTag(
"saes",
pe_moytag.CODE_MOY_UE,
moy_saes_gen,
self.matrice_coeffs_moy_gen,
infos,
)
# Tous les tags
self.tags_sorted = self.get_all_significant_tags()
"""Tags (personnalisés+compétences) par ordre alphabétique"""
def get_repr(self, verbose=False) -> str:
"""Nom affiché pour le semestre taggué, de la forme (par ex.):
* S1#69 si verbose est False
* S1 FI 2023 si verbose est True
"""
if not verbose:
return f"{self.formsemestre}#{self.formsemestre.formsemestre_id}"
else:
return pe_etudiant.nom_semestre_etape(self.formsemestre, avec_fid=True)
def _get_matrice_coeffs(
self, ues_inscr_parcours_df: pd.DataFrame, ues_standards: list[UniteEns]
) -> pd.DataFrame:
"""Renvoie un dataFrame donnant les coefficients à appliquer aux UEs
dans le calcul de la moyenne générale (toutes UEs confondues).
Prend en compte l'inscription des étudiants aux UEs en fonction de leur parcours
(cf. ues_inscr_parcours_df).
Args:
ues_inscr_parcours_df: Les inscriptions des étudiants aux UEs
ues_standards: Les UEs standards à prendre en compte
Returns:
Un dataFrame etudids x acronymes_UEs avec les coeffs des UEs
"""
matrice_coeffs_moy_gen = ues_inscr_parcours_df * [
ue.ects for ue in ues_standards # if ue.type != UE_SPORT <= déjà supprimé
]
matrice_coeffs_moy_gen.columns = [
self.ues_to_acronymes[ue.id] for ue in ues_standards
]
# Tri par etudids (dim 0) et par acronymes (dim 1)
matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index()
matrice_coeffs_moy_gen = matrice_coeffs_moy_gen.sort_index(axis=1)
return matrice_coeffs_moy_gen
def _get_acro_ues_inscr_parcours(
self, ues_inscr_parcours_df: pd.DataFrame, ues_standards: list[UniteEns]
) -> pd.DataFrame:
"""Renvoie un dataFrame donnant les inscriptions (Nan ou 1) des
étudiants aux UEs définies par leur acronyme, en fonction de leur parcours
(cf. ues_inscr_parcours_df) et en limitant les données aux UEs standards (hors sport=
Args:
ues_inscr_parcours_df: Les inscriptions des étudiants aux UEs
ues_standards: Les UEs standards à prendre en compte
Returns:
Un dataFrame etudids x acronymes_UEs avec les coeffs des UEs
"""
matrice_inscription = ues_inscr_parcours_df * [
1 for ue in ues_standards # if ue.type != UE_SPORT <= déjà supprimé
]
matrice_inscription.columns = [
self.ues_to_acronymes[ue.id] for ue in ues_standards
]
# Tri par etudids (dim 0) et par acronymes (dim 1)
matrice_inscription = matrice_inscription.sort_index()
matrice_inscription = matrice_inscription.sort_index(axis=1)
return matrice_inscription
def _get_capitalisations(self, ues_standards) -> pd.DataFrame:
"""Renvoie un dataFrame résumant les UEs capitalisables par les
étudiants, d'après les décisions de jury (sous réserve qu'elles existent).
Args:
ues_standards: Liste des UEs standards (notamment autres que le sport)
Returns:
Un dataFrame etudids x acronymes_UEs dont les valeurs sont ``True`` si l'UE
est capitalisable, ``False`` sinon
"""
capitalisations = pd.DataFrame(
False, index=self.etudids_sorted, columns=self.acronymes_sorted
)
self.get_formsemestre_validations() # charge les validations
res_jury = self.validations
if res_jury:
for etud in self.etuds:
etudid = etud.etudid
decisions = res_jury.decisions_jury_ues.get(etudid, {})
for ue in ues_standards:
if ue.id in decisions and decisions[ue.id]["code"] == sco_codes.ADM:
capitalisations.loc[etudid, ue.acronyme] = True
# Tri par etudis et par accronyme d'UE
capitalisations = capitalisations.sort_index()
capitalisations = capitalisations.sort_index(axis=1)
return capitalisations
def compute_moy_ues_tag(
self,
ues_inscr_parcours_df: pd.DataFrame,
info_tag: dict[int, dict] = None,
pole=None,
) -> pd.DataFrame:
"""Calcule la moyenne par UE des étudiants pour un tag donné,
en ayant connaissance des informations sur le tag et des inscriptions des étudiants aux différentes UEs.
info_tag détermine les modules pris en compte :
* si non `None`, seuls les modules rattachés au tag sont pris en compte
* si `None`, tous les modules (quelque soit leur rattachement au tag) sont pris
en compte (sert au calcul de la moyenne générale par ressource ou SAE)
ues_inscr_parcours_df détermine les UEs pour lesquels le calcul d'une moyenne à un sens.
`pole` détermine les modules pris en compte :
* si `pole` vaut `ModuleType.RESSOURCE`, seules les ressources sont prises
en compte (moyenne de ressources par UEs)
* si `pole` vaut `ModuleType.SAE`, seules les SAEs sont prises en compte
* si `pole` vaut `None` (ou toute autre valeur),
tous les modules sont pris en compte (moyenne d'UEs)
Les informations sur le tag sont un dictionnaire listant les modimpl_id rattachés au tag,
et pour chacun leur éventuel coefficient de **repondération**.
Args:
ues_inscr_parcours_df: L'inscription aux UEs
Returns:
Le dataframe des moyennes du tag par UE
"""
modimpls_sorted = self.formsemestre.modimpls_sorted
# Adaptation du mask de calcul des moyennes au tag visé
modimpls_mask = []
for modimpl in modimpls_sorted:
module = modimpl.module # Le module
mask = module.ue.type == sco_codes.UE_STANDARD # Est-ce une UE stantard ?
if pole == ModuleType.RESSOURCE:
mask &= module.module_type == ModuleType.RESSOURCE
elif pole == ModuleType.SAE:
mask &= module.module_type == ModuleType.SAE
modimpls_mask += [mask]
# Prise en compte du tag
if info_tag:
# Désactive tous les modules qui ne sont pas pris en compte pour ce tag
for i, modimpl in enumerate(modimpls_sorted):
if modimpl.moduleimpl_id not in info_tag:
modimpls_mask[i] = False
# Applique la pondération des coefficients
modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy()
if info_tag:
for modimpl_id in info_tag:
ponderation = info_tag[modimpl_id]["ponderation"]
modimpl_coefs_ponderes_df[modimpl_id] *= ponderation
# Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)
moyennes_ues_tag = comp.moy_ue.compute_ue_moys_apc(
self.sem_cube, # matrice etudids x modimpls x UEs
self.etuds,
self.formsemestre.modimpls_sorted,
self.modimpl_inscr_df,
modimpl_coefs_ponderes_df,
modimpls_mask,
self.dispense_ues,
block=False, # self.formsemestre.block_moyennes, calcul même si sem bloqué
)
# Ne conserve que les UEs standards
colonnes = [ue.id for ue in self.ues_standards]
moyennes_ues_tag = moyennes_ues_tag[colonnes]
# Met à zéro les moyennes non calculées/calculables pour les UEs ayant des notes
for col in colonnes:
if moyennes_ues_tag[col].isna().sum() != len(moyennes_ues_tag[col]):
moyennes_ues_tag[col].fillna(0.0, inplace=True)
# Applique le masque d'inscription aux UE pour ne conserver que les UE dans lequel l'étudiant est inscrit
moyennes_ues_tag = moyennes_ues_tag[colonnes] * ues_inscr_parcours_df[colonnes]
# Transforme les UEs en acronyme
acronymes = [self.ues_to_acronymes[ue.id] for ue in self.ues_standards]
moyennes_ues_tag.columns = acronymes
# Tri par etudids et par ordre alphabétique d'acronyme
moyennes_ues_tag = moyennes_ues_tag.sort_index()
moyennes_ues_tag = moyennes_ues_tag.sort_index(axis=1)
return moyennes_ues_tag
def compute_moy_gen(self, acro_ues_inscr_parcours):
"""Récupère les moyennes des UEs pour le calcul de la moyenne générale,
en associant à chaque UE.id son acronyme (toutes UEs confondues)
"""
df_ues = pd.DataFrame(
{ue.id: self.etud_moy_ue[ue.id] for ue in self.ues_standards},
index=self.etudids,
)
# Transforme les UEs en acronyme
colonnes = df_ues.columns
acronymes = [self.ues_to_acronymes[col] for col in colonnes]
df_ues.columns = acronymes
# Met à zéro les moyennes non calculées/calculables des UEs ayant des notes
for col in df_ues.columns:
if df_ues[col].isna().sum() != len(df_ues[col]):
df_ues[col].fillna(0.0, inplace=True)
# Réapplique le mask d'inscription
df_ues = df_ues * acro_ues_inscr_parcours
# Tri par ordre aphabétique de colonnes
df_ues.sort_index(axis=1)
return df_ues
def _get_tags_dict(self, avec_moyennes_tags=True):
"""Renvoie les tags personnalisés (déduits des modules du semestre)
et les tags automatiques ('but'), et toutes leurs informations,
dans un dictionnaire de la forme :
``{"personnalises": {tag: info_sur_le_tag},
"auto": {tag: {}}``
Returns:
Le dictionnaire structuré des tags ("personnalises" vs. "auto")
"""
dict_tags = {"personnalises": dict(), "auto": dict()}
if avec_moyennes_tags:
# Les tags perso (seulement si l'option d'utiliser les tags perso est choisie)
dict_tags["personnalises"] = get_synthese_tags_personnalises_semestre(
self.formsemestre
)
# Les tags automatiques
# Déduit des compétences
# dict_ues_competences = get_noms_competences_from_ues(self.nt.formsemestre)
# noms_tags_comp = list(set(dict_ues_competences.values()))
# BUT
dict_tags["auto"] = {"but": {}, "ressources": {}, "saes": {}}
return dict_tags
def _check_tags(self, dict_tags):
"""Vérifie l'unicité des tags"""
noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys())))
noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp
noms_tags = noms_tags_perso + noms_tags_auto
intersection = list(set(noms_tags_perso) & set(noms_tags_auto))
if intersection:
liste_intersection = "\n".join(
[f"<li><code>{tag}</code></li>" for tag in intersection]
)
s = "s" if len(intersection) > 1 else ""
message = f"""Erreur dans le module PE : Un des tags saisis dans votre
programme de formation fait parti des tags réservés. En particulier,
votre semestre <em>{self.formsemestre.titre_annee()}</em>
contient le{s} tag{s} réservé{s} suivant :
<ul>
{liste_intersection}
</ul>
Modifiez votre programme de formation pour le{s} supprimer.
Il{s} ser{'ont' if s else 'a'} automatiquement à vos documents de poursuites d'études.
"""
raise ScoValueError(message)
def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre):
"""Etant données les implémentations des modules du semestre (modimpls),
synthétise les tags renseignés dans le programme pédagogique &
associés aux modules du semestre,
en les associant aux modimpls qui les concernent (modimpl_id) et
au coeff de repondération fournie avec le tag (par défaut 1 si non indiquée)).
Le dictionnaire fournit est de la forme :
``{ tag : { modimplid: {"modimpl": ModImpl,
"ponderation": coeff_de_reponderation}
} }``
Args:
formsemestre: Le formsemestre à la base de la recherche des tags
Return:
Un dictionnaire décrivant les tags
"""
synthese_tags = {}
# Instance des modules du semestre
modimpls = formsemestre.modimpls_sorted
for modimpl in modimpls:
modimpl_id = modimpl.id
# Liste des tags pour le module concerné
tags = sco_tag_module.module_tag_list(modimpl.module.id)
# Traitement des tags recensés, chacun pouvant étant de la forme
# "mathématiques", "théorie", "pe:0", "maths:2"
for tag in tags:
# Extraction du nom du tag et du coeff de pondération
(tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag)
# Ajout d'une clé pour le tag
if tagname not in synthese_tags:
synthese_tags[tagname] = {}
# Ajout du module (modimpl) au tagname considéré
synthese_tags[tagname][modimpl_id] = {
"modimpl": modimpl, # les données sur le module
"ponderation": ponderation, # la pondération demandée pour le tag sur le module
}
return synthese_tags

421
app/pe/moys/pe_sxtag.py Normal file
View File

@ -0,0 +1,421 @@
# -*- 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
from app.pe import pe_affichage, pe_comp
import app.pe.moys.pe_ressemtag as pe_ressemtag
import pandas as pd
import numpy as np
from app.pe.moys import pe_moytag, pe_tabletags
import app.pe.rcss.pe_trajectoires as pe_trajectoires
from app.scodoc.sco_utils import ModuleType
class SxTag(pe_tabletags.TableTag):
def __init__(
self,
sxtag_id: (str, int),
semx: pe_trajectoires.SemX,
ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag],
):
"""Calcule les moyennes/classements par tag d'un semestre de type 'Sx'
(par ex. 'S1', 'S2', ...) représentés par acronyme d'UE.
Il représente :
* pour les étudiants *non redoublants* : moyennes/classements
du semestre suivi
* pour les étudiants *redoublants* : une fusion des moyennes/classements
dans les (2) 'Sx' qu'il a suivi, en exploitant les informations de capitalisation :
meilleure moyenne entre l'UE capitalisée et l'UE refaite (la notion de meilleure
s'appliquant à la moyenne d'UE)
Un SxTag (regroupant potentiellement plusieurs semestres) est identifié
par un tuple ``(Sx, fid)`` :
* ``x`` est le rang (semestre_id) du semestre
* ``fid`` le formsemestre_id du semestre final (le plus récent) du regroupement.
Les **tags**, les **UE** et les inscriptions aux UEs (pour les étudiants)
considérés sont uniquement ceux du semestre final.
Args:
sxtag_id: L'identifiant de SxTag
ressembuttags: Un dictionnaire de la forme `{fid: ResSemBUTTag(fid)}` donnant
les semestres à regrouper et les résultats/moyennes par tag des
semestres
"""
pe_tabletags.TableTag.__init__(self)
assert sxtag_id and len(sxtag_id) == 2 and sxtag_id[1] in ressembuttags
self.sxtag_id: (str, int) = sxtag_id
"""Identifiant du SxTag de la forme (nom_Sx, fid_semestre_final)"""
assert (
len(self.sxtag_id) == 2
and isinstance(self.sxtag_id[0], str)
and isinstance(self.sxtag_id[1], int)
), "Format de l'identifiant du SxTag non respecté"
self.agregat = sxtag_id[0]
"""Nom de l'aggrégat du RCS"""
self.semx = semx
"""Le SemX sur lequel il s'appuie"""
assert semx.rcs_id == sxtag_id, "Problème de correspondance SxTag/SemX"
# Les resultats des semestres taggués à prendre en compte dans le SemX
self.ressembuttags = {
fid: ressembuttags[fid] for fid in semx.semestres_aggreges
}
"""Les ResSemBUTTags à regrouper dans le SxTag"""
# Les données du semestre final
self.fid_final = sxtag_id[1]
self.ressembuttag_final = ressembuttags[self.fid_final]
"""Le ResSemBUTTag final"""
self.semestre_id = self.ressembuttag_final.semestre_id
"""Rang du SxTag"""
self.annee_id = self.ressembuttag_final.annee_id
"""Année du SxTag"""
# Ajoute les etudids et les états civils
self.etuds = self.ressembuttag_final.etuds
"""Les étudiants (extraits du ReSemBUTTag final)"""
self.add_etuds(self.etuds)
self.etudids_sorted = sorted(self.etudids)
"""Les etudids triés"""
# Affichage
pe_affichage.pe_print(f"*** {self.get_repr(verbose=True)}")
# Les tags
self.tags_sorted = self.ressembuttag_final.tags_sorted
"""Tags (extraits du ReSemBUTTag final)"""
aff_tag = pe_affichage.repr_tags(self.tags_sorted)
pe_affichage.pe_print(f"--> Tags : {aff_tag}")
# Les UE données par leur acronyme
self.acronymes_sorted = self.ressembuttag_final.acronymes_sorted
"""Les acronymes des UEs (extraits du ResSemBUTTag final)"""
# L'association UE-compétences extraites du dernier semestre
self.acronymes_ues_to_competences = (
self.ressembuttag_final.acronymes_ues_to_competences
)
"""L'association acronyme d'UEs -> compétence"""
self.competences_sorted = sorted(self.acronymes_ues_to_competences.values())
"""Les compétences triées par nom"""
aff = pe_affichage.repr_asso_ue_comp(self.acronymes_ues_to_competences)
pe_affichage.pe_print(f"--> UEs/Compétences : {aff}")
# Les coefs de RCUE issus du dernier semestre
self.coefs_rcue = self.ressembuttag_final.coefs_rcue
"""Coefs de RCUE par acronyme d'UEs"""
# Les inscriptions des étudiants aux UEs (données par leur acronyme)
# par report de celle du ressemfinal
self.acro_ues_inscr_parcours = self.ressembuttag_final.acro_ues_inscr_parcours
# Les coeffs pour la moyenne générale (traduisant également l'inscription
# des étudiants aux UEs) (etudids_sorted x acronymes_ues_sorted)
self.matrice_coeffs_moy_gen = self.ressembuttag_final.matrice_coeffs_moy_gen
"""La matrice des coeffs pour la moyenne générale"""
aff = pe_affichage.repr_profil_coeffs(self.matrice_coeffs_moy_gen)
pe_affichage.pe_print(
f"--> Moyenne générale calculée avec pour coeffs d'UEs : {aff}"
)
# Masque des inscriptions et des capitalisations
self.masque_df = None
"""Le DataFrame traduisant les capitalisations des différents semestres"""
self.masque_df, masque_cube = compute_masques_capitalisation_cube(
self.etudids_sorted,
self.acronymes_sorted,
self.ressembuttags,
self.fid_final,
)
pe_affichage.aff_capitalisations(
self.etuds,
self.ressembuttags,
self.fid_final,
self.acronymes_sorted,
self.masque_df,
)
# Les moyennes par tag
self.moyennes_tags: dict[str, pd.DataFrame] = {}
"""Moyennes aux UEs (identifiées par leur acronyme) des différents tags"""
if self.tags_sorted:
pe_affichage.pe_print("--> Calcul des moyennes par tags :")
for tag in self.tags_sorted:
pe_affichage.pe_print(f" > MoyTag 👜{tag}")
# Masque des inscriptions aux UEs (extraits de la matrice de coefficients)
# inscr_mask: np.array = ~np.isnan(self.matrice_coeffs_moy_gen.to_numpy())
# Moyennes (tous modules confondus)
if not self.has_notes_tag(tag):
pe_affichage.pe_print(
f" --> Semestre (final) actuellement sans notes"
)
matrice_moys_ues = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted
)
else:
# Moyennes tous modules confondus
### Cube de note etudids x UEs tous modules confondus
notes_df_gen, notes_cube_gen = self.compute_notes_ues_cube(tag)
# DataFrame des moyennes (tous modules confondus)
inscr_mask = self.acro_ues_inscr_parcours.to_numpy()
matrice_moys_ues = self.compute_notes_ues(
notes_cube_gen, masque_cube, inscr_mask
)
# Mémorise les infos pour la moyenne au tag
infos = {"aggregat": self.sxtag_id[0], "cohorte": pe_moytag.CHAMP_GROUPE}
self.moyennes_tags[tag] = pe_moytag.MoyennesTag(
tag,
pe_moytag.CODE_MOY_UE,
matrice_moys_ues,
self.matrice_coeffs_moy_gen,
infos,
)
# Affichage de debug
aff = pe_affichage.repr_profil_coeffs(
self.matrice_coeffs_moy_gen, with_index=True
)
pe_affichage.pe_print(f" > Moyenne générale calculée avec : {aff}")
def has_notes_tag(self, tag):
"""Détermine si le SxTag, pour un tag donné, est en cours d'évaluation.
Si oui, n'a pas (encore) de notes dans le resformsemestre final.
Args:
tag: Le tag visé
Returns:
True si a des notes, False sinon
"""
moy_tag_dernier_sem = self.ressembuttag_final.moyennes_tags[tag]
return moy_tag_dernier_sem.has_notes()
def __eq__(self, other):
"""Egalité de 2 SxTag sur la base de leur identifiant"""
return self.sxtag_id == other.sxtag_id
def get_repr(self, verbose=False) -> str:
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
est basée)"""
if verbose:
return f"SXTag basé sur {self.semx.get_repr()}"
else:
# affichage = [str(fid) for fid in self.ressembuttags]
return f"SXTag {self.agregat}#{self.fid_final}"
def compute_notes_ues_cube(self, tag) -> (pd.DataFrame, np.array):
"""Construit le cube de notes des UEs (etudid x accronyme_ue x semestre_aggregé)
nécessaire au calcul des moyennes du tag pour le RCS Sx.
(Renvoie également le dataframe associé pour debug).
Args:
tag: Le tag considéré (personalisé ou "but")
"""
# Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2)
# etudids_sorted = etudids_sorted
# acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()])
semestres_id = list(self.ressembuttags.keys())
dfs = {}
for frmsem_id in semestres_id:
# Partant d'un dataframe vierge
df = pd.DataFrame(
np.nan, index=self.etudids_sorted, columns=self.acronymes_sorted
)
# Charge les notes du semestre tag
sem_tag = self.ressembuttags[frmsem_id]
moys_tag = sem_tag.moyennes_tags[tag]
notes = moys_tag.matrice_notes # dataframe etudids x ues
# les étudiants et les acronymes communs
etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs(
df, notes
)
# Recopie
df.loc[etudids_communs, acronymes_communs] = notes.loc[
etudids_communs, acronymes_communs
]
# Supprime tout ce qui n'est pas numérique
for col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
# Stocke le df
dfs[frmsem_id] = df
"""Réunit les notes sous forme d'un cube etudids x ues x semestres"""
semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs]
etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1)
return dfs, etudids_x_ues_x_semestres
def compute_notes_ues(
self,
set_cube: np.array,
cap_mask_3D: np.array,
inscr_mask: np.array,
) -> pd.DataFrame:
"""Calcule la moyenne par UEs à un tag donné en prenant la note maximum (UE
par UE) obtenue par un étudiant à un semestre.
Args:
set_cube: notes moyennes aux modules ndarray
(semestre_ids x etudids x UEs), des floats avec des NaN
cap_mask_3D
: masque indiquant si la note doit être prise en compte ndarray
(semestre_ids x etudids x UEs), des 1.0 ou des 0.0
inscr_mask: masque etudids x UE traduisant les inscriptions des
étudiants aux UE (du semestre terminal)
Returns:
Un DataFrame avec pour columns les moyennes par ues,
et pour rows les etudid
"""
# etudids_sorted: liste des étudiants (dim. 0 du cube) trié par etudid
# acronymes_sorted: liste des acronymes des ues (dim. 1 du cube) trié par acronyme
nb_etuds, nb_ues, nb_semestres = set_cube.shape
nb_etuds_mask, nb_ues_mask = inscr_mask.shape
# assert nb_etuds == len(self.etudids_sorted)
# assert nb_ues == len(self.acronymes_sorted)
# assert nb_etuds == nb_etuds_mask
# assert nb_ues == nb_ues_mask
# Entrées à garder dans le cube en fonction du masque d'inscription aux UEs du parcours
inscr_mask_3D = np.stack([inscr_mask] * nb_semestres, axis=-1)
set_cube = set_cube * inscr_mask_3D
# Entrées à garder en fonction des UEs capitalisées ou non
set_cube = set_cube * cap_mask_3D
# Enlève les NaN du cube pour les entrées manquantes : NaN -> -1.0
set_cube_no_nan = np.nan_to_num(set_cube, nan=-1.0)
# Les moyennes par ues
# TODO: Pour l'instant un max sans prise en compte des UE capitalisées
etud_moy = np.max(set_cube_no_nan, axis=2)
# Fix les max non calculés (-1) -> NaN
etud_moy[etud_moy < 0] = np.NaN
# Réapplique le masque d'inscription (dans le doute)
etud_moy = etud_moy * inscr_mask
# Le dataFrame
etud_moy_tag_df = pd.DataFrame(
etud_moy,
index=self.etudids_sorted, # les etudids
columns=self.acronymes_sorted, # les acronymes d'UEs
)
etud_moy_tag_df = etud_moy_tag_df.fillna(np.nan)
return etud_moy_tag_df
def compute_masques_capitalisation_cube(
etudids_sorted: list[int],
acronymes_sorted: list[str],
ressembuttags: dict[int, pe_ressemtag.ResSemBUTTag],
formsemestre_id_final: int,
) -> (pd.DataFrame, np.array):
"""Construit le cube traduisant les masques des UEs à prendre en compte dans le calcul
des moyennes, en utilisant le dataFrame de capitalisations de chaque ResSemBUTTag
Ces masques contiennent : 1 si la note doit être prise en compte, 0 sinon
Le masque des UEs à prendre en compte correspondant au semestre final (identifié par
son formsemestre_id_final) est systématiquement à 1 (puisque les résultats
de ce semestre doivent systématiquement
être pris en compte notamment pour les étudiants non redoublant).
Args:
etudids_sorted: La liste des etudids triés par ordre croissant (dim 0)
acronymes_sorted: La liste des acronymes de UEs triés par acronyme croissant (dim 1)
ressembuttags: Le dictionnaire des résultats de semestres BUT (tous tags confondus)
formsemestre_id_final: L'identifiant du formsemestre_id_final
"""
# Index du cube (etudids -> dim 0, ues -> dim 1, semestres -> dim2)
# etudids_sorted = etudids_sorted
# acronymes_ues = sorted([ue.acronyme for ue in selMf.ues.values()])
semestres_id = list(ressembuttags.keys())
dfs = {}
for frmsem_id in semestres_id:
# Partant d'un dataframe contenant des 1.0
if frmsem_id == formsemestre_id_final:
df = pd.DataFrame(1.0, index=etudids_sorted, columns=acronymes_sorted)
else: # semestres redoublés
df = pd.DataFrame(0.0, index=etudids_sorted, columns=acronymes_sorted)
# Traitement des capitalisations : remplace les infos de capitalisations par les coeff 1 ou 0
capitalisations = ressembuttags[frmsem_id].capitalisations
capitalisations = capitalisations.replace(True, 1.0).replace(False, 0.0)
# Met à 0 les coeffs des UEs non capitalisées pour les étudiants
# inscrits dans les 2 semestres: 1.0*False => 0.0
etudids_communs, acronymes_communs = pe_comp.find_index_and_columns_communs(
df, capitalisations
)
df.loc[etudids_communs, acronymes_communs] = capitalisations.loc[
etudids_communs, acronymes_communs
]
# Stocke le df
dfs[frmsem_id] = df
"""Réunit les notes sous forme d'un cube etudids x ues x semestres"""
semestres_x_etudids_x_ues = [dfs[fid].values for fid in dfs]
etudids_x_ues_x_semestres = np.stack(semestres_x_etudids_x_ues, axis=-1)
return dfs, etudids_x_ues_x_semestres

197
app/pe/moys/pe_tabletags.py Normal file
View File

@ -0,0 +1,197 @@
# -*- pole: 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Thu Sep 8 09:36:33 2016
@author: barasc
"""
import pandas as pd
from app.models import Identite
from app.pe.moys import pe_moytag
TAGS_RESERVES = ["but"]
CHAMPS_ADMINISTRATIFS = ["Civilité", "Nom", "Prénom"]
class TableTag(object):
def __init__(self):
"""Classe centralisant différentes méthodes communes aux
SemestreTag, TrajectoireTag, AggregatInterclassTag
"""
# Les étudiants
# self.etuds: list[Identite] = None # A venir
"""Les étudiants"""
# self.etudids: list[int] = {}
"""Les etudids"""
def add_etuds(self, etuds: list[Identite]):
"""Mémorise les informations sur les étudiants
Args:
etuds: la liste des identités de l'étudiant
"""
# self.etuds = etuds
self.etudids = list({etud.etudid for etud in etuds})
def get_all_significant_tags(self):
"""Liste des tags de la table, triée par ordre alphabétique,
extraite des clés du dictionnaire ``moyennes_tags``, en ne
considérant que les moyennes ayant des notes.
Returns:
Liste de tags triés par ordre alphabétique
"""
tags = []
for tag, moytag in self.moyennes_tags.items():
if moytag.has_notes():
tags.append(tag)
return sorted(tags)
def to_df(
self,
administratif=True,
aggregat=None,
tags_cibles=None,
cohorte=None,
options={"min_max_moy": True},
) -> pd.DataFrame:
"""Renvoie un dataframe listant toutes les données
des moyennes/classements/min/max/moy
des étudiants aux différents tags.
tags_cibles limitent le dataframe aux tags indiqués
type_colonnes indiquent si les colonnes doivent être passées en multiindex
Args:
administratif: Indique si les données administratives sont incluses
aggregat: l'aggrégat représenté (éventuellement `None` si non connu)
tags_cibles: la liste des tags ciblés
cohorte: la cohorte représentée (éventuellement `None` si non connue)
Returns:
Le dataframe complet de synthèse
"""
# Les tags visés
tags_tries = self.get_all_significant_tags()
if not tags_cibles:
tags_cibles = tags_tries
tags_cibles = sorted(tags_cibles)
# Les étudiants visés
if administratif:
df = df_administratif(self.etuds, aggregat=aggregat, cohorte=cohorte)
else:
df = pd.DataFrame(index=self.etudids)
if not self.is_significatif():
return df
# Ajout des données par tags
for tag in tags_cibles:
if tag in self.moyennes_tags:
moy_tag_df = self.moyennes_tags[tag].to_df(options=options)
df = df.join(moy_tag_df)
# Tri par nom, prénom
if administratif:
colonnes_tries = [
_get_champ_administratif(champ, aggregat=aggregat, cohorte=cohorte)
for champ in CHAMPS_ADMINISTRATIFS[1:]
] # Nom + Prénom
df = df.sort_values(by=colonnes_tries)
return df
def has_etuds(self):
"""Indique si un tabletag contient des étudiants"""
return len(self.etuds) > 0
def is_significatif(self):
"""Indique si une tabletag a des données"""
# A des étudiants
if not self.etuds:
return False
# A des tags avec des notes
tags_tries = self.get_all_significant_tags()
if not tags_tries:
return False
return True
def _get_champ_administratif(champ, aggregat=None, cohorte=None):
"""Pour un champ donné, renvoie l'index (ou le multindex)
à intégrer au dataframe"""
liste = []
if aggregat != None:
liste += [aggregat]
liste += ["Administratif", "Identité"]
if cohorte != None:
liste += [champ]
liste += [champ]
return "|".join(liste)
def df_administratif(
etuds: list[Identite], aggregat=None, cohorte=None
) -> pd.DataFrame:
"""Renvoie un dataframe donnant les données administratives
des étudiants du TableTag
Args:
etuds: Identité des étudiants générant les données administratives
"""
identites = {etud.etudid: etud for etud in etuds}
donnees = {}
etud: Identite = None
for etudid, etud in identites.items():
data = {
CHAMPS_ADMINISTRATIFS[0]: etud.civilite_str,
CHAMPS_ADMINISTRATIFS[1]: etud.nom,
CHAMPS_ADMINISTRATIFS[2]: etud.prenom_str,
}
donnees[etudid] = {
_get_champ_administratif(champ, aggregat, cohorte): data[champ]
for champ in CHAMPS_ADMINISTRATIFS
}
colonnes = [
_get_champ_administratif(champ, aggregat, cohorte)
for champ in CHAMPS_ADMINISTRATIFS
]
df = pd.DataFrame.from_dict(donnees, orient="index", columns=colonnes)
df = df.sort_values(by=colonnes[1:])
return df

View File

@ -8,8 +8,10 @@
from flask import g
from app import log
from app.pe.rcss import pe_rcs
import app.pe.pe_comp as pe_comp
PE_DEBUG = False
PE_DEBUG = True
# On stocke les logs PE dans g.scodoc_pe_log
@ -20,17 +22,19 @@ def pe_start_log() -> list[str]:
return g.scodoc_pe_log
def pe_print(*a):
def pe_print(*a, **cles):
"Log (or print in PE_DEBUG mode) and store in g"
lines = getattr(g, "scodoc_pe_log")
if lines is None:
lines = pe_start_log()
msg = " ".join(a)
lines.append(msg)
if PE_DEBUG:
msg = " ".join(a)
print(msg)
else:
log(msg)
lines = getattr(g, "scodoc_pe_log")
if lines is None:
lines = pe_start_log()
msg = " ".join(a)
lines.append(msg)
if "info" in cles:
log(msg)
def pe_get_log() -> str:
@ -40,5 +44,212 @@ def pe_get_log() -> str:
# Affichage dans le tableur pe en cas d'absence de notes
SANS_NOTE = "-"
NOM_STAT_GROUPE = "statistiques du groupe"
NOM_STAT_PROMO = "statistiques de la promo"
def repr_profil_coeffs(matrice_coeffs_moy_gen, with_index=False):
"""Affiche les différents types de coefficients (appelés profil)
d'une matrice_coeffs_moy_gen (pour debug)
"""
# Les profils des coeffs d'UE (pour debug)
profils = []
index_a_profils = {}
for i in matrice_coeffs_moy_gen.index:
val = matrice_coeffs_moy_gen.loc[i].fillna("-")
val = " | ".join([str(v) for v in val])
if val not in profils:
profils += [val]
index_a_profils[val] = [str(i)]
else:
index_a_profils[val] += [str(i)]
# L'affichage
if len(profils) > 1:
if with_index:
elmts = [
" " * 10
+ prof
+ " (par ex. "
+ ", ".join(index_a_profils[prof][:10])
+ ")"
for prof in profils
]
else:
elmts = [" " * 10 + prof for prof in profils]
profils_aff = "\n" + "\n".join(elmts)
else:
profils_aff = "\n".join(profils)
return profils_aff
def repr_asso_ue_comp(acronymes_ues_to_competences):
"""Représentation textuelle de l'association UE -> Compétences
fournies dans acronymes_ues_to_competences
"""
champs = acronymes_ues_to_competences.keys()
champs = sorted(champs)
aff_comp = []
for acro in champs:
aff_comp += [f"📍{acro} (∈ 💡{acronymes_ues_to_competences[acro]})"]
return ", ".join(aff_comp)
def aff_UEs(champs):
"""Représentation textuelle des UEs fournies dans `champs`"""
champs_tries = sorted(champs)
aff_comp = []
for comp in champs_tries:
aff_comp += ["📍" + comp]
return ", ".join(aff_comp)
def aff_competences(champs):
"""Affiche les compétences"""
champs_tries = sorted(champs)
aff_comp = []
for comp in champs_tries:
aff_comp += ["💡" + comp]
return ", ".join(aff_comp)
def repr_tags(tags):
"""Affiche les tags"""
tags_tries = sorted(tags)
aff_tag = ["👜" + tag for tag in tags_tries]
return ", ".join(aff_tag)
def aff_tags_par_categories(dict_tags):
"""Etant donné un dictionnaire de tags, triés
par catégorie (ici "personnalisés" ou "auto")
représentation textuelle des tags
"""
noms_tags_perso = sorted(list(set(dict_tags["personnalises"].keys())))
noms_tags_auto = sorted(list(set(dict_tags["auto"].keys()))) # + noms_tags_comp
if noms_tags_perso:
aff_tags_perso = ", ".join([f"👜{nom}" for nom in noms_tags_perso])
aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto])
return f"Tags du programme de formation : {aff_tags_perso} + Automatiques : {aff_tags_auto}"
else:
aff_tags_auto = ", ".join([f"👜{nom}" for nom in noms_tags_auto])
return f"Tags automatiques {aff_tags_auto} (aucun tag personnalisé)"
def repr_jeune(etudid, etudiants):
"""Renvoie la représentation d'un étudiant"""
etat = "" if etudid in etudiants.abandons_ids else ""
jeune = f"{etat} {etudiants.identites[etudid].nomprenom} (#{etudid})"
return jeune
def aff_trajectoires_suivies_par_etudiants(etudiants):
"""Affiche les trajectoires (regroupement de (form)semestres)
amenant un étudiant du S1 à un semestre final,
en regroupant les étudiants par profil de trajectoires"""
# Affichage pour debug
etudiants_ids = etudiants.etudiants_ids
jeunes = list(enumerate(etudiants_ids))
profils_traj = {}
for no_etud, etudid in jeunes:
jeune = repr_jeune(etudid, etudiants)
# La trajectoire du jeune
trajectoires = etudiants.trajectoires[etudid]
profil_traj = []
for nom_rcs, rcs in trajectoires.items():
if rcs:
profil_traj += [f" > RCS ⏯️{nom_rcs}: {rcs.get_repr()}"]
aff_profil_traj = "\n".join(profil_traj)
if aff_profil_traj not in profils_traj:
profils_traj[aff_profil_traj] = []
profils_traj[aff_profil_traj] += [jeune]
# Affichage final
for profil, jeunes in profils_traj.items():
pe_print(f"--> Trajectoire suivie par : ")
pe_print("\n".join([" " + jeune for jeune in jeunes]))
pe_print(profil)
def aff_semXs_suivis_par_etudiants(etudiants):
"""Affiche les SemX (regroupement de semestres de type Sx)
amenant un étudiant à valider un Sx"""
etudiants_ids = etudiants.etudiants_ids
jeunes = list(enumerate(etudiants_ids))
for no_etud, etudid in jeunes:
etat = "" if etudid in etudiants.abandons_ids else ""
pe_print(f"--> {etat} {etudiants.identites[etudid].nomprenom} :")
for nom_rcs, rcs in etudiants.semXs[etudid].items():
if rcs:
pe_print(f" > SemX ⏯️{nom_rcs}: {rcs.get_repr()}")
vides = []
for nom_rcs in pe_rcs.TOUS_LES_SEMESTRES:
les_semX_suivis = []
for no_etud, etudid in jeunes:
if etudiants.semXs[etudid][nom_rcs]:
les_semX_suivis.append(etudiants.semXs[etudid][nom_rcs])
if not les_semX_suivis:
vides += [nom_rcs]
vides = sorted(list(set(vides)))
pe_print(f"⚠️ SemX sans données : {', '.join(vides)}")
def aff_capitalisations(etuds, ressembuttags, fid_final, acronymes_sorted, masque_df):
"""Affichage des capitalisations du sxtag pour debug"""
aff_cap = []
for etud in etuds:
cap = []
for frmsem_id in ressembuttags:
if frmsem_id != fid_final:
for accr in acronymes_sorted:
if masque_df[frmsem_id].loc[etud.etudid, accr] > 0.0:
cap += [accr]
if cap:
aff_cap += [f" > {etud.nomprenom} : {', '.join(cap)}"]
if aff_cap:
pe_print(f"--> ⚠️ Capitalisations :")
pe_print("\n".join(aff_cap))
def repr_comp_et_ues(acronymes_ues_to_competences):
"""Affichage pour debug"""
asso_comp_to_ues = pe_comp.asso_comp_to_accronymes(acronymes_ues_to_competences)
aff_comp = []
competences_sorted = sorted(asso_comp_to_ues.keys())
for comp in competences_sorted:
liste = ["📍" + accro for accro in asso_comp_to_ues[comp]]
aff_comp += [f" 💡{comp} (⇔ {', '.join(liste)})"]
return "\n".join(aff_comp)
def aff_rcsemxs_suivis_par_etudiants(etudiants):
"""Affiche les RCSemX (regroupement de SemX)
amenant un étudiant du S1 à un Sx"""
etudiants_ids = etudiants.etudiants_ids
jeunes = list(enumerate(etudiants_ids))
for no_etud, etudid in jeunes:
etat = "" if etudid in etudiants.abandons_ids else ""
pe_print(f"-> {etat} {etudiants.identites[etudid].nomprenom} :")
for nom_rcs, rcs in etudiants.rcsemXs[etudid].items():
if rcs:
pe_print(f" > RCSemX ⏯️{nom_rcs}: {rcs.get_repr()}")
vides = []
for nom_rcs in pe_rcs.TOUS_LES_RCS:
les_rcssemX_suivis = []
for no_etud, etudid in jeunes:
if etudiants.rcsemXs[etudid][nom_rcs]:
les_rcssemX_suivis.append(etudiants.rcsemXs[etudid][nom_rcs])
if not les_rcssemX_suivis:
vides += [nom_rcs]
vides = sorted(list(set(vides)))
pe_print(f"⚠️ RCSemX vides : {', '.join(vides)}")

View File

@ -41,13 +41,13 @@ import datetime
import re
import unicodedata
import pandas as pd
from flask import g
import app.scodoc.sco_utils as scu
from app.models import FormSemestre
from app.pe.pe_rcs import TYPES_RCS
from app.pe.rcss.pe_rcs import TYPES_RCS
from app.scodoc import sco_formsemestre
from app.scodoc.sco_logos import find_logo
@ -284,3 +284,74 @@ def get_cosemestres_diplomants(annee_diplome: int) -> dict[int, FormSemestre]:
cosemestres[fid] = cosem
return cosemestres
def tri_semestres_par_rang(cosemestres: dict[int, FormSemestre]):
"""Partant d'un dictionnaire de cosemestres, les tri par rang (semestre_id) dans un
dictionnaire {rang: [liste des semestres du dit rang]}"""
cosemestres_tries = {}
for sem in cosemestres.values():
cosemestres_tries[sem.semestre_id] = cosemestres_tries.get(
sem.semestre_id, []
) + [sem]
return cosemestres_tries
def find_index_and_columns_communs(
df1: pd.DataFrame, df2: pd.DataFrame
) -> (list, list):
"""Partant de 2 DataFrames ``df1`` et ``df2``, renvoie les indices de lignes
et de colonnes, communes aux 2 dataframes
Args:
df1: Un dataFrame
df2: Un dataFrame
Returns:
Le tuple formé par la liste des indices de lignes communs et la liste des indices
de colonnes communes entre les 2 dataFrames
"""
indices1 = df1.index
indices2 = df2.index
indices_communs = list(df1.index.intersection(df2.index))
colonnes1 = df1.columns
colonnes2 = df2.columns
colonnes_communes = list(set(colonnes1) & set(colonnes2))
return indices_communs, colonnes_communes
def get_dernier_semestre_en_date(semestres: dict[int, FormSemestre]) -> FormSemestre:
"""Renvoie le dernier semestre en **date de fin** d'un dictionnaire
de semestres (potentiellement non trié) de la forme ``{fid: FormSemestre(fid)}``.
Args:
semestres: Un dictionnaire de semestres
Return:
Le FormSemestre du semestre le plus récent
"""
if semestres:
fid_dernier_semestre = list(semestres.keys())[0]
dernier_semestre: FormSemestre = semestres[fid_dernier_semestre]
for fid in semestres:
if semestres[fid].date_fin > dernier_semestre.date_fin:
dernier_semestre = semestres[fid]
return dernier_semestre
return None
def asso_comp_to_accronymes(accro_ues_to_competences):
"""Partant d'un dictionnaire ``{nom_ue: compétence}`` associant des
accronymes d'UEs à des compétences, renvoie l'association d'une compétence
à ou aux UEs qui l'adresse : ``{competence: [liste_nom_ue]}``
Args:
accro_ues_to_competences: Dictionnaire ``{nom_ue: compétence}``
Return:
Le dictionnaire ``{competence: [liste_nom_ue]}``
"""
asso = {}
for accro, comp in accro_ues_to_competences.items():
if comp not in asso:
asso[comp] = []
asso[comp].append(accro)
return asso

View File

@ -5,7 +5,7 @@
#
# Gestion scolarite IUT
#
# Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved.
# Copyright (c) 1999 - 2024 Emmanuel Viennet. c 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
@ -37,8 +37,10 @@ Created on 17/01/2024
"""
import pandas as pd
from app import ScoValueError
from app.models import FormSemestre, Identite, Formation
from app.pe import pe_comp, pe_affichage
from app.pe.rcss import pe_rcs
from app.scodoc import codes_cursus
from app.scodoc import sco_utils as scu
from app.comp.res_sem import load_formsemestre_results
@ -55,16 +57,20 @@ class EtudiantsJuryPE:
self.annee_diplome = annee_diplome
"""L'année du diplôme"""
self.identites: dict[int, Identite] = {} # ex. ETUDINFO_DICT
"Les identités des étudiants traités pour le jury"
self.identites: dict[int:Identite] = {} # ex. ETUDINFO_DICT
"""Les identités des étudiants traités pour le jury"""
self.cursus: dict[int, dict] = {}
"Les cursus (semestres suivis, abandons) des étudiants"
self.cursus: dict[int:dict] = {}
"""Les cursus (semestres suivis, abandons) des étudiants"""
self.trajectoires = {}
"""Les trajectoires/chemins de semestres suivis par les étudiants
pour atteindre un aggrégat donné
(par ex: 3S=S1+S2+S3 à prendre en compte avec d'éventuels redoublements)"""
self.trajectoires: dict[int:dict] = {}
"""Les trajectoires (regroupement cohérents de semestres) suivis par les étudiants"""
self.semXs: dict[int:dict] = {}
"""Les semXs (RCS de type Sx) suivis par chaque étudiant"""
self.rcsemXs: dict[int:dict] = {}
"""Les RC de SemXs (RCS de type Sx, xA, xS) suivis par chaque étudiant"""
self.etudiants_diplomes = {}
"""Les identités des étudiants à considérer au jury (ceux qui seront effectivement
@ -99,27 +105,26 @@ class EtudiantsJuryPE:
self.cosemestres = cosemestres
pe_affichage.pe_print(
f"1) Recherche des coSemestres -> {len(cosemestres)} trouvés"
f"1) Recherche des cosemestres -> {len(cosemestres)} trouvés", info=True
)
pe_affichage.pe_print("2) Liste des étudiants dans les différents co-semestres")
self.etudiants_ids = get_etudiants_dans_semestres(cosemestres)
pe_affichage.pe_print(
f" => {len(self.etudiants_ids)} étudiants trouvés dans les cosemestres"
"2) Liste des étudiants dans les différents cosemestres", info=True
)
etudiants_ids = get_etudiants_dans_semestres(cosemestres)
pe_affichage.pe_print(
f" => {len(etudiants_ids)} étudiants trouvés dans les cosemestres",
info=True,
)
# Analyse des parcours étudiants pour déterminer leur année effective de diplome
# avec prise en compte des redoublements, des abandons, ....
pe_affichage.pe_print("3) Analyse des parcours individuels des étudiants")
pe_affichage.pe_print(
"3) Analyse des parcours individuels des étudiants", info=True
)
for etudid in self.etudiants_ids:
self.identites[etudid] = Identite.get_etud(etudid)
# Analyse son cursus
self.analyse_etat_etudiant(etudid, cosemestres)
# Analyse son parcours pour atteindre chaque semestre de la formation
self.structure_cursus_etudiant(etudid)
# Ajoute une liste d'étudiants
self.add_etudiants(etudiants_ids)
# Les étudiants à prendre dans le diplôme, étudiants ayant abandonnés non compris
self.etudiants_diplomes = self.get_etudiants_diplomes()
@ -134,23 +139,35 @@ class EtudiantsJuryPE:
# Les identifiants des étudiants ayant redoublés ou ayant abandonnés
# Synthèse
pe_affichage.pe_print(f"4) Bilan", info=True)
pe_affichage.pe_print(
f" => {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}"
f"--> {len(self.etudiants_diplomes)} étudiants à diplômer en {self.annee_diplome}",
info=True,
)
nbre_abandons = len(self.etudiants_ids) - len(self.etudiants_diplomes)
assert nbre_abandons == len(self.abandons_ids)
pe_affichage.pe_print(
f" => {nbre_abandons} étudiants non considérés (redoublement, réorientation, abandon"
f"--> {nbre_abandons} étudiants traités mais non diplômés (redoublement, réorientation, abandon)"
)
# pe_affichage.pe_print(
# " => quelques étudiants futurs diplômés : "
# + ", ".join([str(etudid) for etudid in list(self.etudiants_diplomes)[:10]])
# )
# pe_affichage.pe_print(
# " => semestres dont il faut calculer les moyennes : "
# + ", ".join([str(fid) for fid in list(self.formsemestres_jury_ids)])
# )
def add_etudiants(self, etudiants_ids):
"""Ajoute une liste d'étudiants aux données du jury"""
nbre_etudiants_ajoutes = 0
for etudid in etudiants_ids:
if etudid not in self.identites:
nbre_etudiants_ajoutes += 1
# L'identité de l'étudiant
self.identites[etudid] = Identite.get_etud(etudid)
# Analyse son cursus
self.analyse_etat_etudiant(etudid, self.cosemestres)
# Analyse son parcours pour atteindre chaque semestre de la formation
self.structure_cursus_etudiant(etudid)
self.etudiants_ids = set(self.identites.keys())
return nbre_etudiants_ajoutes
def get_etudiants_diplomes(self) -> dict[int, Identite]:
"""Identités des étudiants (sous forme d'un dictionnaire `{etudid: Identite(etudid)}`
@ -198,8 +215,11 @@ class EtudiantsJuryPE:
* à insérer une entrée dans ``self.cursus`` pour mémoriser son identité,
avec son nom, prénom, etc...
* à analyser son parcours, pour déterminer s'il n'a (ou non) abandonné l'IUT en cours de
route (cf. clé abandon)
* à analyser son parcours, pour déterminer s'il a démissionné, redoublé (autre année de diplôme)
ou a abandonné l'IUT en cours de route (cf. clé abandon). Un étudiant est considéré
en abandon si connaissant son dernier semestre (par ex. un S3) il n'est pas systématiquement
inscrit à l'un des S4, S5 ou S6 existants dans les cosemestres.
Args:
etudid: L'etudid d'un étudiant, à ajouter à ceux traiter par le jury
@ -217,11 +237,19 @@ class EtudiantsJuryPE:
if formsemestre.formation.is_apc()
}
# Le parcours final
parcour = formsemestres[0].etuds_inscriptions[etudid].parcour
if parcour:
libelle = parcour.libelle
else:
libelle = None
self.cursus[etudid] = {
"etudid": etudid, # les infos sur l'étudiant
"etat_civil": identite.etat_civil, # Ajout à la table jury
"nom": identite.nom,
"entree": formsemestres[-1].date_debut.year, # La date d'entrée à l'IUT
"parcours": libelle, # Le parcours final
"diplome": get_annee_diplome(
identite
), # Le date prévisionnelle de son diplôme
@ -232,35 +260,24 @@ class EtudiantsJuryPE:
"abandon": False, # va être traité en dessous
}
# Est-il démissionnaire : charge son dernier semestre pour connaitre son état ?
dernier_semes_etudiant = formsemestres[0]
res = load_formsemestre_results(dernier_semes_etudiant)
etud_etat = res.get_etud_etat(etudid)
if etud_etat == scu.DEMISSION:
self.cursus[etudid]["abandon"] |= True
else:
# Est-il réorienté ou a-t-il arrêté volontairement sa formation ?
self.cursus[etudid]["abandon"] |= arret_de_formation(identite, cosemestres)
# Si l'étudiant est succeptible d'être diplomé
if self.cursus[etudid]["diplome"] == self.annee_diplome:
# Est-il démissionnaire : charge son dernier semestre pour connaitre son état ?
dernier_semes_etudiant = formsemestres[0]
res = load_formsemestre_results(dernier_semes_etudiant)
etud_etat = res.get_etud_etat(etudid)
if etud_etat == scu.DEMISSION:
self.cursus[etudid]["abandon"] = True
else:
# Est-il réorienté ou a-t-il arrêté (volontairement) sa formation ?
self.cursus[etudid]["abandon"] = arret_de_formation(
identite, cosemestres
)
def get_semestres_significatifs(self, etudid: int):
"""Ensemble des semestres d'un étudiant, qui l'auraient amené à être diplomé
l'année visée (supprime les semestres qui conduisent à une diplomation
postérieure à celle du jury visé)
Args:
etudid: L'identifiant d'un étudiant
Returns:
Un dictionnaire ``{fid: FormSemestre(fid)`` dans lequel les semestres
amènent à une diplomation avant l'annee de diplomation du jury
"""
semestres_etudiant = self.cursus[etudid]["formsemestres"]
semestres_significatifs = {}
for fid in semestres_etudiant:
semestre = semestres_etudiant[fid]
if pe_comp.get_annee_diplome_semestre(semestre) <= self.annee_diplome:
semestres_significatifs[fid] = semestre
return semestres_significatifs
# Initialise ses trajectoires/SemX/RCSemX
self.trajectoires[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS}
self.semXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_SEMESTRES}
self.rcsemXs[etudid] = {aggregat: None for aggregat in pe_rcs.TOUS_LES_RCS}
def structure_cursus_etudiant(self, etudid: int):
"""Structure les informations sur les semestres suivis par un
@ -269,9 +286,11 @@ class EtudiantsJuryPE:
Cette structuration s'appuie sur les numéros de semestre: pour chaque Si, stocke :
le dernier semestre (en date) de numéro i qu'il a suivi (1 ou 0 si pas encore suivi).
Ce semestre influera les interclassement par semestre dans la promo.
Ce semestre influera les interclassements par semestre dans la promo.
"""
semestres_significatifs = self.get_semestres_significatifs(etudid)
semestres_significatifs = get_semestres_significatifs(
self.cursus[etudid]["formsemestres"], self.annee_diplome
)
# Tri des semestres par numéro de semestre
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
@ -283,12 +302,10 @@ class EtudiantsJuryPE:
}
self.cursus[etudid][f"S{i}"] = semestres_i
def get_formsemestres_terminaux_aggregat(
self, aggregat: str
) -> dict[int, FormSemestre]:
"""Pour un aggrégat donné, ensemble des formsemestres terminaux possibles pour l'aggrégat
(pour l'aggrégat '3S' incluant S1+S2+S3, a pour semestre terminal S3).
Ces formsemestres traduisent :
def get_formsemestres_finals_des_rcs(self, nom_rcs: str) -> dict[int, FormSemestre]:
"""Pour un nom de RCS donné, ensemble des formsemestres finals possibles
pour les RCS. Par ex. un RCS '3S' incluant S1+S2+S3 a pour semestre final un S3.
Les formsemestres finals obtenus traduisent :
* les différents parcours des étudiants liés par exemple au choix de modalité
(par ex: S1 FI + S2 FI + S3 FI ou S1 FI + S2 FI + S3 UFA), en renvoyant les
@ -299,14 +316,14 @@ class EtudiantsJuryPE:
renvoyant les formsemestre_id du S3 (1ère session) et du S3 (2ème session)
Args:
aggregat: L'aggrégat
nom_rcs: Le nom du RCS (parmi Sx, xA, xS)
Returns:
Un dictionnaire ``{fid: FormSemestre(fid)}``
"""
formsemestres_terminaux = {}
for trajectoire_aggr in self.trajectoires.values():
trajectoire = trajectoire_aggr[aggregat]
for trajectoire_aggr in self.cursus.values():
trajectoire = trajectoire_aggr[nom_rcs]
if trajectoire:
# Le semestre terminal de l'étudiant de l'aggrégat
fid = trajectoire.formsemestre_final.formsemestre_id
@ -345,7 +362,9 @@ class EtudiantsJuryPE:
etudiant = self.identites[etudid]
cursus = self.cursus[etudid]
formsemestres = cursus["formsemestres"]
parcours = cursus["parcours"]
if not parcours:
parcours = ""
if cursus["diplome"]:
diplome = cursus["diplome"]
else:
@ -359,6 +378,7 @@ class EtudiantsJuryPE:
"Prenom": etudiant.prenom,
"Civilite": etudiant.civilite_str,
"Age": pe_comp.calcul_age(etudiant.date_naissance),
"Parcours": parcours,
"Date entree": cursus["entree"],
"Date diplome": diplome,
"Nb semestres": len(formsemestres),
@ -376,13 +396,35 @@ class EtudiantsJuryPE:
return df
def get_semestres_significatifs(formsemestres, annee_diplome):
"""Partant d'un ensemble de semestre, renvoie les semestres qui amèneraient les étudiants
à être diplômé à l'année visée, y compris s'ils n'avaient pas redoublé et seraient donc
diplômé plus tard.
De fait, supprime les semestres qui conduisent à une diplomation postérieure
à celle visée.
Args:
formsemestres: une liste de formsemestres
annee_diplome: l'année du diplôme visée
Returns:
Un dictionnaire ``{fid: FormSemestre(fid)}`` dans lequel les semestres
amènent à une diplômation antérieur à celle de la diplômation visée par le jury
"""
# semestres_etudiant = self.cursus[etudid]["formsemestres"]
semestres_significatifs = {}
for fid in formsemestres:
semestre = formsemestres[fid]
if pe_comp.get_annee_diplome_semestre(semestre) <= annee_diplome:
semestres_significatifs[fid] = semestre
return semestres_significatifs
def get_etudiants_dans_semestres(semestres: dict[int, FormSemestre]) -> set:
"""Ensemble d'identifiants des étudiants (identifiés via leur ``etudid``)
inscrits à l'un des semestres de la liste de ``semestres``.
Remarque : Les ``cosemestres`` sont généralement obtenus avec
``sco_formsemestre.do_formsemestre_list()``
Args:
semestres: Un dictionnaire ``{fid: Formsemestre(fid)}`` donnant un
ensemble d'identifiant de semestres
@ -430,7 +472,7 @@ def get_annee_diplome(etud: Identite) -> int | None:
def get_semestres_apc(identite: Identite) -> list:
"""Liste des semestres d'un étudiant qui corresponde à une formation APC.
"""Liste des semestres d'un étudiant qui correspondent à une formation APC.
Args:
identite: L'identité d'un étudiant
@ -446,8 +488,8 @@ def get_semestres_apc(identite: Identite) -> list:
return semestres_apc
def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool:
"""Détermine si un étudiant a arrêté sa formation. Il peut s'agir :
def arret_de_formation(etud: Identite, cosemestres: dict[int, FormSemestre]) -> bool:
"""Détermine si un étudiant a arrêté sa formation (volontairement ou non). Il peut s'agir :
* d'une réorientation à l'initiative du jury de semestre ou d'une démission
(on pourrait utiliser les code NAR pour réorienté & DEM pour démissionnaire
@ -458,7 +500,8 @@ def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool:
Dans les cas, on considérera que l'étudiant a arrêté sa formation s'il n'est pas
dans l'un des "derniers" cosemestres (semestres conduisant à la même année de diplômation)
connu dans Scodoc.
connu dans Scodoc. Par "derniers" cosemestres, est fait le choix d'analyser tous les cosemestres
de rang/semestre_id supérieur (et donc de dates) au dernier semestre dans lequel il a été inscrit.
Par ex: au moment du jury PE en fin de S5 (pas de S6 renseigné dans Scodoc),
l'étudiant doit appartenir à une instance des S5 qui conduisent à la diplomation dans
@ -485,7 +528,6 @@ def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool:
Est-il réorienté, démissionnaire ou a-t-il arrêté de son propre chef sa formation ?
TODO:: A reprendre pour le cas des étudiants à l'étranger
TODO:: A reprendre si BUT avec semestres décalés
"""
# Les semestres APC de l'étudiant
semestres = get_semestres_apc(etud)
@ -493,61 +535,54 @@ def arret_de_formation(etud: Identite, cosemestres: list[FormSemestre]) -> bool:
if not semestres_apc:
return True
# Son dernier semestre APC en date
dernier_formsemestre = get_dernier_semestre_en_date(semestres_apc)
numero_dernier_formsemestre = dernier_formsemestre.semestre_id
# Le dernier semestre de l'étudiant
dernier_formsemestre = semestres[0]
rang_dernier_semestre = dernier_formsemestre.semestre_id
# Les numéro de semestres possible dans lesquels il pourrait s'incrire
# semestre impair => passage de droit en semestre pair suivant (effet de l'annualisation)
if numero_dernier_formsemestre % 2 == 1:
numeros_possibles = list(
range(numero_dernier_formsemestre + 1, pe_comp.NBRE_SEMESTRES_DIPLOMANT)
)
# semestre pair => passage en année supérieure ou redoublement
else: #
numeros_possibles = list(
range(
max(numero_dernier_formsemestre - 1, 1),
pe_comp.NBRE_SEMESTRES_DIPLOMANT,
)
# Les cosemestres de rang supérieur ou égal à celui de formsemestre, triés par rang,
# sous la forme ``{semestre_id: [liste des comestres associé à ce semestre_id]}``
cosemestres_tries_par_rang = pe_comp.tri_semestres_par_rang(cosemestres)
cosemestres_superieurs = {}
for rang in cosemestres_tries_par_rang:
if rang > rang_dernier_semestre:
cosemestres_superieurs[rang] = cosemestres_tries_par_rang[rang]
# Si pas d'autres cosemestres postérieurs
if not cosemestres_superieurs:
return False
# Pour chaque rang de (co)semestres, y-a-il un dans lequel il est inscrit ?
etat_inscriptions = {rang: False for rang in cosemestres_superieurs}
for rang in etat_inscriptions:
for sem in cosemestres_superieurs[rang]:
etudiants_du_sem = {ins.etudid for ins in sem.inscriptions}
if etud.etudid in etudiants_du_sem:
etat_inscriptions[rang] = True
# Vérifie qu'il n'y a pas de "trous" dans les rangs des cosemestres
rangs = sorted(etat_inscriptions.keys())
if list(rangs) != list(range(min(rangs), max(rangs) + 1)):
difference = set(range(min(rangs), max(rangs) + 1)) - set(rangs)
affichage = ",".join([f"S{val}" for val in difference])
raise ScoValueError(
f"Il manque le(s) semestre(s) {affichage} au cursus de {etud.etat_civil} ({etud.etudid})."
)
# Y-a-t-il des cosemestres dans lesquels il aurait pu s'incrire ?
formsestres_superieurs_possibles = []
for fid, sem in cosemestres.items(): # Les semestres ayant des inscrits
if (
fid != dernier_formsemestre.formsemestre_id
and sem.semestre_id in numeros_possibles
and sem.date_debut.year >= dernier_formsemestre.date_debut.year
):
# date de debut des semestres possibles postérieur au dernier semestre de l'étudiant
# et de niveau plus élevé que le dernier semestre valide de l'étudiant
formsestres_superieurs_possibles.append(fid)
# Est-il inscrit à tous les semestres de rang supérieur ? Si non, est démissionnaire
est_demissionnaire = sum(etat_inscriptions.values()) != len(rangs)
if est_demissionnaire:
non_inscrit_a = [
rang for rang in etat_inscriptions if not etat_inscriptions[rang]
]
affichage = ", ".join([f"S{val}" for val in non_inscrit_a])
pe_affichage.pe_print(
f"--> ⛔ {etud.etat_civil} ({etud.etudid}), non inscrit dans {affichage} amenant à diplômation"
)
else:
pe_affichage.pe_print(f"--> ✅ {etud.etat_civil} ({etud.etudid})")
if len(formsestres_superieurs_possibles) > 0:
return True
return False
def get_dernier_semestre_en_date(semestres: dict[int, FormSemestre]) -> FormSemestre:
"""Renvoie le dernier semestre en **date de fin** d'un dictionnaire
de semestres (potentiellement non trié) de la forme ``{fid: FormSemestre(fid)}``.
Args:
semestres: Un dictionnaire de semestres
Return:
Le FormSemestre du semestre le plus récent
"""
if semestres:
fid_dernier_semestre = list(semestres.keys())[0]
dernier_semestre: FormSemestre = semestres[fid_dernier_semestre]
for fid in semestres:
if semestres[fid].date_fin > dernier_semestre.date_fin:
dernier_semestre = semestres[fid]
return dernier_semestre
return None
return est_demissionnaire
def etapes_du_cursus(
@ -613,6 +648,6 @@ def nom_semestre_etape(semestre: FormSemestre, avec_fid=False) -> str:
f"{semestre.date_debut.year}-{semestre.date_fin.year}",
]
if avec_fid:
description.append(f"({semestre.formsemestre_id})")
description.append(f"(#{semestre.formsemestre_id})")
return " ".join(description)

View File

@ -1,160 +0,0 @@
##############################################################################
#
# 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Thu Sep 8 09:36:33 2016
@author: barasc
"""
import pandas as pd
import numpy as np
from app.pe.pe_tabletags import TableTag, MoyenneTag
from app.pe.pe_etudiant import EtudiantsJuryPE
from app.pe.pe_rcs import RCS, RCSsJuryPE
from app.pe.pe_rcstag import RCSTag
class RCSInterclasseTag(TableTag):
"""
Interclasse l'ensemble des étudiants diplômés à une année
donnée (celle du jury), pour un RCS donné (par ex: 'S2', '3S')
en reportant :
* les moyennes obtenues sur la trajectoire qu'il ont suivi pour atteindre
le numéro de semestre de fin de l'aggrégat (indépendamment de son
formsemestre)
* calculant le classement sur les étudiants diplômes
"""
def __init__(
self,
nom_rcs: str,
etudiants: EtudiantsJuryPE,
rcss_jury_pe: RCSsJuryPE,
rcss_tags: dict[tuple, RCSTag],
):
TableTag.__init__(self)
self.nom_rcs = nom_rcs
"""Le nom du RCS interclassé"""
self.nom = self.get_repr()
"""Les étudiants diplômés et leurs rcss""" # TODO
self.diplomes_ids = etudiants.etudiants_diplomes
self.etudiants_diplomes = {etudid for etudid in self.diplomes_ids}
# pour les exports sous forme de dataFrame
self.etudiants = {
etudid: etudiants.identites[etudid].etat_civil
for etudid in self.diplomes_ids
}
# Les trajectoires (et leur version tagguées), en ne gardant que
# celles associées à l'aggrégat
self.rcss: dict[int, RCS] = {}
"""Ensemble des trajectoires associées à l'aggrégat"""
for trajectoire_id in rcss_jury_pe.rcss:
trajectoire = rcss_jury_pe.rcss[trajectoire_id]
if trajectoire_id[0] == nom_rcs:
self.rcss[trajectoire_id] = trajectoire
self.trajectoires_taggues: dict[int, RCS] = {}
"""Ensemble des trajectoires tagguées associées à l'aggrégat"""
for trajectoire_id in self.rcss:
self.trajectoires_taggues[trajectoire_id] = rcss_tags[trajectoire_id]
# Les trajectoires suivies par les étudiants du jury, en ne gardant que
# celles associées aux diplomés
self.suivi: dict[int, RCS] = {}
"""Association entre chaque étudiant et la trajectoire tagguée à prendre en
compte pour l'aggrégat"""
for etudid in self.diplomes_ids:
self.suivi[etudid] = rcss_jury_pe.suivi[etudid][nom_rcs]
self.tags_sorted = self.do_taglist()
"""Liste des tags (triés par ordre alphabétique)"""
# Construit la matrice de notes
self.notes = self.compute_notes_matrice()
"""Matrice des notes de l'aggrégat"""
# Synthétise les moyennes/classements par tag
self.moyennes_tags: dict[str, MoyenneTag] = {}
for tag in self.tags_sorted:
moy_gen_tag = self.notes[tag]
self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag)
# Est significatif ? (aka a-t-il des tags et des notes)
self.significatif = len(self.tags_sorted) > 0
def get_repr(self) -> str:
"""Une représentation textuelle"""
return f"Aggrégat {self.nom_rcs}"
def do_taglist(self):
"""Synthétise les tags à partir des trajectoires_tagguées
Returns:
Une liste de tags triés par ordre alphabétique
"""
tags = []
for trajectoire in self.trajectoires_taggues.values():
tags.extend(trajectoire.tags_sorted)
return sorted(set(tags))
def compute_notes_matrice(self):
"""Construit la matrice de notes (etudid x tags)
retraçant les moyennes obtenues par les étudiants dans les semestres associés à
l'aggrégat (une trajectoire ayant pour numéro de semestre final, celui de l'aggrégat).
"""
# nb_tags = len(self.tags_sorted) unused ?
# nb_etudiants = len(self.diplomes_ids)
# Index de la matrice (etudids -> dim 0, tags -> dim 1)
etudids = list(self.diplomes_ids)
tags = self.tags_sorted
# Partant d'un dataframe vierge
df = pd.DataFrame(np.nan, index=etudids, columns=tags)
for trajectoire in self.trajectoires_taggues.values():
# Charge les moyennes par tag de la trajectoire tagguée
notes = trajectoire.notes
# Etudiants/Tags communs entre la trajectoire_tagguée et les données interclassées
etudids_communs = df.index.intersection(notes.index)
tags_communs = df.columns.intersection(notes.columns)
# Injecte les notes par tag
df.loc[etudids_communs, tags_communs] = notes.loc[
etudids_communs, tags_communs
]
return df

File diff suppressed because it is too large Load Diff

View File

@ -1,269 +0,0 @@
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on 01-2024
@author: barasc
"""
import app.pe.pe_comp as pe_comp
from app.models import FormSemestre
from app.pe.pe_etudiant import EtudiantsJuryPE, get_dernier_semestre_en_date
TYPES_RCS = {
"S1": {
"aggregat": ["S1"],
"descr": "Semestre 1 (S1)",
},
"S2": {
"aggregat": ["S2"],
"descr": "Semestre 2 (S2)",
},
"1A": {
"aggregat": ["S1", "S2"],
"descr": "BUT1 (S1+S2)",
},
"S3": {
"aggregat": ["S3"],
"descr": "Semestre 3 (S3)",
},
"S4": {
"aggregat": ["S4"],
"descr": "Semestre 4 (S4)",
},
"2A": {
"aggregat": ["S3", "S4"],
"descr": "BUT2 (S3+S4)",
},
"3S": {
"aggregat": ["S1", "S2", "S3"],
"descr": "Moyenne du semestre 1 au semestre 3 (S1+S2+S3)",
},
"4S": {
"aggregat": ["S1", "S2", "S3", "S4"],
"descr": "Moyenne du semestre 1 au semestre 4 (S1+S2+S3+S4)",
},
"S5": {
"aggregat": ["S5"],
"descr": "Semestre 5 (S5)",
},
"S6": {
"aggregat": ["S6"],
"descr": "Semestre 6 (S6)",
},
"3A": {
"aggregat": ["S5", "S6"],
"descr": "3ème année (S5+S6)",
},
"5S": {
"aggregat": ["S1", "S2", "S3", "S4", "S5"],
"descr": "Moyenne du semestre 1 au semestre 5 (S1+S2+S3+S4+S5)",
},
"6S": {
"aggregat": ["S1", "S2", "S3", "S4", "S5", "S6"],
"descr": "Moyenne globale (S1+S2+S3+S4+S5+S6)",
},
}
"""Dictionnaire détaillant les différents regroupements cohérents
de semestres (RCS), en leur attribuant un nom et en détaillant
le nom des semestres qu'ils regroupent et l'affichage qui en sera fait
dans les tableurs de synthèse.
"""
TOUS_LES_RCS_AVEC_PLUSIEURS_SEM = [cle for cle in TYPES_RCS if not cle.startswith("S")]
TOUS_LES_RCS = list(TYPES_RCS.keys())
TOUS_LES_SEMESTRES = [cle for cle in TYPES_RCS if cle.startswith("S")]
class RCS:
"""Modélise un ensemble de semestres d'étudiants
associé à un type de regroupement cohérent de semestres
donné (par ex: 'S2', '3S', '2A').
Si le RCS est un semestre de type Si, stocke le (ou les)
formsemestres de numéro i qu'ont suivi l'étudiant pour atteindre le Si
(en général 1 si personnes n'a redoublé, mais 2 s'il y a des redoublants)
Pour le RCS de type iS ou iA (par ex, 3A=S1+S2+S3), elle identifie
les semestres que les étudiants ont suivis pour les amener jusqu'au semestre
terminal de la trajectoire (par ex: ici un S3).
Ces semestres peuvent être :
* des S1+S2+S1+S2+S3 si redoublement de la 1ère année
* des S1+S2+(année de césure)+S3 si césure, ...
Args:
nom_rcs: Un nom du RCS (par ex: '5S')
semestre_final: Le semestre final du RCS
"""
def __init__(self, nom_rcs: str, semestre_final: FormSemestre):
self.nom = nom_rcs
"""Nom du RCS"""
self.formsemestre_final = semestre_final
"""FormSemestre terminal du RCS"""
self.rcs_id = (nom_rcs, semestre_final.formsemestre_id)
"""Identifiant du RCS sous forme (nom_rcs, id du semestre_terminal)"""
self.semestres_aggreges = {}
"""Semestres regroupés dans le RCS"""
def add_semestres_a_aggreger(self, semestres: dict[int:FormSemestre]):
"""Ajout de semestres aux semestres à regrouper
Args:
semestres: Dictionnaire ``{fid: FormSemestre(fid)}`` à ajouter
"""
self.semestres_aggreges = self.semestres_aggreges | semestres
def get_repr(self, verbose=True) -> str:
"""Représentation textuelle d'un RCS
basé sur ses semestres aggrégés"""
noms = []
for fid in self.semestres_aggreges:
semestre = self.semestres_aggreges[fid]
noms.append(f"S{semestre.semestre_id}({fid})")
noms = sorted(noms)
title = f"""{self.nom} ({
self.formsemestre_final.formsemestre_id}) {self.formsemestre_final.date_fin.year}"""
if verbose and noms:
title += " - " + "+".join(noms)
return title
class RCSsJuryPE:
"""Classe centralisant toutes les regroupements cohérents de
semestres (RCS) des étudiants à prendre en compte dans un jury PE
Args:
annee_diplome: L'année de diplomation
"""
def __init__(self, annee_diplome: int):
self.annee_diplome = annee_diplome
"""Année de diplômation"""
self.rcss: dict[tuple:RCS] = {}
"""Ensemble des RCS recensés : {(nom_RCS, fid_terminal): RCS}"""
self.suivi: dict[int:str] = {}
"""Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS,
son RCS : {etudid: {nom_RCS: RCS}}"""
def cree_rcss(self, etudiants: EtudiantsJuryPE):
"""Créé tous les RCS, au regard du cursus des étudiants
analysés + les mémorise dans les données de l'étudiant
Args:
etudiants: Les étudiants à prendre en compte dans le Jury PE
"""
for nom_rcs in pe_comp.TOUS_LES_SEMESTRES + TOUS_LES_RCS_AVEC_PLUSIEURS_SEM:
# L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre
# terminal (par ex: S3) et son numéro (par ex: 3)
noms_semestre_de_aggregat = TYPES_RCS[nom_rcs]["aggregat"]
nom_semestre_terminal = noms_semestre_de_aggregat[-1]
for etudid in etudiants.cursus:
if etudid not in self.suivi:
self.suivi[etudid] = {
aggregat: None
for aggregat in pe_comp.TOUS_LES_SEMESTRES
+ TOUS_LES_RCS_AVEC_PLUSIEURS_SEM
}
# Le formsemestre terminal (dernier en date) associé au
# semestre marquant la fin de l'aggrégat
# (par ex: son dernier S3 en date)
semestres = etudiants.cursus[etudid][nom_semestre_terminal]
if semestres:
formsemestre_final = get_dernier_semestre_en_date(semestres)
# Ajout ou récupération de la trajectoire
trajectoire_id = (nom_rcs, formsemestre_final.formsemestre_id)
if trajectoire_id not in self.rcss:
trajectoire = RCS(nom_rcs, formsemestre_final)
self.rcss[trajectoire_id] = trajectoire
else:
trajectoire = self.rcss[trajectoire_id]
# La liste des semestres de l'étudiant à prendre en compte
# pour cette trajectoire
semestres_a_aggreger = get_rcs_etudiant(
etudiants.cursus[etudid], formsemestre_final, nom_rcs
)
# Ajout des semestres à la trajectoire
trajectoire.add_semestres_a_aggreger(semestres_a_aggreger)
# Mémoire la trajectoire suivie par l'étudiant
self.suivi[etudid][nom_rcs] = trajectoire
def get_rcs_etudiant(
semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str
) -> dict[int, FormSemestre]:
"""Ensemble des semestres parcourus par un étudiant, connaissant
les semestres de son cursus,
dans le cadre du RCS visé et ayant pour semestre terminal `formsemestre_final`.
Si le RCS est de type "Si", limite les semestres à ceux de numéro i.
Par ex: si formsemestre_terminal est un S3 et nom_agrregat "S3", ne prend en compte que les
semestres 3.
Si le RCS est de type "iA" ou "iS" (incluant plusieurs numéros de semestres), prend en
compte les dit numéros de semestres.
Par ex: si formsemestre_terminal est un S3, ensemble des S1,
S2, S3 suivi pour l'amener au S3 (il peut y avoir plusieurs S1,
ou S2, ou S3 s'il a redoublé).
Les semestres parcourus sont antérieurs (en terme de date de fin)
au formsemestre_terminal.
Args:
cursus: Dictionnaire {fid: FormSemestre(fid)} donnant l'ensemble des semestres
dans lesquels l'étudiant a été inscrit
formsemestre_final: le semestre final visé
nom_rcs: Nom du RCS visé
"""
numero_semestre_terminal = formsemestre_final.semestre_id
# semestres_significatifs = self.get_semestres_significatifs(etudid)
semestres_significatifs = {}
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
semestres_significatifs = semestres_significatifs | semestres[f"S{i}"]
if nom_rcs.startswith("S"): # les semestres
numero_semestres_possibles = [numero_semestre_terminal]
elif nom_rcs.endswith("A"): # les années
numero_semestres_possibles = [
int(sem[-1]) for sem in TYPES_RCS[nom_rcs]["aggregat"]
]
assert numero_semestre_terminal in numero_semestres_possibles
else: # les xS = tous les semestres jusqu'à Sx (eg S1, S2, S3 pour un S3 terminal)
numero_semestres_possibles = list(range(1, numero_semestre_terminal + 1))
semestres_aggreges = {}
for fid, semestre in semestres_significatifs.items():
# Semestres parmi ceux de n° possibles & qui lui sont antérieurs
if (
semestre.semestre_id in numero_semestres_possibles
and semestre.date_fin <= formsemestre_final.date_fin
):
semestres_aggreges[fid] = semestre
return semestres_aggreges
def get_descr_rcs(nom_rcs: str) -> str:
"""Renvoie la description pour les tableurs de synthèse
Excel d'un nom de RCS"""
return TYPES_RCS[nom_rcs]["descr"]

289
app/pe/pe_rcss_jury.py Normal file
View File

@ -0,0 +1,289 @@
import app.pe.pe_comp
from app.pe.rcss import pe_rcs, pe_trajectoires, pe_rcsemx
import app.pe.pe_etudiant as pe_etudiant
import app.pe.pe_comp as pe_comp
from app.models import FormSemestre
from app.pe import pe_affichage
class RCSsJuryPE:
"""Classe centralisant tous les regroupements cohérents de
semestres (RCS) des étudiants à prendre en compte dans un jury PE
Args:
annee_diplome: L'année de diplomation
"""
def __init__(self, annee_diplome: int, etudiants: pe_etudiant.EtudiantsJuryPE):
self.annee_diplome = annee_diplome
"""Année de diplômation"""
self.etudiants = etudiants
"""Les étudiants recensés"""
self.trajectoires: dict[tuple(int, str) : pe_trajectoires.Trajectoire] = {}
"""Ensemble des trajectoires recensées (regroupement de (form)semestres BUT)"""
self.trajectoires_suivies: dict[int:dict] = {}
"""Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS,
sa Trajectoire : {etudid: {nom_RCS: Trajectoire}}"""
self.semXs: dict[tuple(int, str) : pe_trajectoires.SemX] = {}
"""Ensemble des SemX recensés (regroupement de (form)semestre BUT de rang x) :
{(nom_RCS, fid_terminal): SemX}"""
self.semXs_suivis: dict[int:dict] = {}
"""Dictionnaire associant, pour chaque étudiant et pour chaque RCS de type Sx,
son SemX : {etudid: {nom_RCS_de_type_Sx: SemX}}"""
self.rcsemxs: dict[tuple(int, str) : pe_rcsemx.RCSemX] = {}
"""Ensemble des RCSemX (regroupement de SemX donnant les résultats aux sems de rang x)
recensés : {(nom_RCS, fid_terminal): RCSemX}"""
self.rcsemxs_suivis: dict[int:str] = {}
"""Dictionnaire associant, pour chaque étudiant et pour chaque type de RCS,
son RCSemX : {etudid: {nom_RCS: RCSemX}}"""
def cree_trajectoires(self):
"""Créé toutes les trajectoires, au regard du cursus des étudiants
analysés + les mémorise dans les données de l'étudiant
Args:
etudiants: Les étudiants à prendre en compte dans le Jury PE
"""
tous_les_aggregats = pe_rcs.TOUS_LES_RCS
for etudid in self.etudiants.cursus:
self.trajectoires_suivies[etudid] = self.etudiants.trajectoires[etudid]
for nom_rcs in tous_les_aggregats:
# L'aggrégat considéré (par ex: 3S=S1+S2+S3), son nom de son semestre
# terminal (par ex: S3) et son numéro (par ex: 3)
noms_semestres = pe_rcs.TYPES_RCS[nom_rcs]["aggregat"]
nom_semestre_final = noms_semestres[-1]
for etudid in self.etudiants.cursus:
# Le (ou les) semestre(s) marquant la fin du cursus de l'étudiant
sems_final = self.etudiants.cursus[etudid][nom_semestre_final]
if sems_final:
# Le formsemestre final (dernier en date) de l'étudiant,
# marquant la fin de son aggrégat (par ex: son dernier S3 en date)
formsemestre_final = app.pe.pe_comp.get_dernier_semestre_en_date(
sems_final
)
# Ajout (si nécessaire) et récupération du RCS associé
rcs_id = (nom_rcs, formsemestre_final.formsemestre_id)
if rcs_id not in self.trajectoires:
self.trajectoires[rcs_id] = pe_trajectoires.Trajectoire(
nom_rcs, formsemestre_final
)
rcs = self.trajectoires[rcs_id]
# La liste des semestres de l'étudiant à prendre en compte
# pour cette trajectoire
semestres_a_aggreger = get_rcs_etudiant(
self.etudiants.cursus[etudid], formsemestre_final, nom_rcs
)
# Ajout des semestres au RCS
rcs.add_semestres(semestres_a_aggreger)
# Mémorise le RCS suivi par l'étudiant
self.trajectoires_suivies[etudid][nom_rcs] = rcs
self.etudiants.trajectoires[etudid][nom_rcs] = rcs
def cree_semxs(self):
"""Créé les SemXs (trajectoires/combinaisons de semestre de même rang x),
en ne conservant dans les trajectoires que les regroupements
de type Sx"""
self.semXs = {}
for rcs_id, trajectoire in self.trajectoires.items():
if trajectoire.nom in pe_rcs.TOUS_LES_SEMESTRES:
self.semXs[rcs_id] = pe_trajectoires.SemX(trajectoire)
# L'association (pour chaque étudiant entre chaque Sx et le SemX associé)
self.semXs_suivis = {}
for etudid in self.etudiants.trajectoires:
self.semXs_suivis[etudid] = {
agregat: None for agregat in pe_rcs.TOUS_LES_SEMESTRES
}
for agregat in pe_rcs.TOUS_LES_SEMESTRES:
trajectoire = self.etudiants.trajectoires[etudid][agregat]
if trajectoire:
rcs_id = trajectoire.rcs_id
semX = self.semXs[rcs_id]
self.semXs_suivis[etudid][agregat] = semX
self.etudiants.semXs[etudid][agregat] = semX
def cree_rcsemxs(self, options={"moyennes_ues_rcues": True}):
"""Créé tous les RCSemXs, au regard du cursus des étudiants
analysés (trajectoires traduisant son parcours dans les
différents semestres) + les mémorise dans les données de l'étudiant
"""
self.rcsemxs_suivis = {}
self.rcsemxs = {}
if "moyennes_ues_rcues" in options and options["moyennes_ues_rcues"] == False:
# Pas de RCSemX généré
pe_affichage.pe_print("⚠️ Pas de RCSemX générés")
return
# Pour tous les étudiants du jury
pas_de_semestres = []
for etudid in self.trajectoires_suivies:
self.rcsemxs_suivis[etudid] = {
nom_rcs: None for nom_rcs in pe_rcs.TOUS_LES_RCS_AVEC_PLUSIEURS_SEM
}
# Pour chaque aggréggat de type xA ou Sx ou xS
for agregat in pe_rcs.TOUS_LES_RCS:
trajectoire = self.trajectoires_suivies[etudid][agregat]
if not trajectoire:
self.rcsemxs_suivis[etudid][agregat] = None
else:
# Identifiant de la trajectoire => donnera ceux du RCSemX
tid = trajectoire.rcs_id
# Ajout du RCSemX
if tid not in self.rcsemxs:
self.rcsemxs[tid] = pe_rcsemx.RCSemX(
trajectoire.nom, trajectoire.formsemestre_final
)
# Récupére les SemX (RC de type Sx) associés aux semestres de son cursus
# Par ex: dans S1+S2+S1+S2+S3 => les 2 S1 devient le SemX('S1'), les 2 S2 le SemX('S2'), etc..
# Les Sx pris en compte dans l'aggrégat
noms_sems_aggregat = pe_rcs.TYPES_RCS[agregat]["aggregat"]
semxs_a_aggreger = {}
for Sx in noms_sems_aggregat:
semestres_etudiants = self.etudiants.cursus[etudid][Sx]
if not semestres_etudiants:
pas_de_semestres += [
f"{Sx} pour {self.etudiants.identites[etudid].nomprenom}"
]
else:
semx_id = get_semx_from_semestres_aggreges(
self.semXs, semestres_etudiants
)
if not semx_id:
raise (
"Il manque un SemX pour créer les RCSemX dans cree_rcsemxs"
)
# Les SemX à ajouter au RCSemX
semxs_a_aggreger[semx_id] = self.semXs[semx_id]
# Ajout des SemX à ceux à aggréger dans le RCSemX
rcsemx = self.rcsemxs[tid]
rcsemx.add_semXs(semxs_a_aggreger)
# Mémoire du RCSemX aux informations de suivi de l'étudiant
self.rcsemxs_suivis[etudid][agregat] = rcsemx
self.etudiants.rcsemXs[etudid][agregat] = rcsemx
# Affichage des étudiants pour lesquels il manque un semestre
pas_de_semestres = sorted(set(pas_de_semestres))
if pas_de_semestres:
pe_affichage.pe_print("⚠️ Semestres manquants :")
pe_affichage.pe_print(
"\n".join([" " * 10 + psd for psd in pas_de_semestres])
)
def get_rcs_etudiant(
semestres: dict[int:FormSemestre], formsemestre_final: FormSemestre, nom_rcs: str
) -> dict[int, FormSemestre]:
"""Ensemble des semestres parcourus (trajectoire)
par un étudiant dans le cadre
d'un RCS de type Sx, iA ou iS et ayant pour semestre terminal `formsemestre_final`.
Par ex: pour un RCS "3S", dont le formsemestre_terminal est un S3, regroupe
le ou les S1 qu'il a suivi (1 ou 2 si redoublement) + le ou les S2 + le ou les S3.
Les semestres parcourus sont antérieurs (en terme de date de fin)
au formsemestre_terminal.
Args:
cursus: Dictionnaire {fid: Formsemestre} donnant l'ensemble des semestres
dans lesquels l'étudiant a été inscrit
formsemestre_final: le semestre final visé
nom_rcs: Nom du RCS visé
"""
numero_semestre_terminal = formsemestre_final.semestre_id
# semestres_significatifs = self.get_semestres_significatifs(etudid)
semestres_significatifs = {}
for i in range(1, pe_comp.NBRE_SEMESTRES_DIPLOMANT + 1):
semestres_significatifs = semestres_significatifs | semestres[f"S{i}"]
if nom_rcs.startswith("S"): # les semestres
numero_semestres_possibles = [numero_semestre_terminal]
elif nom_rcs.endswith("A"): # les années
numero_semestres_possibles = [
int(sem[-1]) for sem in pe_rcs.TYPES_RCS[nom_rcs]["aggregat"]
]
assert numero_semestre_terminal in numero_semestres_possibles
else: # les xS = tous les semestres jusqu'à Sx (eg S1, S2, S3 pour un S3 terminal)
numero_semestres_possibles = list(range(1, numero_semestre_terminal + 1))
semestres_aggreges = {}
for fid, semestre in semestres_significatifs.items():
# Semestres parmi ceux de n° possibles & qui lui sont antérieurs
if (
semestre.semestre_id in numero_semestres_possibles
and semestre.date_fin <= formsemestre_final.date_fin
):
semestres_aggreges[fid] = semestre
return semestres_aggreges
def get_semx_from_semestres_aggreges(
semXs: dict[(str, int) : pe_trajectoires.SemX],
semestres_a_aggreger: dict[(str, int):FormSemestre],
) -> (str, int):
"""Partant d'un dictionnaire de SemX (de la forme
``{ (nom_rcs, fid): SemX }, et connaissant une liste
de (form)semestres suivis, renvoie l'identifiant
(nom_rcs, fid) du SemX qui lui correspond.
Le SemX qui correspond est tel que :
* le semestre final du SemX correspond au dernier semestre en date des
semestres_a_aggreger
* le rang du SemX est le même que celui des semestres_aggreges
* les semestres_a_aggreger (plus large, car contenant plusieurs
parcours), matchent avec les semestres aggrégés
par le SemX
Returns:
rcf_id: L'identifiant du RCF trouvé
"""
assert semestres_a_aggreger, "Pas de semestres à aggréger"
rangs_a_aggreger = [sem.semestre_id for fid, sem in semestres_a_aggreger.items()]
assert (
len(set(rangs_a_aggreger)) == 1
), "Tous les sem à aggréger doivent être de même rang"
# Le dernier semestre des semestres à regrouper
dernier_sem_a_aggreger = pe_comp.get_dernier_semestre_en_date(semestres_a_aggreger)
semxs_ids = [] # Au cas où il y ait plusieurs solutions
for semx_id, semx in semXs.items():
# Même semestre final ?
if semx.get_formsemestre_id_final() == dernier_sem_a_aggreger.formsemestre_id:
# Les fids
fids_a_aggreger = set(semestres_a_aggreger.keys())
# Ceux du semx
fids_semx = set(semx.semestres_aggreges.keys())
if fids_a_aggreger.issubset(
fids_semx
): # tous les semestres du semx correspond à des sems de la trajectoire
semxs_ids += [semx_id]
if len(semxs_ids) == 0:
return None # rien trouvé
elif len(semxs_ids) == 1:
return semxs_ids[0]
else:
raise "Plusieurs solutions :)"

View File

@ -1,217 +0,0 @@
# -*- 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
from app.comp.res_sem import load_formsemestre_results
from app.pe.pe_semtag import SemestreTag
import pandas as pd
import numpy as np
from app.pe.pe_rcs import RCS
from app.pe.pe_tabletags import TableTag, MoyenneTag
class RCSTag(TableTag):
def __init__(
self, rcs: RCS, semestres_taggues: dict[int, SemestreTag]
):
"""Calcule les moyennes par tag d'une combinaison de semestres
(RCS), pour extraire les classements par tag pour un
groupe d'étudiants donnés. Le groupe d'étudiants est formé par ceux ayant tous
participé au semestre terminal.
Args:
rcs: Un RCS (identifié par un nom et l'id de son semestre terminal)
semestres_taggues: Les données sur les semestres taggués
"""
TableTag.__init__(self)
self.rcs_id = rcs.rcs_id
"""Identifiant du RCS taggué (identique au RCS sur lequel il s'appuie)"""
self.rcs = rcs
"""RCS associé au RCS taggué"""
self.nom = self.get_repr()
"""Représentation textuelle du RCS taggué"""
self.formsemestre_terminal = rcs.formsemestre_final
"""Le formsemestre terminal"""
# Les résultats du formsemestre terminal
nt = load_formsemestre_results(self.formsemestre_terminal)
self.semestres_aggreges = rcs.semestres_aggreges
"""Les semestres aggrégés"""
self.semestres_tags_aggreges = {}
"""Les semestres tags associés aux semestres aggrégés"""
for frmsem_id in self.semestres_aggreges:
try:
self.semestres_tags_aggreges[frmsem_id] = semestres_taggues[frmsem_id]
except:
raise ValueError("Semestres taggués manquants")
"""Les étudiants (état civil + cursus connu)"""
self.etuds = nt.etuds
# assert self.etuds == trajectoire.suivi # manque-t-il des étudiants ?
self.etudiants = {etud.etudid: etud.etat_civil for etud in self.etuds}
self.tags_sorted = self.do_taglist()
"""Tags extraits de tous les semestres"""
self.notes_cube = self.compute_notes_cube()
"""Cube de notes"""
etudids = list(self.etudiants.keys())
self.notes = compute_tag_moy(self.notes_cube, etudids, self.tags_sorted)
"""Calcul les moyennes par tag sous forme d'un dataframe"""
self.moyennes_tags: dict[str, MoyenneTag] = {}
"""Synthétise les moyennes/classements par tag (qu'ils soient personnalisé ou de compétences)"""
for tag in self.tags_sorted:
moy_gen_tag = self.notes[tag]
self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag)
def __eq__(self, other):
"""Egalité de 2 RCS taggués sur la base de leur identifiant"""
return self.rcs_id == other.rcs_id
def get_repr(self, verbose=False) -> str:
"""Renvoie une représentation textuelle (celle de la trajectoire sur laquelle elle
est basée)"""
return self.rcs.get_repr(verbose=verbose)
def compute_notes_cube(self):
"""Construit le cube de notes (etudid x tags x semestre_aggregé)
nécessaire au calcul des moyennes de l'aggrégat
"""
# nb_tags = len(self.tags_sorted)
# nb_etudiants = len(self.etuds)
# nb_semestres = len(self.semestres_tags_aggreges)
# Index du cube (etudids -> dim 0, tags -> dim 1)
etudids = [etud.etudid for etud in self.etuds]
tags = self.tags_sorted
semestres_id = list(self.semestres_tags_aggreges.keys())
dfs = {}
for frmsem_id in semestres_id:
# Partant d'un dataframe vierge
df = pd.DataFrame(np.nan, index=etudids, columns=tags)
# Charge les notes du semestre tag
notes = self.semestres_tags_aggreges[frmsem_id].notes
# Les étudiants & les tags commun au dataframe final et aux notes du semestre)
etudids_communs = df.index.intersection(notes.index)
tags_communs = df.columns.intersection(notes.columns)
# Injecte les notes par tag
df.loc[etudids_communs, tags_communs] = notes.loc[
etudids_communs, tags_communs
]
# Supprime tout ce qui n'est pas numérique
for col in df.columns:
df[col] = pd.to_numeric(df[col], errors="coerce")
# Stocke le df
dfs[frmsem_id] = df
"""Réunit les notes sous forme d'un cube etdids x tags x semestres"""
semestres_x_etudids_x_tags = [dfs[fid].values for fid in dfs]
etudids_x_tags_x_semestres = np.stack(semestres_x_etudids_x_tags, axis=-1)
return etudids_x_tags_x_semestres
def do_taglist(self):
"""Synthétise les tags à partir des semestres (taggués) aggrégés
Returns:
Une liste de tags triés par ordre alphabétique
"""
tags = []
for frmsem_id in self.semestres_tags_aggreges:
tags.extend(self.semestres_tags_aggreges[frmsem_id].tags_sorted)
return sorted(set(tags))
def compute_tag_moy(set_cube: np.array, etudids: list, tags: list):
"""Calcul de la moyenne par tag sur plusieurs semestres.
La moyenne est un nombre (note/20), ou NaN si pas de notes disponibles
*Remarque* : Adaptation de moy_ue.compute_ue_moys_apc au cas des moyennes de tag
par aggrégat de plusieurs semestres.
Args:
set_cube: notes moyennes aux modules ndarray
(etuds x modimpls x UEs), des floats avec des NaN
etudids: liste des étudiants (dim. 0 du cube)
tags: liste des tags (dim. 1 du cube)
Returns:
Un DataFrame avec pour columns les moyennes par tags,
et pour rows les etudid
"""
nb_etuds, nb_tags, nb_semestres = set_cube.shape
assert nb_etuds == len(etudids)
assert nb_tags == len(tags)
# Quelles entrées du cube contiennent des notes ?
mask = ~np.isnan(set_cube)
# Enlève les NaN du cube pour les entrées manquantes
set_cube_no_nan = np.nan_to_num(set_cube, nan=0.0)
# Les moyennes par tag
with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN)
etud_moy_tag = np.sum(set_cube_no_nan, axis=2) / np.sum(mask, axis=2)
# Le dataFrame
etud_moy_tag_df = pd.DataFrame(
etud_moy_tag,
index=etudids, # les etudids
columns=tags, # les tags
)
etud_moy_tag_df.fillna(np.nan)
return etud_moy_tag_df

View File

@ -1,310 +0,0 @@
# -*- 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Fri Sep 9 09:15:05 2016
@author: barasc
"""
import pandas as pd
import app.pe.pe_etudiant
from app import db, ScoValueError
from app import comp
from app.comp.res_sem import load_formsemestre_results
from app.models import FormSemestre
from app.models.moduleimpls import ModuleImpl
import app.pe.pe_affichage as pe_affichage
from app.pe.pe_tabletags import TableTag, MoyenneTag
from app.scodoc import sco_tag_module
from app.scodoc.codes_cursus import UE_SPORT
class SemestreTag(TableTag):
"""
Un SemestreTag représente les résultats des étudiants à un semestre, en donnant
accès aux moyennes par tag.
Il s'appuie principalement sur FormSemestre et sur ResultatsSemestreBUT.
"""
def __init__(self, formsemestre_id: int):
"""
Args:
formsemestre_id: Identifiant du ``FormSemestre`` sur lequel il se base
"""
TableTag.__init__(self)
# Le semestre
self.formsemestre_id = formsemestre_id
self.formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# Le nom du semestre taggué
self.nom = self.get_repr()
# Les résultats du semestre
self.nt = load_formsemestre_results(self.formsemestre)
# Les étudiants
self.etuds = self.nt.etuds
self.etudiants = {etud.etudid: etud.etat_civil for etud in self.etuds}
# Les notes, les modules implémentés triés, les étudiants, les coeffs,
# récupérés notamment de py:mod:`res_but`
self.sem_cube = self.nt.sem_cube
self.modimpls_sorted = self.nt.formsemestre.modimpls_sorted
self.modimpl_coefs_df = self.nt.modimpl_coefs_df
# Les inscriptions au module et les dispenses d'UE
self.modimpl_inscr_df = self.nt.modimpl_inscr_df
self.ues = self.nt.ues
self.ues_inscr_parcours_df = self.nt.load_ues_inscr_parcours()
self.dispense_ues = self.nt.dispense_ues
# Les tags :
## Saisis par l'utilisateur
tags_personnalises = get_synthese_tags_personnalises_semestre(
self.nt.formsemestre
)
noms_tags_perso = list(set(tags_personnalises.keys()))
## Déduit des compétences
dict_ues_competences = get_noms_competences_from_ues(self.nt.formsemestre)
noms_tags_comp = list(set(dict_ues_competences.values()))
noms_tags_auto = ["but"] + noms_tags_comp
self.tags = noms_tags_perso + noms_tags_auto
"""Tags du semestre taggué"""
## Vérifie l'unicité des tags
if len(set(self.tags)) != len(self.tags):
intersection = list(set(noms_tags_perso) & set(noms_tags_auto))
liste_intersection = "\n".join(
[f"<li><code>{tag}</code></li>" for tag in intersection]
)
s = "s" if len(intersection) > 0 else ""
message = f"""Erreur dans le module PE : Un des tags saisis dans votre
programme de formation fait parti des tags réservés. En particulier,
votre semestre <em>{self.formsemestre.titre_annee()}</em>
contient le{s} tag{s} réservé{s} suivant :
<ul>
{liste_intersection}
</ul>
Modifiez votre programme de formation pour le{s} supprimer.
Il{s} ser{'ont' if s else 'a'} automatiquement à vos documents de poursuites d'études.
"""
raise ScoValueError(message)
# Calcul des moyennes & les classements de chaque étudiant à chaque tag
self.moyennes_tags = {}
for tag in tags_personnalises:
# pe_affichage.pe_print(f" -> Traitement du tag {tag}")
moy_gen_tag = self.compute_moyenne_tag(tag, tags_personnalises)
self.moyennes_tags[tag] = MoyenneTag(tag, moy_gen_tag)
# Ajoute les moyennes générales de BUT pour le semestre considéré
moy_gen_but = self.nt.etud_moy_gen
self.moyennes_tags["but"] = MoyenneTag("but", moy_gen_but)
# Ajoute les moyennes par compétence
for ue_id, competence in dict_ues_competences.items():
if competence not in self.moyennes_tags:
moy_ue = self.nt.etud_moy_ue[ue_id]
self.moyennes_tags[competence] = MoyenneTag(competence, moy_ue)
self.tags_sorted = self.get_all_tags()
"""Tags (personnalisés+compétences) par ordre alphabétique"""
# Synthétise l'ensemble des moyennes dans un dataframe
self.notes = self.df_notes()
"""Dataframe synthétique des notes par tag"""
pe_affichage.pe_print(
f" => Traitement des tags {', '.join(self.tags_sorted)}"
)
def get_repr(self):
"""Nom affiché pour le semestre taggué"""
return app.pe.pe_etudiant.nom_semestre_etape(self.formsemestre, avec_fid=True)
def compute_moyenne_tag(self, tag: str, tags_infos: dict) -> pd.Series:
"""Calcule la moyenne des étudiants pour le tag indiqué,
pour ce SemestreTag, en ayant connaissance des informations sur
les tags (dictionnaire donnant les coeff de repondération)
Sont pris en compte les modules implémentés associés au tag,
avec leur éventuel coefficient de **repondération**, en utilisant les notes
chargées pour ce SemestreTag.
Force ou non le calcul de la moyenne lorsque des notes sont manquantes.
Returns:
La série des moyennes
"""
# Adaptation du mask de calcul des moyennes au tag visé
modimpls_mask = [
modimpl.module.ue.type != UE_SPORT
for modimpl in self.formsemestre.modimpls_sorted
]
# Désactive tous les modules qui ne sont pas pris en compte pour ce tag
for i, modimpl in enumerate(self.formsemestre.modimpls_sorted):
if modimpl.moduleimpl_id not in tags_infos[tag]:
modimpls_mask[i] = False
# Applique la pondération des coefficients
modimpl_coefs_ponderes_df = self.modimpl_coefs_df.copy()
for modimpl_id in tags_infos[tag]:
ponderation = tags_infos[tag][modimpl_id]["ponderation"]
modimpl_coefs_ponderes_df[modimpl_id] *= ponderation
# Calcule les moyennes pour le tag visé dans chaque UE (dataframe etudid x ues)#
moyennes_ues_tag = comp.moy_ue.compute_ue_moys_apc(
self.sem_cube,
self.etuds,
self.formsemestre.modimpls_sorted,
self.modimpl_inscr_df,
modimpl_coefs_ponderes_df,
modimpls_mask,
self.dispense_ues,
block=self.formsemestre.block_moyennes,
)
# Les ects
ects = self.ues_inscr_parcours_df.fillna(0.0) * [
ue.ects for ue in self.ues if ue.type != UE_SPORT
]
# Calcule la moyenne générale dans le semestre (pondérée par le ECTS)
moy_gen_tag = comp.moy_sem.compute_sem_moys_apc_using_ects(
moyennes_ues_tag,
ects,
formation_id=self.formsemestre.formation_id,
skip_empty_ues=True,
)
return moy_gen_tag
def get_moduleimpl(modimpl_id) -> dict:
"""Renvoie l'objet modimpl dont l'id est modimpl_id"""
modimpl = db.session.get(ModuleImpl, modimpl_id)
if modimpl:
return modimpl
return None
def get_moy_ue_from_nt(nt, etudid, modimpl_id) -> float:
"""Renvoie la moyenne de l'UE d'un etudid dans laquelle se trouve
le module de modimpl_id
"""
# ré-écrit
modimpl = get_moduleimpl(modimpl_id) # le module
ue_status = nt.get_etud_ue_status(etudid, modimpl.module.ue.id)
if ue_status is None:
return None
return ue_status["moy"]
def get_synthese_tags_personnalises_semestre(formsemestre: FormSemestre):
"""Etant données les implémentations des modules du semestre (modimpls),
synthétise les tags renseignés dans le programme pédagogique &
associés aux modules du semestre,
en les associant aux modimpls qui les concernent (modimpl_id) et
aucoeff et pondération fournie avec le tag (par défaut 1 si non indiquée)).
Args:
formsemestre: Le formsemestre à la base de la recherche des tags
Return:
Un dictionnaire de tags
"""
synthese_tags = {}
# Instance des modules du semestre
modimpls = formsemestre.modimpls_sorted
for modimpl in modimpls:
modimpl_id = modimpl.id
# Liste des tags pour le module concerné
tags = sco_tag_module.module_tag_list(modimpl.module.id)
# Traitement des tags recensés, chacun pouvant étant de la forme
# "mathématiques", "théorie", "pe:0", "maths:2"
for tag in tags:
# Extraction du nom du tag et du coeff de pondération
(tagname, ponderation) = sco_tag_module.split_tagname_coeff(tag)
# Ajout d'une clé pour le tag
if tagname not in synthese_tags:
synthese_tags[tagname] = {}
# Ajout du module (modimpl) au tagname considéré
synthese_tags[tagname][modimpl_id] = {
"modimpl": modimpl, # les données sur le module
# "coeff": modimpl.module.coefficient, # le coeff du module dans le semestre
"ponderation": ponderation, # la pondération demandée pour le tag sur le module
# "module_code": modimpl.module.code, # le code qui doit se retrouver à l'identique dans des ue capitalisee
# "ue_id": modimpl.module.ue.id, # les données sur l'ue
# "ue_code": modimpl.module.ue.ue_code,
# "ue_acronyme": modimpl.module.ue.acronyme,
}
return synthese_tags
def get_noms_competences_from_ues(formsemestre: FormSemestre) -> dict[int, str]:
"""Partant d'un formsemestre, extrait le nom des compétences associés
à (ou aux) parcours des étudiants du formsemestre.
Ignore les UEs non associées à un niveau de compétence.
Args:
formsemestre: Un FormSemestre
Returns:
Dictionnaire {ue_id: nom_competence} lisant tous les noms des compétences
en les raccrochant à leur ue
"""
# Les résultats du semestre
nt = load_formsemestre_results(formsemestre)
noms_competences = {}
for ue in nt.ues:
if ue.niveau_competence and ue.type != UE_SPORT:
# ?? inutilisé ordre = ue.niveau_competence.ordre
nom = ue.niveau_competence.competence.titre
noms_competences[ue.ue_id] = f"comp. {nom}"
return noms_competences

View File

@ -1,263 +0,0 @@
# -*- 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
#
##############################################################################
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on Thu Sep 8 09:36:33 2016
@author: barasc
"""
import datetime
import numpy as np
from app import ScoValueError
from app.comp.moy_sem import comp_ranks_series
from app.pe import pe_affichage
from app.pe.pe_affichage import SANS_NOTE
from app.scodoc import sco_utils as scu
import pandas as pd
TAGS_RESERVES = ["but"]
class MoyenneTag:
def __init__(self, tag: str, notes: pd.Series):
"""Classe centralisant la synthèse des moyennes/classements d'une série
d'étudiants à un tag donné, en stockant un dictionnaire :
``
{
"notes": la Serie pandas des notes (float),
"classements": la Serie pandas des classements (float),
"min": la note minimum,
"max": la note maximum,
"moy": la moyenne,
"nb_inscrits": le nombre d'étudiants ayant une note,
}
``
Args:
tag: Un tag
note: Une série de notes (moyenne) sous forme d'un pd.Series()
"""
self.tag = tag
"""Le tag associé à la moyenne"""
self.etudids = list(notes.index) # calcul à venir
"""Les id des étudiants"""
self.inscrits_ids = notes[notes.notnull()].index.to_list()
"""Les id des étudiants dont la moyenne est non nulle"""
self.df: pd.DataFrame = self.comp_moy_et_stat(notes)
"""Le dataframe retraçant les moyennes/classements/statistiques"""
self.synthese = self.to_dict()
"""La synthèse (dictionnaire) des notes/classements/statistiques"""
def __eq__(self, other):
"""Egalité de deux MoyenneTag lorsque leur tag sont identiques"""
return self.tag == other.tag
def comp_moy_et_stat(self, notes: pd.Series) -> dict:
"""Calcule et structure les données nécessaires au PE pour une série
de notes (souvent une moyenne par tag) dans un dictionnaire spécifique.
Partant des notes, sont calculés les classements (en ne tenant compte
que des notes non nulles).
Args:
notes: Une série de notes (avec des éventuels NaN)
Returns:
Un dictionnaire stockant les notes, les classements, le min,
le max, la moyenne, le nb de notes (donc d'inscrits)
"""
df = pd.DataFrame(
np.nan,
index=self.etudids,
columns=[
"note",
"classement",
"rang",
"min",
"max",
"moy",
"nb_etuds",
"nb_inscrits",
],
)
# Supprime d'éventuelles chaines de caractères dans les notes
notes = pd.to_numeric(notes, errors="coerce")
df["note"] = notes
# Les nb d'étudiants & nb d'inscrits
df["nb_etuds"] = len(self.etudids)
df.loc[self.inscrits_ids, "nb_inscrits"] = len(self.inscrits_ids)
# Le classement des inscrits
notes_non_nulles = notes[self.inscrits_ids]
(class_str, class_int) = comp_ranks_series(notes_non_nulles)
df.loc[self.inscrits_ids, "classement"] = class_int
# Le rang (classement/nb_inscrit)
df["rang"] = df["rang"].astype(str)
df.loc[self.inscrits_ids, "rang"] = (
df.loc[self.inscrits_ids, "classement"].astype(int).astype(str)
+ "/"
+ df.loc[self.inscrits_ids, "nb_inscrits"].astype(int).astype(str)
)
# Les stat (des inscrits)
df.loc[self.inscrits_ids, "min"] = notes.min()
df.loc[self.inscrits_ids, "max"] = notes.max()
df.loc[self.inscrits_ids, "moy"] = notes.mean()
return df
def to_dict(self) -> dict:
"""Renvoie un dictionnaire de synthèse des moyennes/classements/statistiques"""
synthese = {
"notes": self.df["note"],
"classements": self.df["classement"],
"min": self.df["min"].mean(),
"max": self.df["max"].mean(),
"moy": self.df["moy"].mean(),
"nb_inscrits": self.df["nb_inscrits"].mean(),
}
return synthese
def get_notes(self):
"""Série des notes, arrondies à 2 chiffres après la virgule"""
return self.df["note"].round(2)
def get_rangs_inscrits(self) -> pd.Series:
"""Série des rangs classement/nbre_inscrit"""
return self.df["rang"]
def get_min(self) -> pd.Series:
"""Série des min"""
return self.df["min"].round(2)
def get_max(self) -> pd.Series:
"""Série des max"""
return self.df["max"].round(2)
def get_moy(self) -> pd.Series:
"""Série des moy"""
return self.df["moy"].round(2)
def get_note_for_df(self, etudid: int):
"""Note d'un étudiant donné par son etudid"""
return round(self.df["note"].loc[etudid], 2)
def get_min_for_df(self) -> float:
"""Min renseigné pour affichage dans un df"""
return round(self.synthese["min"], 2)
def get_max_for_df(self) -> float:
"""Max renseigné pour affichage dans un df"""
return round(self.synthese["max"], 2)
def get_moy_for_df(self) -> float:
"""Moyenne renseignée pour affichage dans un df"""
return round(self.synthese["moy"], 2)
def get_class_for_df(self, etudid: int) -> str:
"""Classement ramené au nombre d'inscrits,
pour un étudiant donné par son etudid"""
classement = self.df["rang"].loc[etudid]
if not pd.isna(classement):
return classement
else:
return pe_affichage.SANS_NOTE
def is_significatif(self) -> bool:
"""Indique si la moyenne est significative (c'est-à-dire à des notes)"""
return self.synthese["nb_inscrits"] > 0
class TableTag(object):
def __init__(self):
"""Classe centralisant différentes méthodes communes aux
SemestreTag, TrajectoireTag, AggregatInterclassTag
"""
pass
# -----------------------------------------------------------------------------------------------------------
def get_all_tags(self):
"""Liste des tags de la table, triée par ordre alphabétique,
extraite des clés du dictionnaire ``moyennes_tags`` connues (tags en doublon
possible).
Returns:
Liste de tags triés par ordre alphabétique
"""
return sorted(list(self.moyennes_tags.keys()))
def df_moyennes_et_classements(self) -> pd.DataFrame:
"""Renvoie un dataframe listant toutes les moyennes,
et les classements des étudiants pour tous les tags.
Est utilisé pour afficher le détail d'un tableau taggué
(semestres, trajectoires ou aggrégat)
Returns:
Le dataframe des notes et des classements
"""
etudiants = self.etudiants
df = pd.DataFrame.from_dict(etudiants, orient="index", columns=["nom"])
tags_tries = self.get_all_tags()
for tag in tags_tries:
moy_tag = self.moyennes_tags[tag]
df = df.join(moy_tag.synthese["notes"].rename(f"Moy {tag}"))
df = df.join(moy_tag.synthese["classements"].rename(f"Class {tag}"))
return df
def df_notes(self) -> pd.DataFrame | None:
"""Renvoie un dataframe (etudid x tag) listant toutes les moyennes par tags
Returns:
Un dataframe etudids x tag (avec tag par ordre alphabétique)
"""
tags_tries = self.get_all_tags()
if tags_tries:
dict_series = {}
for tag in tags_tries:
# Les moyennes associés au tag
moy_tag = self.moyennes_tags[tag]
dict_series[tag] = moy_tag.synthese["notes"]
df = pd.DataFrame(dict_series)
return df

View File

@ -38,6 +38,7 @@
from flask import flash, g, redirect, render_template, request, send_file, url_for
from app.decorators import permission_required, scodoc
from app.forms.pe.pe_sem_recap import ParametrageClasseurPE
from app.models import FormSemestre
from app.pe import pe_comp
from app.pe import pe_jury
@ -73,32 +74,50 @@ def pe_view_sem_recap(formsemestre_id: int):
# Cosemestres diplomants
cosemestres = pe_comp.get_cosemestres_diplomants(annee_diplome)
form = ParametrageClasseurPE()
cosemestres_tries = pe_comp.tri_semestres_par_rang(cosemestres)
affichage_cosemestres_tries = {
rang: ", ".join([sem.titre_annee() for sem in cosemestres_tries[rang]])
for rang in cosemestres_tries
}
if request.method == "GET":
return render_template(
"pe/pe_view_sem_recap.j2",
annee_diplome=annee_diplome,
form=form,
formsemestre=formsemestre,
sco=ScoData(formsemestre=formsemestre),
cosemestres=cosemestres,
cosemestres=affichage_cosemestres_tries,
rangs_tries=sorted(affichage_cosemestres_tries.keys()),
)
# request.method == "POST"
jury = pe_jury.JuryPE(annee_diplome)
if not jury.diplomes_ids:
flash("aucun étudiant à considérer !")
return redirect(
url_for(
"notes.pe_view_sem_recap",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
if form.validate_on_submit():
jury = pe_jury.JuryPE(annee_diplome, formsemestre_id, options=form.data)
if not jury.diplomes_ids:
flash("aucun étudiant à considérer !")
return redirect(
url_for(
"notes.pe_view_sem_recap",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
data = jury.get_zipped_data()
return send_file(
data,
mimetype="application/zip",
download_name=scu.sanitize_filename(jury.nom_export_zip + ".zip"),
as_attachment=True,
)
data = jury.get_zipped_data()
return send_file(
data,
mimetype="application/zip",
download_name=scu.sanitize_filename(jury.nom_export_zip + ".zip"),
as_attachment=True,
return redirect(
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)

0
app/pe/rcss/__init__.py Normal file
View File

131
app/pe/rcss/pe_rcs.py Normal file
View File

@ -0,0 +1,131 @@
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on 01-2024
@author: barasc
"""
from app.models import FormSemestre
TYPES_RCS = {
"S1": {
"aggregat": ["S1"],
"descr": "Semestre 1 (S1)",
},
"S2": {
"aggregat": ["S2"],
"descr": "Semestre 2 (S2)",
},
"1A": {
"aggregat": ["S1", "S2"],
"descr": "BUT1 (S1+S2)",
},
"S3": {
"aggregat": ["S3"],
"descr": "Semestre 3 (S3)",
},
"S4": {
"aggregat": ["S4"],
"descr": "Semestre 4 (S4)",
},
"2A": {
"aggregat": ["S3", "S4"],
"descr": "BUT2 (S3+S4)",
},
"3S": {
"aggregat": ["S1", "S2", "S3"],
"descr": "Moyenne du S1 au S3 (S1+S2+S3)",
},
"4S": {
"aggregat": ["S1", "S2", "S3", "S4"],
"descr": "Moyenne du S1 au S4 (S1+S2+S3+S4)",
},
"S5": {
"aggregat": ["S5"],
"descr": "Semestre 5 (S5)",
},
"S6": {
"aggregat": ["S6"],
"descr": "Semestre 6 (S6)",
},
"3A": {
"aggregat": ["S5", "S6"],
"descr": "BUT3 (S5+S6)",
},
"5S": {
"aggregat": ["S1", "S2", "S3", "S4", "S5"],
"descr": "Moyenne du S1 au S5 (S1+S2+S3+S4+S5)",
},
"6S": {
"aggregat": ["S1", "S2", "S3", "S4", "S5", "S6"],
"descr": "Moyenne globale (S1+S2+S3+S4+S5+S6)",
},
}
"""Dictionnaire détaillant les différents regroupements cohérents
de semestres (RCS), en leur attribuant un nom et en détaillant
le nom des semestres qu'ils regroupent et l'affichage qui en sera fait
dans les tableurs de synthèse.
"""
TOUS_LES_RCS_AVEC_PLUSIEURS_SEM = [cle for cle in TYPES_RCS if not cle.startswith("S")]
TOUS_LES_RCS = list(TYPES_RCS.keys())
TOUS_LES_SEMESTRES = [cle for cle in TYPES_RCS if cle.startswith("S")]
def get_descr_rcs(nom_rcs: str) -> str:
"""Renvoie la description pour les tableurs de synthèse
Excel d'un nom de RCS"""
return TYPES_RCS[nom_rcs]["descr"]
class RCS:
"""Modélise un regroupement cohérent de semestres,
tous se terminant par un (form)semestre final.
"""
def __init__(self, nom: str, semestre_final: FormSemestre):
self.nom: str = nom
"""Nom du RCS"""
assert self.nom in TOUS_LES_RCS, "Le nom d'un RCS doit être un aggrégat"
self.noms_semestres_aggreges: list[str] = TYPES_RCS[nom]["aggregat"]
"""Noms des semestres aggrégés)"""
self.formsemestre_final: FormSemestre = semestre_final
"""(Form)Semestre final du RCS"""
self.rang_final = self.formsemestre_final.semestre_id
"""Rang du formsemestre final"""
self.rcs_id: (str, int) = (nom, semestre_final.formsemestre_id)
"""Identifiant du RCS sous forme (nom_rcs, id du semestre_terminal)"""
self.fid_final: int = self.formsemestre_final.formsemestre_id
"""Identifiant du (Form)Semestre final"""
def get_formsemestre_id_final(self) -> int:
"""Renvoie l'identifiant du formsemestre final du RCS
Returns:
L'id du formsemestre final (marquant la fin) du RCS
"""
return self.formsemestre_final.formsemestre_id
def __str__(self):
"""Représentation textuelle d'un RCS"""
return f"{self.nom}[#{self.formsemestre_final.formsemestre_id}{self.formsemestre_final.date_fin.year}]"
def get_repr(self, verbose=True):
"""Représentation textuelle d'un RCS"""
return self.__str__()
def __eq__(self, other):
"""Egalité de RCS"""
return (
self.nom == other.nom
and self.formsemestre_final == other.formsemestre_final
)

59
app/pe/rcss/pe_rcsemx.py Normal file
View File

@ -0,0 +1,59 @@
##############################################################################
# Module "Avis de poursuite d'étude"
# conçu et développé par Cléo Baras (IUT de Grenoble)
##############################################################################
"""
Created on 01-2024
@author: barasc
"""
from app.models import FormSemestre
from app.pe.moys import pe_sxtag
from app.pe.rcss import pe_rcs, pe_trajectoires
class RCSemX(pe_rcs.RCS):
"""Modélise un regroupement cohérent de SemX (en regroupant
des semestres Sx combinés pour former les résultats des étudiants
au semestre de rang x) dans le but de synthétiser les résultats
du S1 jusqu'au semestre final ciblé par le RCSemX (dépendant de l'aggrégat
visé).
Par ex: Si l'aggrégat du RCSemX est '3S' (=S1+S2+S3),
regroupement le SemX du S1 + le SemX du S2 + le SemX du S3 (chacun
incluant des infos sur les redoublements).
Args:
nom: Un nom du RCS (par ex: '5S')
semestre_final: Le semestre final du RCS
"""
def __init__(self, nom: str, semestre_final: FormSemestre):
pe_rcs.RCS.__init__(self, nom, semestre_final)
self.semXs_aggreges: dict[(str, int) : pe_sxtag.SxTag] = {}
"""Les semX à aggréger"""
def add_semXs(self, semXs: dict[(str, int) : pe_trajectoires.SemX]):
"""Ajoute des semXs aux semXs à regrouper dans le RCSemX
Args:
semXs: Dictionnaire ``{(str,fid): RCF}`` à ajouter
"""
self.semXs_aggreges = self.semXs_aggreges | semXs
def get_repr(self, verbose=True) -> str:
"""Représentation textuelle d'un RCSF
basé sur ses RCF aggrégés"""
title = f"""{self.__class__.__name__} {pe_rcs.RCS.__str__(self)}"""
if verbose:
noms = []
for semx_id, semx in self.semXs_aggreges.items():
noms.append(semx.get_repr(verbose=False))
if noms:
title += " <<" + "+".join(noms) + ">>"
else:
title += " <<vide>>"
return title

View File

@ -0,0 +1,87 @@
from app.models import FormSemestre
import app.pe.rcss.pe_rcs as pe_rcs
class Trajectoire(pe_rcs.RCS):
"""Regroupement Cohérent de Semestres ciblant un type d'aggrégat (par ex.
'S2', '3S', '1A') et un semestre final, et dont les données regroupées
sont des **FormSemestres** suivis par les étudiants.
Une *Trajectoire* traduit la succession de semestres
qu'ont pu suivre des étudiants pour aller d'un semestre S1 jusqu'au semestre final
de l'aggrégat.
Une *Trajectoire* peut être :
* un RCS de semestre de type "Sx" (cf. classe "SemX"), qui stocke les
formsemestres de rang x qu'ont suivi l'étudiant pour valider le Sx
(en général 1 formsemestre pour les non-redoublants et 2 pour les redoublants)
* un RCS de type iS ou iA (par ex, 3A=S1+S2+S3), qui identifie
les formsemestres que des étudiants ont suivis pour les amener jusqu'au semestre
terminal du RCS. Par ex: si le RCS est un 3S:
* des S1+S2+S1+S2+S3 si redoublement de la 1ère année
* des S1+S2+(année de césure)+S3 si césure, ...
Args:
nom: Un nom du RCS (par ex: '5S')
semestre_final: Le formsemestre final du RCS
"""
def __init__(self, nom: str, semestre_final: FormSemestre):
pe_rcs.RCS.__init__(self, nom, semestre_final)
self.semestres_aggreges: dict[int:FormSemestre] = {}
"""Formsemestres regroupés dans le RCS"""
def add_semestres(self, semestres: dict[int:FormSemestre]):
"""Ajout de semestres aux semestres à regrouper
Args:
semestres: Dictionnaire ``{fid: Formsemestre)``
"""
for sem in semestres.values():
assert isinstance(
sem, FormSemestre
), "Les données aggrégées d'une Trajectoire doivent être des FormSemestres"
self.semestres_aggreges = self.semestres_aggreges | semestres
def get_repr(self, verbose=True) -> str:
"""Représentation textuelle d'un RCS
basé sur ses semestres aggrégés"""
title = f"""{self.__class__.__name__} {pe_rcs.RCS.__str__(self)}"""
if verbose:
noms = []
for fid in self.semestres_aggreges:
semestre = self.semestres_aggreges[fid]
noms.append(f"S{semestre.semestre_id}#{fid}")
noms = sorted(noms)
if noms:
title += " <" + "+".join(noms) + ">"
else:
title += " <vide>"
return title
class SemX(Trajectoire):
"""Trajectoire (regroupement cohérent de (form)semestres
dans laquelle tous les semestres regroupés sont de même rang `x`.
Les SemX stocke les
formsemestres de rang x qu'ont suivi l'étudiant pour valider le Sx
(en général 1 formsemestre pour les non-redoublants et 2 pour les redoublants).
Ils servent à calculer les SemXTag (moyennes par tag des RCS de type `Sx`).
"""
def __init__(self, trajectoire: Trajectoire):
Trajectoire.__init__(self, trajectoire.nom, trajectoire.formsemestre_final)
semestres_aggreges = trajectoire.semestres_aggreges
for sem in semestres_aggreges.values():
assert (
sem.semestre_id == trajectoire.rang_final
), "Tous les semestres aggrégés d'un SemX doivent être de même rang"
self.semestres_aggreges = trajectoire.semestres_aggreges

View File

@ -85,17 +85,6 @@ UE_ELECTIVE = 4 # UE "élective" dans certains cursus (UCAC?, ISCID)
UE_PROFESSIONNELLE = 5 # UE "professionnelle" (ISCID, ...)
UE_OPTIONNELLE = 6 # UE non fondamentales (ILEPS, ...)
def ue_is_fondamentale(ue_type):
return ue_type in (UE_STANDARD, UE_STAGE_LP, UE_PROFESSIONNELLE)
def ue_is_professionnelle(ue_type):
return (
ue_type == UE_PROFESSIONNELLE
) # NB: les UE_PROFESSIONNELLE sont à la fois fondamentales et pro
UE_TYPE_NAME = {
UE_STANDARD: "Standard",
UE_SPORT: "Sport/Culture (points bonus)",
@ -104,8 +93,6 @@ UE_TYPE_NAME = {
UE_ELECTIVE: "Elective (ISCID)",
UE_PROFESSIONNELLE: "Professionnelle (ISCID)",
UE_OPTIONNELLE: "Optionnelle",
# UE_FONDAMENTALE : '"Fondamentale" (eg UCAC)',
# UE_OPTIONNELLE : '"Optionnelle" (UCAC)'
}
# Couleurs RGB (dans [0.,1.]) des UE pour les bulletins:
@ -409,6 +396,7 @@ class CursusBUT(TypeCursus):
APC_SAE = True
USE_REFERENTIEL_COMPETENCES = True
ALLOWED_UE_TYPES = [UE_STANDARD, UE_SPORT]
ECTS_DIPLOME = 180
register_cursus(CursusBUT())

View File

@ -44,13 +44,15 @@ import random
from collections import OrderedDict
from xml.etree import ElementTree
import json
from typing import Any
from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
from openpyxl.utils import get_column_letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame
from reportlab.platypus import Paragraph, Spacer
from reportlab.platypus import Table, KeepInFrame
from reportlab.lib.colors import Color
from reportlab.lib import styles
from reportlab.lib.units import inch, cm, mm
from reportlab.rl_config import defaultPageSize # pylint: disable=no-name-in-module
from reportlab.lib.units import cm
from app.scodoc import html_sco_header
from app.scodoc import sco_utils as scu
@ -62,16 +64,32 @@ from app.scodoc.sco_pdf import SU
from app import log, ScoDocJSONEncoder
def mark_paras(L, tags) -> list[str]:
"""Put each (string) element of L between <tag>...</tag>,
def mark_paras(items: list[Any], tags: list[str]) -> list[str]:
"""Put each string element of items between <tag>...</tag>,
for each supplied tag.
Leave non string elements untouched.
"""
for tag in tags:
start = "<" + tag + ">"
end = "</" + tag.split()[0] + ">"
L = [(start + (x or "") + end) if isinstance(x, str) else x for x in L]
return L
items = [(start + (x or "") + end) if isinstance(x, str) else x for x in items]
return items
def add_query_param(url: str, key: str, value: str) -> str:
"add parameter key=value to the given URL"
# Parse the URL
parsed_url = urlparse(url)
# Parse the query parameters
query_params = parse_qs(parsed_url.query)
# Add or update the query parameter
query_params[key] = [value]
# Encode the query parameters
encoded_query_params = urlencode(query_params, doseq=True)
# Construct the new URL
new_url_parts = parsed_url._replace(query=encoded_query_params)
new_url = urlunparse(new_url_parts)
return new_url
class DEFAULT_TABLE_PREFERENCES(object):
@ -477,13 +495,15 @@ class GenTable:
H.append('<span class="gt_export_icons">')
if self.xls_link:
H.append(
' <a href="%s&fmt=xls">%s</a>' % (self.base_url, scu.ICON_XLS)
f""" <a href="{add_query_param(self.base_url, "fmt", "xls")
}">{scu.ICON_XLS}</a>"""
)
if self.xls_link and self.pdf_link:
H.append("&nbsp;")
if self.pdf_link:
H.append(
' <a href="%s&fmt=pdf">%s</a>' % (self.base_url, scu.ICON_PDF)
f""" <a href="{add_query_param(self.base_url, "fmt", "pdf")
}">{scu.ICON_PDF}</a>"""
)
H.append("</span>")
H.append("</p>")
@ -582,9 +602,11 @@ class GenTable:
for line in data_list:
Pt.append(
[
Paragraph(SU(str(x)), CellStyle)
if (not isinstance(x, Paragraph))
else x
(
Paragraph(SU(str(x)), CellStyle)
if (not isinstance(x, Paragraph))
else x
)
for x in line
]
)

View File

@ -109,7 +109,7 @@ def sidebar_common():
{sidebar_dept()}
<h2 class="insidebar">Scolarité</h2>
<a href="{scu.ScoURL()}" class="sidebar">Semestres</a> <br>
<a href="{scu.NotesURL()}" class="sidebar">Programmes</a> <br>
<a href="{scu.NotesURL()}" class="sidebar">Formations</a> <br>
"""
]
if current_user.has_permission(Permission.AbsChange):
@ -175,10 +175,9 @@ def sidebar(etudid: int = None):
inscription = etud.inscription_courante()
if inscription:
formsemestre = inscription.formsemestre
nbabs, nbabsjust = sco_assiduites.formsemestre_get_assiduites_count(
nbabsnj, nbabsjust, _ = sco_assiduites.formsemestre_get_assiduites_count(
etudid, formsemestre
)
nbabsnj = nbabs - nbabsjust
H.append(
f"""<span title="absences du {
formsemestre.date_debut.strftime("%d/%m/%Y")
@ -186,7 +185,7 @@ def sidebar(etudid: int = None):
formsemestre.date_fin.strftime("%d/%m/%Y")
}">({
sco_preferences.get_preference("assi_metrique", None)})
<br>{ nbabsjust } J., { nbabsnj } N.J.</span>"""
<br>{nbabsjust:1g} J., {nbabsnj:1g} N.J.</span>"""
)
H.append("<ul>")
if current_user.has_permission(Permission.AbsChange):

View File

@ -124,9 +124,9 @@ def table_billets(
else:
billet_dict["nomprenom"] = billet.etudiant.nomprenom
billet_dict["_nomprenom_order"] = billet.etudiant.sort_key
billet_dict[
"_nomprenom_td_attrs"
] = f'id="{billet.etudiant.id}" class="etudinfo"'
billet_dict["_nomprenom_td_attrs"] = (
f'id="{billet.etudiant.id}" class="etudinfo"'
)
if with_links:
billet_dict["_nomprenom_target"] = url_for(
"scolar.fiche_etud",

View File

@ -34,7 +34,7 @@ Il suffit d'appeler abs_notify() après chaque ajout d'absence.
import datetime
from typing import Optional
from flask import g, url_for
from flask import flash, g, url_for
from flask_mail import Message
from app import db
@ -46,7 +46,6 @@ from app.models.etudiants import Identite
from app.models.events import Scolog
from app.models.formsemestre import FormSemestre
import app.scodoc.notesdb as ndb
from app.scodoc import sco_etud
from app.scodoc import sco_preferences
from app.scodoc import sco_utils as scu
@ -68,7 +67,7 @@ def abs_notify(etudid: int, date: str | datetime.datetime):
if not formsemestre:
return # non inscrit a la date, pas de notification
nbabs, nbabsjust = sco_assiduites.get_assiduites_count_in_interval(
_, nbabsjust, nbabs = sco_assiduites.get_assiduites_count_in_interval(
etudid,
metrique=scu.translate_assiduites_metric(
sco_preferences.get_preference(
@ -283,10 +282,17 @@ def abs_notification_message(
)
template = prefs["abs_notification_mail_tmpl"]
txt = ""
if template:
txt = prefs["abs_notification_mail_tmpl"] % values
try:
txt = prefs["abs_notification_mail_tmpl"] % values
except KeyError:
flash("Mail non envoyé: format invalide (voir paramétrage)")
log("abs_notification_message: invalid key in abs_notification_mail_tmpl")
txt = ""
else:
log("abs_notification_message: empty template, not sending message")
if not txt:
return None
subject = f"""[ScoDoc] Trop d'absences pour {etud.nomprenom}"""

View File

@ -20,8 +20,11 @@ class Trace:
Role des fichiers traces :
- Sauvegarder la date de dépôt du fichier
- Sauvegarder la date de suppression du fichier (dans le cas de plusieurs fichiers pour un même justif)
- Sauvegarder l'user_id de l'utilisateur ayant déposé le fichier (=> permet de montrer les fichiers qu'aux personnes qui l'on déposé / qui ont le rôle AssiJustifView)
- Sauvegarder la date de suppression du fichier
(dans le cas de plusieurs fichiers pour un même justif)
- Sauvegarder l'user_id de l'utilisateur ayant déposé le fichier
(=> permet de montrer les fichiers qu'aux personnes
qui l'on déposé / qui ont le rôle AssiJustifView)
_trace.csv :
nom_fichier_srv,datetime_depot,datetime_suppr,user_id

View File

@ -17,7 +17,7 @@ from app.models import (
ModuleImplInscription,
ScoDocSiteConfig,
)
from app.models.assiduites import Assiduite, Justificatif, compute_assiduites_justified
from app.models.assiduites import Assiduite, Justificatif
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_preferences
from app.scodoc import sco_cache
@ -37,21 +37,34 @@ class CountCalculator:
------------
1. Initialisation : La classe peut être initialisée avec des horaires personnalisés
pour le matin, le midi et le soir, ainsi qu'une durée de pause déjeuner.
Si non spécifiés, les valeurs par défaut seront chargées depuis la configuration `ScoDocSiteConfig`.
Si non spécifiés, les valeurs par défaut seront
chargées depuis la configuration `ScoDocSiteConfig`.
Exemple d'initialisation :
calculator = CountCalculator(morning="08:00", noon="13:00", evening="18:00", nb_heures_par_jour=8)
calculator = CountCalculator(
morning="08:00",
noon="13:00",
evening="18:00",
nb_heures_par_jour=8
)
2. Ajout d'assiduités :
Exemple d'ajout d'assiduité :
- calculator.compute_assiduites(etudiant.assiduites)
- calculator.compute_assiduites([<Assiduite>, <Assiduite>, <Assiduite>, <Assiduite>])
- calculator.compute_assiduites([
<Assiduite>,
<Assiduite>,
<Assiduite>,
<Assiduite>
])
3. Accès aux métriques : Après l'ajout des assiduités, on peut accéder aux métriques telles que :
3. Accès aux métriques : Après l'ajout des assiduités,
on peut accéder aux métriques telles que :
le nombre total de jours, de demi-journées et d'heures calculées.
Exemple d'accès aux métriques :
metrics = calculator.to_dict()
4.Réinitialisation du comptage: Si besoin on peut réinitialisé le compteur sans perdre la configuration
4.Réinitialisation du comptage: Si besoin on peut réinitialiser
le compteur sans perdre la configuration
(horaires personnalisés)
Exemple de réinitialisation :
calculator.reset()
@ -61,8 +74,10 @@ class CountCalculator:
- reset() : Réinitialise les compteurs de la classe.
- add_half_day(day: date, is_morning: bool) : Ajoute une demi-journée au comptage.
- add_day(day: date) : Ajoute un jour complet au comptage.
- compute_long_assiduite(assi: Assiduite) : Traite les assiduités s'étendant sur plus d'un jour.
- compute_assiduites(assiduites: Query | list) : Calcule les métriques pour une collection d'assiduités.
- compute_long_assiduite(assi: Assiduite) : Traite les assiduités
s'étendant sur plus d'un jour.
- compute_assiduites(assiduites: Query | list) : Calcule les métriques pour
une collection d'assiduités.
- to_dict() : Retourne les métriques sous forme de dictionnaire.
Notes :
@ -85,17 +100,14 @@ class CountCalculator:
evening: str = None,
nb_heures_par_jour: int = None,
) -> None:
# Transformation d'une heure "HH:MM" en time(h,m)
STR_TIME = lambda x: time(*list(map(int, x.split(":"))))
self.morning: time = STR_TIME(
self.morning: time = str_to_time(
morning if morning else ScoDocSiteConfig.get("assi_morning_time", "08:00")
)
# Date pivot pour déterminer les demi-journées
self.noon: time = STR_TIME(
self.noon: time = str_to_time(
noon if noon else ScoDocSiteConfig.get("assi_lunch_time", "13:00")
)
self.evening: time = STR_TIME(
self.evening: time = str_to_time(
evening if evening else ScoDocSiteConfig.get("assi_afternoon_time", "18:00")
)
@ -103,10 +115,6 @@ class CountCalculator:
scu.NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
)
delta_total: timedelta = datetime.combine(
date.min, self.evening
) - datetime.combine(date.min, self.morning)
# Sera utilisé pour les assiduités longues (> 1 journée)
self.nb_heures_par_jour = (
nb_heures_par_jour
@ -340,17 +348,27 @@ class CountCalculator:
def setup_data(self):
"""Met en forme les données
pour les journées et les demi-journées : au lieu d'avoir list[str] on a le nombre (len(list[str]))
pour les journées et les demi-journées :
au lieu d'avoir list[str] on a le nombre (len(list[str]))
"""
for key in self.data:
self.data[key]["journee"] = len(self.data[key]["journee"])
self.data[key]["demi"] = len(self.data[key]["demi"])
for value in self.data.values():
value["journee"] = len(value["journee"])
value["demi"] = len(value["demi"])
def to_dict(self, only_total: bool = True) -> dict[str, int | float]:
"""Retourne les métriques sous la forme d'un dictionnaire"""
return self.data["total"] if only_total else self.data
def str_to_time(time_str: str) -> time:
"""Convertit une chaîne de caractères représentant une heure en objet time
exemples :
- "08:00" -> time(8, 0)
- "18:00:00" -> time(18, 0, 0)
"""
return time(*list(map(int, time_str.split(":"))))
def get_assiduites_stats(
assiduites: Query, metric: str = "all", filtered: dict[str, object] = None
) -> dict[str, int | float]:
@ -643,7 +661,7 @@ def create_absence_billet(
db.session.add(justi)
db.session.commit()
compute_assiduites_justified(etud.id, [justi])
justi.justifier_assiduites()
calculator: CountCalculator = CountCalculator()
calculator.compute_assiduites([assiduite_unique])
@ -651,9 +669,9 @@ def create_absence_billet(
# Gestion du cache
def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int]:
def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non justifiées, nb abs justifiées)
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
Utilise un cache.
"""
metrique = sco_preferences.get_preference("assi_metrique", sem["formsemestre_id"])
@ -667,19 +685,19 @@ def get_assiduites_count(etudid: int, sem: dict) -> tuple[int, int]:
def formsemestre_get_assiduites_count(
etudid: int, formsemestre: FormSemestre, moduleimpl_id: int = None
) -> tuple[int, int]:
) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant dans ce semestre:
tuple (nb abs non justifiées, nb abs justifiées)
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
Utilise un cache.
"""
metrique = sco_preferences.get_preference("assi_metrique", formsemestre.id)
return get_assiduites_count_in_interval(
etudid,
date_debut=scu.localize_datetime(
datetime.combine(formsemestre.date_debut, time(8, 0))
datetime.combine(formsemestre.date_debut, time(0, 0))
),
date_fin=scu.localize_datetime(
datetime.combine(formsemestre.date_fin, time(18, 0))
datetime.combine(formsemestre.date_fin, time(23, 0))
),
metrique=scu.translate_assiduites_metric(metrique),
moduleimpl_id=moduleimpl_id,
@ -694,14 +712,14 @@ def get_assiduites_count_in_interval(
date_debut: datetime = None,
date_fin: datetime = None,
moduleimpl_id: int = None,
):
) -> tuple[int, int, int]:
"""Les comptes d'absences de cet étudiant entre ces deux dates, incluses:
tuple (nb abs, nb abs justifiées)
tuple (nb abs non justifiées, nb abs justifiées, nb abs total)
On peut spécifier les dates comme datetime ou iso.
Utilise un cache.
"""
date_debut_iso = date_debut_iso or date_debut.isoformat()
date_fin_iso = date_fin_iso or date_fin.isoformat()
date_debut_iso = date_debut_iso or date_debut.strftime("%Y-%m-%d")
date_fin_iso = date_fin_iso or date_fin.strftime("%Y-%m-%d")
key = f"{etudid}_{date_debut_iso}_{date_fin_iso}_assiduites"
r = sco_cache.AbsSemEtudCache.get(key)
@ -726,9 +744,10 @@ def get_assiduites_count_in_interval(
if not ans:
log("warning: get_assiduites_count failed to cache")
nb_abs: dict = r["absent"][metrique]
nb_abs_just: dict = r["absent_just"][metrique]
return (nb_abs, nb_abs_just)
nb_abs: int = r["absent"][metrique]
nb_abs_nj: int = r["absent_non_just"][metrique]
nb_abs_just: int = r["absent_just"][metrique]
return (nb_abs_nj, nb_abs_just, nb_abs)
def invalidate_assiduites_count(etudid: int, sem: dict):
@ -756,7 +775,6 @@ def invalidate_assiduites_etud_date(etudid: int, the_date: datetime):
pour cet étudiant et cette date.
Invalide cache absence et caches semestre
"""
from app.scodoc import sco_compute_moy
# Semestres a cette date:
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)
@ -776,17 +794,9 @@ def invalidate_assiduites_etud_date(etudid: int, the_date: datetime):
# Invalide les PDF et les absences:
for sem in sems:
# Inval cache bulletin et/ou note_table
if sco_compute_moy.formsemestre_expressions_use_abscounts(
sem["formsemestre_id"]
):
# certaines formules utilisent les absences
pdfonly = False
else:
# efface toujours le PDF car il affiche en général les absences
pdfonly = True
# efface toujours le PDF car il affiche en général les absences
sco_cache.invalidate_formsemestre(
formsemestre_id=sem["formsemestre_id"], pdfonly=pdfonly
formsemestre_id=sem["formsemestre_id"], pdfonly=True
)
# Inval cache compteurs absences:
@ -818,4 +828,4 @@ def simple_invalidate_cache(obj: dict, etudid: str | int = None):
pattern=f"tableau-etud-{etudid}*"
)
# Invalide les tableaux "bilan dept"
sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern=f"tableau-dept*")
sco_cache.RequeteTableauAssiduiteCache.delete_pattern(pattern="tableau-dept*")

View File

@ -55,7 +55,7 @@ from app.models import (
ScoDocSiteConfig,
)
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError, ScoTemporaryError
from app.scodoc import html_sco_header
from app.scodoc import htmlutils
from app.scodoc import sco_assiduites
@ -196,7 +196,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
pid = partition["partition_id"]
partitions_etud_groups[pid] = sco_groups.get_etud_groups_in_partition(pid)
# --- Absences
I["nbabs"], I["nbabsjust"] = sco_assiduites.get_assiduites_count(etudid, nt.sem)
_, I["nbabsjust"], I["nbabs"] = sco_assiduites.get_assiduites_count(etudid, nt.sem)
# --- Decision Jury
infos, dpv = etud_descr_situation_semestre(
@ -318,7 +318,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
if nt.bonus_ues is not None:
u["cur_moy_ue_txt"] += " (+ues)"
u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"])
if ue_status["coef_ue"] != None:
if ue_status["coef_ue"] is not None:
u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"])
else:
u["coef_ue_txt"] = "-"
@ -346,14 +346,14 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"):
# auparavant on filtrait les modules sans notes
# si ue_status['cur_moy_ue'] != 'NA' alors u['modules'] = [] (pas de moyenne => pas de modules)
u[
"modules_capitalized"
] = [] # modules de l'UE capitalisée (liste vide si pas capitalisée)
u["modules_capitalized"] = (
[]
) # modules de l'UE capitalisée (liste vide si pas capitalisée)
if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None:
sem_origin = db.session.get(FormSemestre, ue_status["formsemestre_id"])
u[
"ue_descr_txt"
] = f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}'
u["ue_descr_txt"] = (
f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}'
)
u["ue_descr_html"] = (
f"""<a href="{ url_for( 'notes.formsemestre_bulletinetud',
scodoc_dept=g.scodoc_dept, formsemestre_id=sem_origin.id, etudid=etudid)}"
@ -471,7 +471,7 @@ def _ue_mod_bulletin(
) # peut etre 'NI'
is_malus = mod["module"]["module_type"] == ModuleType.MALUS
if bul_show_abs_modules:
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, sem)
_, nbabsjust, nbabs = sco_assiduites.get_assiduites_count(etudid, sem)
mod_abs = [nbabs, nbabsjust]
mod["mod_abs_txt"] = scu.fmt_abs(mod_abs)
else:
@ -558,6 +558,8 @@ def _ue_mod_bulletin(
).order_by(Evaluation.numero, Evaluation.date_debut)
# (plus ancienne d'abord)
for e in all_evals:
if e.is_blocked():
continue # ignore évaluations bloquées
if not e.visibulletin and version != "long":
continue
is_complete = e.id in complete_eval_ids
@ -610,19 +612,22 @@ def _ue_mod_bulletin(
e_dict["coef_txt"] = ""
else:
e_dict["coef_txt"] = scu.fmt_coef(e.coefficient)
if e.evaluation_type == scu.EVALUATION_RATTRAPAGE:
if e.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE:
e_dict["coef_txt"] = "rat."
elif e.evaluation_type == scu.EVALUATION_SESSION2:
elif e.evaluation_type == Evaluation.EVALUATION_SESSION2:
e_dict["coef_txt"] = "Ses. 2"
if modimpl_results.evaluations_etat[e.id].nb_attente:
mod_attente = True # une eval en attente dans ce module
if ((not is_malus) or (val != "NP")) and (
(e.evaluation_type == scu.EVALUATION_NORMALE or not np.isnan(val))
(
e.evaluation_type == Evaluation.EVALUATION_NORMALE
or not np.isnan(val)
)
):
# ne liste pas les eval malus sans notes
# ni les rattrapages et sessions 2 si pas de note
# ni les rattrapages, sessions 2 et bonus si pas de note
if e.id in complete_eval_ids:
mod["evaluations"].append(e_dict)
else:
@ -731,7 +736,11 @@ def etud_descr_situation_semestre(
infos["refcomp_specialite_long"] = ""
if formsemestre.formation.is_apc():
res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
parcour_id = res.etuds_parcour_id[etudid]
try:
parcour_id = res.etuds_parcour_id[etudid]
except KeyError as exc:
log("sco_bulletins: ScoTemporaryError 240222")
raise ScoTemporaryError() from exc
parcour: ApcParcours = (
db.session.get(ApcParcours, parcour_id) if parcour_id is not None else None
)

View File

@ -50,14 +50,11 @@ import traceback
import reportlab
from reportlab.platypus import (
SimpleDocTemplate,
DocIf,
Paragraph,
Spacer,
Frame,
PageBreak,
)
from reportlab.platypus import Table, TableStyle, Image, KeepInFrame
from reportlab.platypus import Table, KeepInFrame
from flask import request
from flask_login import current_user
@ -213,26 +210,26 @@ class BulletinGenerator:
story.append(PageBreak()) # insert page break at end
return story
else:
# Generation du document PDF
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
report = io.BytesIO() # in-memory document, no disk file
document = sco_pdf.BaseDocTemplate(report)
document.addPageTemplates(
sco_pdf.ScoDocPageTemplate(
document,
author="%s %s (E. Viennet) [%s]"
% (sco_version.SCONAME, sco_version.SCOVERSION, self.description),
title=f"""Bulletin {sem["titremois"]} de {etat_civil}""",
subject="Bulletin de note",
margins=self.margins,
server_name=self.server_name,
filigranne=self.filigranne,
preferences=sco_preferences.SemPreferences(formsemestre_id),
)
# Generation du document PDF
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
report = io.BytesIO() # in-memory document, no disk file
document = sco_pdf.BaseDocTemplate(report)
document.addPageTemplates(
sco_pdf.ScoDocPageTemplate(
document,
author=f"""{sco_version.SCONAME} {
sco_version.SCOVERSION} (E. Viennet) [{self.description}]""",
title=f"""Bulletin {sem["titremois"]} de {etat_civil}""",
subject="Bulletin de note",
margins=self.margins,
server_name=self.server_name,
filigranne=self.filigranne,
preferences=sco_preferences.SemPreferences(formsemestre_id),
)
document.build(story)
data = report.getvalue()
)
document.build(story)
data = report.getvalue()
return data
def buildTableObject(self, P, pdfTableStyle, colWidths):

View File

@ -25,7 +25,7 @@
#
##############################################################################
"""Génération du bulletin en format JSON
"""Génération du bulletin en format JSON (formations classiques)
"""
import datetime
@ -296,7 +296,7 @@ def formsemestre_bulletinetud_published_dict(
# --- Absences
if prefs["bul_show_abs"]:
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, sem)
_, nbabsjust, nbabs = sco_assiduites.get_assiduites_count(etudid, sem)
d["absences"] = dict(nbabs=nbabs, nbabsjust=nbabsjust)
# --- Décision Jury

View File

@ -62,10 +62,12 @@ from flask import g, request
from app import log, ScoValueError
from app.comp.res_but import ResultatsSemestreBUT
from app.models import FormSemestre, Identite
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_pdf
from app.scodoc import sco_preferences
from app.scodoc import (
codes_cursus,
sco_cache,
sco_pdf,
sco_preferences,
)
from app.scodoc.sco_logos import find_logo
import app.scodoc.sco_utils as scu
@ -111,7 +113,8 @@ def assemble_bulletins_pdf(
return data
def replacement_function(match):
def replacement_function(match) -> str:
"remplace logo par balise html img"
balise = match.group(1)
name = match.group(3)
logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id)
@ -211,7 +214,11 @@ def process_field(
)
def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"):
def get_formsemestre_bulletins_pdf(
formsemestre_id,
version="selectedevals",
groups_infos=None, # si indiqué, ne prend que ces groupes
):
"Document pdf avec tous les bulletins du semestre, et filename"
from app.but import bulletin_but_court
from app.scodoc import sco_bulletins
@ -226,13 +233,22 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"):
raise ScoValueError(
"get_formsemestre_bulletins_pdf: version de bulletin demandée invalide !"
)
cached = sco_cache.SemBulletinsPDFCache.get(str(formsemestre_id) + "_" + version)
etuds = formsemestre.get_inscrits(include_demdef=True, order=True)
if groups_infos is None:
gr_key = ""
else:
etudids = {m["etudid"] for m in groups_infos.members}
etuds = [etud for etud in etuds if etud.id in etudids]
gr_key = groups_infos.get_groups_key()
cache_key = str(formsemestre_id) + "_" + version + "_" + gr_key
cached = sco_cache.SemBulletinsPDFCache.get(cache_key)
if cached:
return cached[1], cached[0]
fragments = []
# Make each bulletin
for etud in formsemestre.get_inscrits(include_demdef=True, order=True):
for etud in etuds:
if version == "butcourt":
frag = bulletin_but_court.bulletin_but_court_pdf_frag(etud, formsemestre)
else:
@ -262,7 +278,7 @@ def get_formsemestre_bulletins_pdf(formsemestre_id, version="selectedevals"):
sco_pdf.PDFLOCK.release()
#
date_iso = time.strftime("%Y-%m-%d")
filename = "bul-%s-%s.pdf" % (formsemestre.titre_num(), date_iso)
filename = f"bul-{formsemestre.titre_num()}-{date_iso}.pdf"
filename = scu.unescape_html(filename).replace(" ", "_").replace("&", "")
# fill cache
sco_cache.SemBulletinsPDFCache.set(

View File

@ -51,7 +51,7 @@ from reportlab.lib.colors import Color, blue
from reportlab.lib.units import cm, mm
from reportlab.platypus import KeepTogether, Paragraph, Spacer, Table
from app.models import BulAppreciations
from app.models import BulAppreciations, Evaluation
import app.scodoc.sco_utils as scu
from app.scodoc import (
gen_tables,
@ -715,9 +715,15 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator):
eval_style = ""
t = {
"module": '<bullet indent="2mm">&bull;</bullet>&nbsp;' + e["name"],
"coef": ("<i>" + e["coef_txt"] + "</i>")
if prefs["bul_show_coef"]
else "",
"coef": (
(
f"<i>{e['coef_txt']}</i>"
if e["evaluation_type"] != Evaluation.EVALUATION_BONUS
else "bonus"
)
if prefs["bul_show_coef"]
else ""
),
"_hidden": hidden,
"_module_target": e["target_html"],
# '_module_help' : ,

View File

@ -260,7 +260,7 @@ def make_xml_formsemestre_bulletinetud(
numero=str(mod["numero"]),
titre=quote_xml_attr(mod["titre"]),
abbrev=quote_xml_attr(mod["abbrev"]),
code_apogee=quote_xml_attr(mod["code_apogee"])
code_apogee=quote_xml_attr(mod["code_apogee"]),
# ects=ects ects des modules maintenant inutilisés
)
x_ue.append(x_mod)
@ -347,7 +347,7 @@ def make_xml_formsemestre_bulletinetud(
# --- Absences
if sco_preferences.get_preference("bul_show_abs", formsemestre_id):
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, sem)
_, nbabsjust, nbabs = sco_assiduites.get_assiduites_count(etudid, sem)
doc.append(Element("absences", nbabs=str(nbabs), nbabsjust=str(nbabsjust)))
# --- Decision Jury
if (

View File

@ -67,7 +67,7 @@ class ScoDocCache:
keys are prefixed by the current departement: g.scodoc_dept MUST be set.
"""
timeout = None # ttl, infinite by default
timeout = 3600 # ttl, one hour by default
prefix = ""
verbose = False # if true, verbose logging (debug)
@ -201,7 +201,7 @@ class AbsSemEtudCache(ScoDocCache):
"""
prefix = "ABSE"
timeout = 60 * 60 # ttl 60 minutes
timeout = 600 # ttl 10 minutes
class SemBulletinsPDFCache(ScoDocCache):
@ -233,7 +233,6 @@ class SemInscriptionsCache(ScoDocCache):
"""
prefix = "SI"
duration = 12 * 60 * 60 # ttl 12h
class TableRecapCache(ScoDocCache):
@ -243,7 +242,6 @@ class TableRecapCache(ScoDocCache):
"""
prefix = "RECAP"
duration = 12 * 60 * 60 # ttl 12h
class TableRecapWithEvalsCache(ScoDocCache):
@ -253,7 +251,6 @@ class TableRecapWithEvalsCache(ScoDocCache):
"""
prefix = "RECAPWITHEVALS"
duration = 12 * 60 * 60 # ttl 12h
class TableJuryCache(ScoDocCache):
@ -263,7 +260,6 @@ class TableJuryCache(ScoDocCache):
"""
prefix = "RECAPJURY"
duration = 12 * 60 * 60 # ttl 12h
class TableJuryWithEvalsCache(ScoDocCache):
@ -273,7 +269,6 @@ class TableJuryWithEvalsCache(ScoDocCache):
"""
prefix = "RECAPJURYWITHEVALS"
duration = 12 * 60 * 60 # ttl 12h
def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=False)

View File

@ -114,7 +114,7 @@ def index_html(showcodes=0, showsemtable=0):
# aucun semestre courant: affiche aide
H.append(
"""<h2 class="listesems">Aucune session en cours !</h2>
<p>Pour ajouter une session, aller dans <a href="Notes" id="link-programmes">Programmes</a>,
<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
@ -208,6 +208,8 @@ def index_html(showcodes=0, showsemtable=0):
"""<hr>
<h3>Assistance</h3>
<ul>
<li><a class="stdlink" href="https://scodoc.org/Contact" target="_blank"
rel="noopener noreferrer">Contact (Discord)</a></li>
<li><a class="stdlink" href="sco_dump_and_send_db">Envoyer données</a></li>
</ul>
"""
@ -336,15 +338,15 @@ def _style_sems(sems):
else:
sem["semestre_id_n"] = sem["semestre_id"]
# pour édition codes Apogée:
sem[
"_etapes_apo_str_td_attrs"
] = f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['etapes_apo_str']}" """
sem[
"_elt_annee_apo_td_attrs"
] = f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_annee_apo']}" """
sem[
"_elt_sem_apo_td_attrs"
] = f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_sem_apo']}" """
sem["_etapes_apo_str_td_attrs"] = (
f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['etapes_apo_str']}" """
)
sem["_elt_annee_apo_td_attrs"] = (
f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_annee_apo']}" """
)
sem["_elt_sem_apo_td_attrs"] = (
f""" data-oid="{sem['formsemestre_id']}" data-value="{sem['elt_sem_apo']}" """
)
def delete_dept(dept_id: int) -> str:

View File

@ -28,7 +28,6 @@
from flask.templating import render_template
from app import db
from app.but import apc_edit_ue
from app.models import UniteEns, Matiere, Module, FormSemestre, ModuleImpl
from app.models.validations import ScolarFormSemestreValidation
from app.scodoc import codes_cursus
@ -48,6 +47,8 @@ def html_edit_formation_apc(
- Les ressources
- Les SAÉs
"""
from app.but import cursus_but
cursus = formation.get_cursus()
assert cursus.APC_SAE
@ -101,18 +102,26 @@ def html_edit_formation_apc(
),
}
html_ue_warning = {
semestre_idx: cursus_but.formation_semestre_niveaux_warning(
formation, semestre_idx
)
for semestre_idx in semestre_ids
}
H = [
render_template(
"pn/form_ues.j2",
formation=formation,
semestre_ids=semestre_ids,
editable=editable,
tag_editable=tag_editable,
icons=icons,
ues_by_sem=ues_by_sem,
ects_by_sem=ects_by_sem,
scu=scu,
codes_cursus=codes_cursus,
ects_by_sem=ects_by_sem,
editable=editable,
formation=formation,
html_ue_warning=html_ue_warning,
icons=icons,
scu=scu,
semestre_ids=semestre_ids,
tag_editable=tag_editable,
ues_by_sem=ues_by_sem,
),
]
for semestre_idx in semestre_ids:

View File

@ -412,7 +412,7 @@ def module_move(module_id, after=0, redirect=True):
db.session.add(neigh)
db.session.commit()
module.formation.invalidate_cached_sems()
# redirect to ue_list page:
# redirect to ue_table page:
if redirect:
return flask.redirect(
url_for(
@ -454,7 +454,7 @@ def ue_move(ue_id, after=0, redirect=1):
db.session.commit()
ue.formation.invalidate_cached_sems()
# redirect to ue_list page
# redirect to ue_table page
if redirect:
return flask.redirect(
url_for(

View File

@ -106,9 +106,9 @@ def do_module_create(args) -> int:
if int(args.get("semestre_id", 0)) != ue.semestre_idx:
raise ScoValueError("Formation incompatible: contacter le support ScoDoc")
# create
cnx = ndb.GetDBConnexion()
module_id = _moduleEditor.create(cnx, args)
log(f"do_module_create: created {module_id} with {args}")
module = Module.create_from_dict(args)
db.session.commit()
log(f"do_module_create: created {module.id} with {args}")
# news
ScolarNews.add(
@ -117,7 +117,7 @@ def do_module_create(args) -> int:
text=f"Modification de la formation {formation.acronyme}",
)
formation.invalidate_cached_sems()
return module_id
return module.id
def module_create(
@ -666,7 +666,7 @@ def module_edit(
"explanation": "numéro (1, 2, 3, 4, ...) pour ordre d'affichage",
"type": "int",
"default": default_num,
"allow_null": False,
"allow_null": True,
},
),
]
@ -682,8 +682,11 @@ def module_edit(
"input_type": "checkbox",
"vertical": True,
"dom_id": "tf_module_parcours",
"labels": [parcour.libelle for parcour in ref_comp.parcours]
+ ["Tous (tronc commun)"],
"labels": [
f"&nbsp; {parcour.libelle} (<b>{parcour.code}</b>)"
for parcour in ref_comp.parcours
]
+ ["&nbsp; Tous (tronc commun)"],
"allowed_values": [
str(parcour.id) for parcour in ref_comp.parcours
]
@ -811,6 +814,10 @@ def module_edit(
)
)
else:
if isinstance(tf[2]["numero"], str):
tf[2]["numero"] = tf[2]["numero"].strip()
if not isinstance(tf[2]["numero"], int) and not tf[2]["numero"]:
tf[2]["numero"] = tf[2]["numero"] or default_num
if create:
if not matiere_id:
# formulaire avec choix UE de rattachement

View File

@ -766,7 +766,7 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list
"libjs/jQuery-tagEditor/jquery.caret.min.js",
"js/module_tag_editor.js",
],
page_title=f"Programme {formation.acronyme} v{formation.version}",
page_title=f"Formation {formation.acronyme} v{formation.version}",
),
f"""<h2>{formation.html()} {lockicon}
</h2>
@ -888,11 +888,13 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
H.append(
f"""
<div class="formation_ue_list">
<div class="ue_list_tit">Programme pédagogique:</div>
<div class="ue_list_tit">Formation (programme pédagogique):</div>
<form>
<input type="checkbox" class="sco_tag_checkbox"
{'checked' if show_tags else ''}
> Montrer les tags des modules voire en ajouter <i>(ceux correspondant aux titres des compétences étant ajoutés par défaut)</i></input>
> Montrer les tags des modules voire en ajouter
<i>(ceux correspondant aux titres des compétences étant ajoutés par défaut)</i>
</input>
</form>
"""
)
@ -1054,7 +1056,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
)
# <li>(debug) <a class="stdlink" href="check_form_integrity?formation_id=%(formation_id)s">Vérifier cohérence</a></li>
warn, _ = sco_formsemestre_validation.check_formation_ues(formation_id)
warn, _ = sco_formsemestre_validation.check_formation_ues(formation)
H.append(warn)
H.append(html_sco_header.sco_footer())

View File

@ -70,9 +70,9 @@ def evaluation_check_absences(evaluation: Evaluation):
deb <= Assiduite.date_fin,
)
abs_etudids = set(assi.etudid for assi in assiduites)
abs_nj_etudids = set(assi.etudid for assi in assiduites if assi.est_just is False)
just_etudids = set(assi.etudid for assi in assiduites if assi.est_just is True)
abs_etudids = {assi.etudid for assi in assiduites}
abs_nj_etudids = {assi.etudid for assi in assiduites if assi.est_just is False}
just_etudids = {assi.etudid for assi in assiduites if assi.est_just is True}
# Les notes:
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)

View File

@ -31,96 +31,15 @@
import flask
from flask import url_for, g
from flask_login import current_user
import sqlalchemy as sa
from app import db, log
from app.models import Evaluation
from app.models.evaluations import check_convert_evaluation_args
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc.sco_exceptions import AccessDenied
from app.scodoc import sco_cache
from app.scodoc import sco_moduleimpl
_evaluationEditor = ndb.EditableTable(
"notes_evaluation",
"evaluation_id",
(
"evaluation_id",
"moduleimpl_id",
"date_debut",
"date_fin",
"description",
"note_max",
"coefficient",
"visibulletin",
"publish_incomplete",
"evaluation_type",
"numero",
),
sortkey="numero, date_debut desc", # plus recente d'abord
output_formators={
"numero": ndb.int_null_is_zero,
},
input_formators={
"visibulletin": bool,
"publish_incomplete": bool,
"evaluation_type": int,
},
)
def get_evaluations_dict(args: dict) -> list[dict]:
"""Liste evaluations, triées numero (or most recent date first).
Fonction de transition pour ancien code ScoDoc7.
Ajoute les champs:
'duree' : '2h30'
'matin' : 1 (commence avant 12:00) ou 0
'apresmidi' : 1 (termine après 12:00) ou 0
'descrheure' : ' de 15h00 à 16h30'
"""
# calcule duree (chaine de car.) de chaque evaluation et ajoute jour_iso, matin, apresmidi
return [
e.to_dict()
for e in Evaluation.query.filter_by(**args).order_by(
sa.desc(Evaluation.numero), sa.desc(Evaluation.date_debut)
)
]
def do_evaluation_list_in_formsemestre(formsemestre_id):
"list evaluations in this formsemestre"
mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id)
evals = []
for modimpl in mods:
evals += get_evaluations_dict(args={"moduleimpl_id": modimpl["moduleimpl_id"]})
return evals
def do_evaluation_edit(args):
"edit an evaluation"
evaluation_id = args["evaluation_id"]
evaluation: Evaluation = db.session.get(Evaluation, evaluation_id)
if evaluation is None:
raise ValueError("evaluation inexistante !")
if not evaluation.moduleimpl.can_edit_evaluation(current_user):
raise AccessDenied(
f"Modification évaluation impossible pour {current_user.get_nomplogin()}"
)
args["moduleimpl_id"] = evaluation.moduleimpl.id
check_convert_evaluation_args(evaluation.moduleimpl, args)
cnx = ndb.GetDBConnexion()
_evaluationEditor.edit(cnx, args)
# inval cache pour ce semestre
sco_cache.invalidate_formsemestre(
formsemestre_id=evaluation.moduleimpl.formsemestre_id
)
# ancien _notes_getall

View File

@ -31,14 +31,12 @@ import datetime
import time
import flask
from flask import url_for, render_template
from flask import g
from flask import g, render_template, request, url_for
from flask_login import current_user
from flask import request
from app import db
from app.models import Evaluation, Module, ModuleImpl
from app.models.evaluations import heure_to_time
from app.models.evaluations import heure_to_time, check_and_convert_evaluation_args
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
@ -108,7 +106,7 @@ def evaluation_create_form(
raise ValueError("missing evaluation_id parameter")
initvalues = evaluation.to_dict()
moduleimpl_id = initvalues["moduleimpl_id"]
submitlabel = "Modifier les données"
submitlabel = "Modifier l'évaluation"
action = "Modification d'une évaluation"
link = ""
# Note maximale actuelle dans cette éval ?
@ -142,6 +140,15 @@ def evaluation_create_form(
else:
poids = 0.0
initvalues[f"poids_{ue.id}"] = poids
# Blocage
if edit:
initvalues["blocked"] = evaluation.is_blocked()
initvalues["blocked_until"] = (
evaluation.blocked_until.strftime("%d/%m/%Y")
if evaluation.blocked_until
and evaluation.blocked_until < Evaluation.BLOCKED_FOREVER
else ""
)
#
form = [
("evaluation_id", {"default": evaluation_id, "input_type": "hidden"}),
@ -183,7 +190,8 @@ def evaluation_create_form(
{
"size": 6,
"type": "float", # peut être négatif (!)
"explanation": "coef. dans le module (choisi librement par l'enseignant, non utilisé pour rattrapage et 2ème session)",
"explanation": """coef. dans le module (choisi librement par
l'enseignant, non utilisé pour rattrapage, 2ème session et bonus)""",
"allow_null": False,
},
)
@ -195,7 +203,7 @@ def evaluation_create_form(
"size": 4,
"type": "float",
"title": "Notes de 0 à",
"explanation": f"barème (note max actuelle: {min_note_max_str})",
"explanation": f"""barème (note max actuelle: {min_note_max_str}).""",
"allow_null": False,
"max_value": scu.NOTES_MAX,
"min_value": min_note_max,
@ -206,7 +214,8 @@ def evaluation_create_form(
{
"size": 36,
"type": "text",
"explanation": """type d'évaluation, apparait sur le bulletins longs. Exemples: "contrôle court", "examen de TP", "examen final".""",
"explanation": """type d'évaluation, apparait sur le bulletins longs.
Exemples: "contrôle court", "examen de TP", "examen final".""",
},
),
(
@ -230,16 +239,20 @@ def evaluation_create_form(
{
"input_type": "menu",
"title": "Modalité",
"allowed_values": (
scu.EVALUATION_NORMALE,
scu.EVALUATION_RATTRAPAGE,
scu.EVALUATION_SESSION2,
),
"allowed_values": Evaluation.VALID_EVALUATION_TYPES,
"type": "int",
"labels": (
"Normale",
"Rattrapage (remplace si meilleure note)",
"Deuxième session (remplace toujours)",
(
"Bonus "
+ (
"(pondéré par poids et ajouté aux moyennes de ce module)"
if is_apc
else "(ajouté à la moyenne de ce module)"
)
),
),
},
),
@ -251,8 +264,10 @@ def evaluation_create_form(
{
"size": 6,
"type": "float",
"explanation": "importance de l'évaluation (multiplie les poids ci-dessous)",
"explanation": """importance de l'évaluation (multiplie les poids ci-dessous).
Non utilisé pour les bonus.""",
"allow_null": False,
"dom_id": "evaluation-edit-coef",
},
),
]
@ -294,6 +309,28 @@ def evaluation_create_form(
},
),
)
# Bloquage / date prise en compte
form += [
(
"blocked",
{
"input_type": "boolcheckbox",
"title": "Bloquer la prise en compte",
"explanation": """empêche la prise en compte
(ne sera pas visible sur les bulletins ni dans les tableaux)""",
"dom_id": "evaluation-edit-blocked",
},
),
(
"blocked_until",
{
"input_type": "datedmy",
"title": "Date déblocage",
"size": 12,
"explanation": "sera débloquée à partir de cette date",
},
),
]
tf = TrivialFormulator(
request.base_url,
vals,
@ -324,7 +361,9 @@ def evaluation_create_form(
+ "\n".join(H)
+ "\n"
+ tf[1]
+ render_template("scodoc/help/evaluations.j2", is_apc=is_apc)
+ render_template(
"scodoc/help/evaluations.j2", is_apc=is_apc, modimpl=modimpl
)
+ render_template("sco_timepicker.j2")
+ html_sco_header.sco_footer()
)
@ -342,6 +381,8 @@ def evaluation_create_form(
raise ScoValueError("Date (j/m/a) invalide") from exc
else:
date_debut = None
args["date_debut"] = date_debut
args["date_fin"] = date_debut # même jour
args.pop("jour", None)
if date_debut and args.get("heure_debut"):
try:
@ -350,7 +391,8 @@ def evaluation_create_form(
raise ScoValueError("Heure début invalide") from exc
args["date_debut"] = datetime.datetime.combine(date_debut, heure_debut)
args.pop("heure_debut", None)
# note: ce formulaire ne permet de créer que des évaluation avec debut et fin sur le même jour.
# note: ce formulaire ne permet de créer que des évaluations
# avec debut et fin sur le même jour.
if date_debut and args.get("heure_fin"):
try:
heure_fin = heure_to_time(args["heure_fin"])
@ -358,8 +400,22 @@ def evaluation_create_form(
raise ScoValueError("Heure fin invalide") from exc
args["date_fin"] = datetime.datetime.combine(date_debut, heure_fin)
args.pop("heure_fin", None)
# Blocage:
if args.get("blocked"):
if args.get("blocked_until"):
try:
args["blocked_until"] = datetime.datetime.strptime(
args["blocked_until"], "%d/%m/%Y"
)
except ValueError as exc:
raise ScoValueError("Date déblocage (j/m/a) invalide") from exc
else: # bloquage coché sans date
args["blocked_until"] = Evaluation.BLOCKED_FOREVER
else: # si pas coché, efface date déblocage
args["blocked_until"] = None
#
if edit:
check_and_convert_evaluation_args(args, modimpl)
evaluation.from_dict(args)
else:
# création d'une evaluation

View File

@ -40,16 +40,14 @@ from app import db
from app.auth.models import User
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Evaluation, FormSemestre, ModuleImpl
from app.models import Evaluation, FormSemestre, ModuleImpl, Module
import app.scodoc.sco_utils as scu
from app.scodoc.sco_utils import ModuleType
import app.scodoc.notesdb as ndb
from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header
from app.scodoc import sco_cal
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_edit_module
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
from app.scodoc import sco_moduleimpl
@ -114,9 +112,10 @@ def do_evaluation_etat(
nb_neutre,
nb_att,
moy, median, mini, maxi : # notes, en chaine, sur 20
last_modif: datetime,
maxi_num : note max, numérique
last_modif: datetime, *
gr_complets, gr_incomplets,
evalcomplete
evalcomplete *
}
evalcomplete est vrai si l'eval est complete (tous les inscrits
à ce module ont des notes)
@ -130,11 +129,12 @@ def do_evaluation_etat(
) # { etudid : note }
# ---- Liste des groupes complets et incomplets
E = sco_evaluation_db.get_evaluations_dict(args={"evaluation_id": evaluation_id})[0]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
is_malus = Mod["module_type"] == ModuleType.MALUS # True si module de malus
formsemestre_id = M["formsemestre_id"]
evaluation = Evaluation.get_evaluation(evaluation_id)
modimpl: ModuleImpl = evaluation.moduleimpl
module: Module = modimpl.module
is_malus = module.module_type == ModuleType.MALUS # True si module de malus
formsemestre_id = modimpl.formsemestre_id
# Si partition_id is None, prend 'all' ou bien la premiere:
if partition_id is None:
if select_first_partition:
@ -150,9 +150,7 @@ def do_evaluation_etat(
insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits(
formsemestre_id
)
insmod = sco_moduleimpl.do_moduleimpl_inscription_list(
moduleimpl_id=E["moduleimpl_id"]
)
insmod = sco_moduleimpl.do_moduleimpl_inscription_list(moduleimpl_id=modimpl.id)
insmodset = {x["etudid"] for x in insmod}
# retire de insem ceux qui ne sont pas inscrits au module
ins = [i for i in insem if i["etudid"] in insmodset]
@ -175,9 +173,9 @@ def do_evaluation_etat(
maxi_num = None
else:
median = scu.fmt_note(median_num)
moy = scu.fmt_note(moy_num, E["note_max"])
mini = scu.fmt_note(mini_num, E["note_max"])
maxi = scu.fmt_note(maxi_num, E["note_max"])
moy = scu.fmt_note(moy_num, evaluation.note_max)
mini = scu.fmt_note(mini_num, evaluation.note_max)
maxi = scu.fmt_note(maxi_num, evaluation.note_max)
# cherche date derniere modif note
if len(etuds_notes_dict):
t = [x["date"] for x in etuds_notes_dict.values()]
@ -218,25 +216,17 @@ def do_evaluation_etat(
gr_incomplets = list(group_nb_missing.keys())
gr_incomplets.sort()
if (
(total_nb_missing > 0)
and (E["evaluation_type"] != scu.EVALUATION_RATTRAPAGE)
and (E["evaluation_type"] != scu.EVALUATION_SESSION2)
):
complete = False
else:
complete = True
complete = (
(total_nb_missing == 0)
or (E["evaluation_type"] == scu.EVALUATION_RATTRAPAGE)
or (E["evaluation_type"] == scu.EVALUATION_SESSION2)
or (evaluation.evaluation_type != Evaluation.EVALUATION_NORMALE)
and not evaluation.is_blocked()
)
evalattente = (total_nb_missing > 0) and (
(total_nb_missing == total_nb_att) or E["publish_incomplete"]
(total_nb_missing == total_nb_att) or evaluation.publish_incomplete
)
# mais ne met pas en attente les evals immediates sans aucune notes:
if E["publish_incomplete"] and nb_notes == 0:
if evaluation.publish_incomplete and nb_notes == 0:
evalattente = False
# Calcul moyenne dans chaque groupe de TD
@ -247,10 +237,10 @@ def do_evaluation_etat(
{
"group_id": group_id,
"group_name": group_by_id[group_id]["group_name"],
"gr_moy": scu.fmt_note(gr_moy, E["note_max"]),
"gr_median": scu.fmt_note(gr_median, E["note_max"]),
"gr_mini": scu.fmt_note(gr_mini, E["note_max"]),
"gr_maxi": scu.fmt_note(gr_maxi, E["note_max"]),
"gr_moy": scu.fmt_note(gr_moy, evaluation.note_max),
"gr_median": scu.fmt_note(gr_median, evaluation.note_max),
"gr_mini": scu.fmt_note(gr_mini, evaluation.note_max),
"gr_maxi": scu.fmt_note(gr_maxi, evaluation.note_max),
"gr_nb_notes": len(notes),
"gr_nb_att": len([x for x in notes if x == scu.NOTES_ATTENTE]),
}
@ -283,8 +273,9 @@ def do_evaluation_etat(
def _summarize_evals_etats(etat_evals: list[dict]) -> dict:
"""Synthétise les états d'une liste d'évaluations
evals: list of mappings (etats),
utilise e["etat"]["evalcomplete"], e["etat"]["nb_notes"], e["etat"]["last_modif"]
utilise e["blocked"], e["etat"]["evalcomplete"], e["etat"]["nb_notes"], e["etat"]["last_modif"]
->
nb_evals : nb total qcq soit état
nb_eval_completes (= prises en compte)
nb_evals_en_cours (= avec des notes, mais pas complete)
nb_evals_vides (= sans aucune note)
@ -292,14 +283,16 @@ def _summarize_evals_etats(etat_evals: list[dict]) -> dict:
Une eval est "complete" ssi tous les etudiants *inscrits* ont une note.
"""
nb_evals_completes, nb_evals_en_cours, nb_evals_vides = 0, 0, 0
nb_evals_completes, nb_evals_en_cours, nb_evals_vides, nb_evals_blocked = 0, 0, 0, 0
dates = []
for e in etat_evals:
if e["etat"]["blocked"]:
nb_evals_blocked += 1
if e["etat"]["evalcomplete"]:
nb_evals_completes += 1
elif e["etat"]["nb_notes"] == 0:
nb_evals_vides += 1
else:
elif not e["etat"]["blocked"]:
nb_evals_en_cours += 1
last_modif = e["etat"]["last_modif"]
if last_modif is not None:
@ -309,6 +302,8 @@ def _summarize_evals_etats(etat_evals: list[dict]) -> dict:
last_modif = sorted(dates)[-1] if dates else ""
return {
"nb_evals": len(etat_evals),
"nb_evals_blocked": nb_evals_blocked,
"nb_evals_completes": nb_evals_completes,
"nb_evals_en_cours": nb_evals_en_cours,
"nb_evals_vides": nb_evals_vides,
@ -499,13 +494,14 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, fmt="html"):
"""Experimental: un tableau indiquant pour chaque évaluation
le nombre de jours avant la publication des notes.
N'indique pas les évaluations de rattrapage ni celles des modules de bonus/malus.
N'indique que les évaluations "normales" (pas rattrapage, ni bonus, ni session2,
ni celles des modules de bonus/malus).
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
evaluations = formsemestre.get_evaluations()
rows = []
for e in evaluations:
if (e.evaluation_type != scu.EVALUATION_NORMALE) or (
if (e.evaluation_type != Evaluation.EVALUATION_NORMALE) or (
e.moduleimpl.module.module_type == ModuleType.MALUS
):
continue
@ -519,9 +515,9 @@ 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",
"jour": (
e.date_debut.strftime("%d/%m/%Y") if e.date_debut else "sans date"
),
"_jour_target": url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
@ -611,13 +607,17 @@ def evaluation_describe(evaluation_id="", edit_in_place=True, link_saisie=True)
# Indique l'UE
ue = modimpl.module.ue
H.append(f"<p><b>UE : {ue.acronyme}</b></p>")
if (
modimpl.module.module_type == ModuleType.MALUS
or evaluation.evaluation_type == Evaluation.EVALUATION_BONUS
):
# store min/max values used by JS client-side checks:
H.append(
"""<span id="eval_note_min" class="sco-hidden">-20.</span>
<span id="eval_note_max" class="sco-hidden">20.</span>"""
)
else:
# date et absences (pas pour evals de malus)
# date et absences (pas pour evals bonus ni des modules de malus)
if evaluation.date_debut is not None:
H.append(f"<p>Réalisée le <b>{evaluation.descr_date()}</b> ")
group_id = sco_groups.get_default_group(modimpl.formsemestre_id)

View File

@ -534,7 +534,7 @@ def excel_feuille_saisie(evaluation: "Evaluation", titreannee, description, line
# description evaluation
ws.append_single_cell_row(scu.unescape_html(description), style_titres)
ws.append_single_cell_row(
f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient or 0.0):g})",
f"Evaluation {evaluation.descr_date()} (coef. {(evaluation.coefficient):g})",
style,
)
# ligne blanche

View File

@ -28,6 +28,7 @@
"""Exception handling
"""
from flask_login import current_user
import app
# --- Exceptions
@ -237,8 +238,11 @@ class ScoTemporaryError(ScoValueError):
def __init__(self, msg: str = ""):
msg = """
<p>"Erreur temporaire</p>
<p>Veuillez -essayer. Si le problème persiste, merci de contacter l'assistance ScoDoc
<p>Erreur temporaire</p>
<p>Veuillez -essayer. Si le problème persiste (ou s'il venait
à se produire fréquemment), merci de contacter l'assistance ScoDoc
(voir <a href="https://scodoc.org/Contact/">les informations de contact</a>).
</p>
"""
app.clear_scodoc_cache()
super().__init__(msg)

View File

@ -30,7 +30,7 @@
import xml.dom.minidom
import flask
from flask import flash, g, url_for
from flask import flash, g, request, url_for
from flask_login import current_user
import app.scodoc.sco_utils as scu
@ -495,7 +495,7 @@ def formation_list_table() -> GenTable:
returns a table
"""
formations: list[Formation] = Formation.query.filter_by(dept_id=g.scodoc_dept_id)
title = "Programmes pédagogiques"
title = "Formations (programmes pédagogiques)"
lockicon = scu.icontag(
"lock32_img", title="Comporte des semestres verrouillés", border="0"
)
@ -527,6 +527,12 @@ def formation_list_table() -> GenTable:
"_titre_id": f"""titre-{acronyme_no_spaces}""",
"version": formation.version or 0,
"commentaire": formation.commentaire or "",
"referentiel": (
f"""{formation.referentiel_competence.specialite} {
formation.referentiel_competence.get_version()}"""
if formation.referentiel_competence
else ""
),
}
# Ajoute les semestres associés à chaque formation:
row["formsemestres"] = formation.formsemestres.order_by(
@ -603,6 +609,7 @@ def formation_list_table() -> GenTable:
"formation_code",
"version",
"titre",
"referentiel",
"commentaire",
"sems_list_txt",
)
@ -615,6 +622,7 @@ def formation_list_table() -> GenTable:
"version": "Version",
"formation_code": "Code",
"sems_list_txt": "Semestres",
"referentiel": "Réf.",
}
return GenTable(
columns_ids=columns_ids,
@ -627,7 +635,7 @@ def formation_list_table() -> GenTable:
html_class="formation_list_table table_leftalign",
html_with_td_classes=True,
html_sortable=True,
base_url="{request.base_url}?formation_id={formation_id}",
base_url=f"{request.base_url}",
page_title=title,
pdf_title=title,
preferences=sco_preferences.SemPreferences(),

View File

@ -31,6 +31,7 @@ import flask
from flask import url_for, flash, redirect
from flask import g, request
from flask_login import current_user
import sqlalchemy as sa
from app import db
from app.auth.models import User
@ -63,8 +64,6 @@ from app.scodoc import html_sco_header
from app.scodoc import codes_cursus
from app.scodoc import sco_compute_moy
from app.scodoc import sco_edit_module
from app.scodoc import sco_edit_ue
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups_copy
from app.scodoc import sco_modalites
@ -304,12 +303,16 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
{
"input_type": "text_suggest",
"size": 50,
"title": "(Co-)Directeur(s) des études"
if index
else "Directeur des études",
"explanation": "(facultatif) taper le début du nom et choisir dans le menu"
if index
else "(obligatoire) taper le début du nom et choisir dans le menu",
"title": (
"(Co-)Directeur(s) des études"
if index
else "Directeur des études"
),
"explanation": (
"(facultatif) taper le début du nom et choisir dans le menu"
if index
else "(obligatoire) taper le début du nom et choisir dans le menu"
),
"allowed_values": allowed_user_names,
"allow_null": index, # > 0, # il faut au moins un responsable de semestre
"text_suggest_options": {
@ -356,9 +359,11 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
"title": "Semestre dans la formation",
"allowed_values": semestre_id_list,
"labels": semestre_id_labels,
"explanation": "en BUT, on ne peut pas modifier le semestre après création"
if is_apc
else "",
"explanation": (
"en BUT, on ne peut pas modifier le semestre après création"
if is_apc
else ""
),
"attributes": ['onchange="change_semestre_id();"'] if is_apc else "",
},
),
@ -568,7 +573,7 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
"input_type": "checkbox",
"title": "Publication",
"allowed_values": ["X"],
"explanation": "publier le bulletin sur le portail étudiants",
"explanation": "publier le bulletin sur la passerelle étudiants",
"labels": [""],
},
),
@ -1107,7 +1112,8 @@ def formsemestre_delete_moduleimpls(formsemestre_id, module_ids_to_del):
f"""<b>impossible de supprimer {module.code} ({module.titre or ""})
car il y a {nb_evals} évaluations définies
(<a href="{
url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
url_for("notes.moduleimpl_status",
scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
}" class="stdlink">supprimez-les d\'abord</a>)</b>"""
]
ok = False
@ -1227,7 +1233,11 @@ def formsemestre_clone(formsemestre_id):
return "".join(H) + msg + tf[1] + html_sco_header.sco_footer()
elif tf[0] == -1: # cancel
return flask.redirect(
"formsemestre_status?formsemestre_id=%s" % formsemestre_id
url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre_id,
)
)
else:
resp = User.get_user_from_nomplogin(tf[2]["responsable_id"])
@ -1327,21 +1337,12 @@ def do_formsemestre_clone(
% (pname, pvalue, formsemestre_id)
)
# 5- Copy formules utilisateur
objs = sco_compute_moy.formsemestre_ue_computation_expr_list(
cnx, args={"formsemestre_id": orig_formsemestre_id}
)
for obj in objs:
args = obj.copy()
args["formsemestre_id"] = formsemestre_id
_ = sco_compute_moy.formsemestre_ue_computation_expr_create(cnx, args)
# 6- Copie les parcours
# 5- Copie les parcours
formsemestre.parcours = formsemestre_orig.parcours
db.session.add(formsemestre)
db.session.commit()
# 7- Copy partitions and groups
# 6- Copy partitions and groups
if clone_partitions:
sco_groups_copy.clone_partitions_and_groups(
orig_formsemestre_id, formsemestre_id
@ -1350,9 +1351,9 @@ def do_formsemestre_clone(
return formsemestre_id
def formsemestre_delete(formsemestre_id):
def formsemestre_delete(formsemestre_id: int) -> str | flask.Response:
"""Delete a formsemestre (affiche avertissements)"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre: FormSemestre = FormSemestre.get_formsemestre(formsemestre_id)
H = [
html_sco_header.html_sem_header("Suppression du semestre"),
"""<div class="ue_warning"><span>Attention !</span>
@ -1370,17 +1371,18 @@ Ceci n'est possible que si :
</ol>
</div>""",
]
evals = sco_evaluation_db.do_evaluation_list_in_formsemestre(formsemestre_id)
if evals:
evaluations = (
Evaluation.query.join(ModuleImpl)
.filter_by(formsemestre_id=formsemestre.id)
.all()
)
if evaluations:
H.append(
f"""<p class="warning">Attention: il y a {len(evals)} évaluations
f"""<p class="warning">Attention: il y a {len(evaluations)} évaluations
dans ce semestre
(sa suppression entrainera l'effacement définif des notes) !</p>"""
)
submit_label = (
f"Confirmer la suppression (du semestre et des {len(evals)} évaluations !)"
)
submit_label = f"Confirmer la suppression (du semestre et des {len(evaluations)} évaluations !)"
else:
submit_label = "Confirmer la suppression du semestre"
tf = TrivialFormulator(
@ -1407,8 +1409,10 @@ Ceci n'est possible que si :
)
else:
H.append(tf[1])
return "\n".join(H) + html_sco_header.sco_footer()
elif tf[0] == -1: # cancel
if tf[0] == -1: # cancel
return flask.redirect(
url_for(
"notes.formsemestre_status",
@ -1416,10 +1420,9 @@ Ceci n'est possible que si :
formsemestre_id=formsemestre_id,
)
)
else:
return flask.redirect(
"formsemestre_delete2?formsemestre_id=" + str(formsemestre_id)
)
return flask.redirect(
"formsemestre_delete2?formsemestre_id=" + str(formsemestre_id)
)
def formsemestre_delete2(formsemestre_id, dialog_confirmed=False):
@ -1480,106 +1483,165 @@ def formsemestre_has_decisions_or_compensations(
return False, ""
def do_formsemestre_delete(formsemestre_id):
def do_formsemestre_delete(formsemestre_id: int):
"""delete formsemestre, and all its moduleimpls.
No checks, no warnings: erase all !
"""
cnx = ndb.GetDBConnexion()
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
sco_cache.EvaluationCache.invalidate_sem(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
sco_cache.EvaluationCache.invalidate_sem(formsemestre.id)
titre_sem = formsemestre.titre_annee()
# --- Destruction des modules de ce semestre
mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id)
for mod in mods:
for modimpl in formsemestre.modimpls:
# evaluations
evals = sco_evaluation_db.get_evaluations_dict(
args={"moduleimpl_id": mod["moduleimpl_id"]}
)
for e in evals:
ndb.SimpleQuery(
"DELETE FROM notes_notes WHERE evaluation_id=%(evaluation_id)s",
e,
for e in modimpl.evaluations:
db.session.execute(
sa.text(
"""DELETE FROM notes_notes WHERE evaluation_id=:evaluation_id"""
),
{"evaluation_id": e.id},
)
ndb.SimpleQuery(
"DELETE FROM notes_notes_log WHERE evaluation_id=%(evaluation_id)s",
e,
)
ndb.SimpleQuery(
"DELETE FROM notes_evaluation WHERE id=%(evaluation_id)s",
e,
db.session.execute(
sa.text(
"""DELETE FROM notes_notes_log WHERE evaluation_id=:evaluation_id"""
),
{"evaluation_id": e.id},
)
sco_moduleimpl.do_moduleimpl_delete(
mod["moduleimpl_id"], formsemestre_id=formsemestre_id
)
db.session.delete(e)
db.session.delete(modimpl)
# --- Desinscription des etudiants
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
req = "DELETE FROM notes_formsemestre_inscription WHERE formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"DELETE FROM notes_formsemestre_inscription WHERE formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- Suppression des evenements
req = "DELETE FROM scolar_events WHERE formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text("DELETE FROM scolar_events WHERE formsemestre_id=:formsemestre_id"),
{"formsemestre_id": formsemestre_id},
)
# --- Suppression des appreciations
req = "DELETE FROM notes_appreciations WHERE formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"DELETE FROM notes_appreciations WHERE formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- Supression des validations (!!!)
req = "DELETE FROM scolar_formsemestre_validation WHERE formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"DELETE FROM scolar_formsemestre_validation WHERE formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- Supression des references a ce semestre dans les compensations:
req = "UPDATE scolar_formsemestre_validation SET compense_formsemestre_id=NULL WHERE compense_formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"""UPDATE scolar_formsemestre_validation
SET compense_formsemestre_id=NULL
WHERE compense_formsemestre_id=:formsemestre_id"""
),
{"formsemestre_id": formsemestre_id},
)
# --- Suppression des autorisations
req = "DELETE FROM scolar_autorisation_inscription WHERE origin_formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"DELETE FROM scolar_autorisation_inscription WHERE origin_formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- Suppression des coefs d'UE capitalisées
req = "DELETE FROM notes_formsemestre_uecoef WHERE formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"DELETE FROM notes_formsemestre_uecoef WHERE formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- Suppression des item du menu custom
req = "DELETE FROM notes_formsemestre_custommenu WHERE formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"DELETE FROM notes_formsemestre_custommenu WHERE formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- Suppression des formules
req = "DELETE FROM notes_formsemestre_ue_computation_expr WHERE formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"DELETE FROM notes_formsemestre_ue_computation_expr WHERE formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- Suppression des preferences
req = "DELETE FROM sco_prefs WHERE formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text("DELETE FROM sco_prefs WHERE formsemestre_id=:formsemestre_id"),
{"formsemestre_id": formsemestre_id},
)
# --- Suppression des groupes et partitions
req = """DELETE FROM group_membership
db.session.execute(
sa.text(
"""
DELETE FROM group_membership
WHERE group_id IN
(SELECT gm.group_id FROM group_membership gm, partition p, group_descr gd
WHERE gm.group_id = gd.id AND gd.partition_id = p.id
AND p.formsemestre_id=%(formsemestre_id)s)
AND p.formsemestre_id=:formsemestre_id)
"""
cursor.execute(req, {"formsemestre_id": formsemestre_id})
req = """DELETE FROM group_descr
),
{"formsemestre_id": formsemestre_id},
)
db.session.execute(
sa.text(
"""
DELETE FROM group_descr
WHERE id IN
(SELECT gd.id FROM group_descr gd, partition p
WHERE gd.partition_id = p.id
AND p.formsemestre_id=%(formsemestre_id)s)
AND p.formsemestre_id=:formsemestre_id)
"""
cursor.execute(req, {"formsemestre_id": formsemestre_id})
req = "DELETE FROM partition WHERE formsemestre_id=%(formsemestre_id)s"
cursor.execute(req, {"formsemestre_id": formsemestre_id})
),
{"formsemestre_id": formsemestre_id},
)
db.session.execute(
sa.text("DELETE FROM partition WHERE formsemestre_id=:formsemestre_id"),
{"formsemestre_id": formsemestre_id},
)
# --- Responsables
req = """DELETE FROM notes_formsemestre_responsables
WHERE formsemestre_id=%(formsemestre_id)s"""
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"DELETE FROM notes_formsemestre_responsables WHERE formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- Etapes
req = """DELETE FROM notes_formsemestre_etapes
WHERE formsemestre_id=%(formsemestre_id)s"""
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text(
"DELETE FROM notes_formsemestre_etapes WHERE formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- SemSets
db.session.execute(
sa.text(
"DELETE FROM notes_semset_formsemestre WHERE formsemestre_id=:formsemestre_id"
),
{"formsemestre_id": formsemestre_id},
)
# --- Dispenses d'UE
req = """DELETE FROM "dispenseUE" WHERE formsemestre_id=%(formsemestre_id)s"""
cursor.execute(req, {"formsemestre_id": formsemestre_id})
db.session.execute(
sa.text("""DELETE FROM "dispenseUE" WHERE formsemestre_id=:formsemestre_id"""),
{"formsemestre_id": formsemestre_id},
)
# --- Destruction du semestre
sco_formsemestre._formsemestreEditor.delete(cnx, formsemestre_id)
db.session.delete(formsemestre)
# news
ScolarNews.add(
typ=ScolarNews.NEWS_SEM,
obj=formsemestre_id,
text="Suppression du semestre %(titre)s" % sem,
text=f"Suppression du semestre {titre_sem}",
max_frequency=0,
)
@ -1636,13 +1698,13 @@ def formsemestre_change_publication_bul(
def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None):
"""Changement manuel des coefficients des UE capitalisées."""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
ok, err = sco_permissions_check.check_access_diretud(formsemestre_id)
if not ok:
return err
footer = html_sco_header.sco_footer()
help = """<p class="help">
help_msg = """<p class="help">
Seuls les modules ont un coefficient. Cependant, il est nécessaire d'affecter un coefficient aux UE capitalisée pour pouvoir les prendre en compte dans la moyenne générale.
</p>
<p class="help">ScoDoc calcule normalement le coefficient d'une UE comme la somme des
@ -1665,17 +1727,16 @@ def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None):
"""
H = [
html_sco_header.html_sem_header("Coefficients des UE du semestre"),
help,
help_msg,
]
#
ues, modimpls = _get_sem_ues_modimpls(formsemestre_id)
ues, modimpls = _get_sem_ues_modimpls(formsemestre)
sum_coefs_by_ue_id = {}
for ue in ues:
ue["sum_coefs"] = sum(
[
mod["module"]["coefficient"]
for mod in modimpls
if mod["module"]["ue_id"] == ue["ue_id"]
]
sum_coefs_by_ue_id[ue.id] = sum(
modimpl.module.coefficient or 0.0
for modimpl in modimpls
if modimpl.module.ue_id == ue.id
)
cnx = ndb.GetDBConnexion()
@ -1684,20 +1745,20 @@ def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None):
form = [("formsemestre_id", {"input_type": "hidden"})]
for ue in ues:
coefs = sco_formsemestre.formsemestre_uecoef_list(
cnx, args={"formsemestre_id": formsemestre_id, "ue_id": ue["ue_id"]}
cnx, args={"formsemestre_id": formsemestre_id, "ue_id": ue.id}
)
if coefs:
initvalues["ue_" + str(ue["ue_id"])] = coefs[0]["coefficient"]
initvalues["ue_" + str(ue.id)] = coefs[0]["coefficient"]
else:
initvalues["ue_" + str(ue["ue_id"])] = "auto"
initvalues["ue_" + str(ue.id)] = "auto"
descr = {
"size": 10,
"title": ue["acronyme"],
"explanation": "somme coefs modules = %s" % ue["sum_coefs"],
"title": ue.acronyme,
"explanation": f"somme coefs modules = {sum_coefs_by_ue_id[ue.id]}",
}
if ue["ue_id"] == err_ue_id:
if ue.id == err_ue_id:
descr["dom_id"] = "erroneous_ue"
form.append(("ue_" + str(ue["ue_id"]), descr))
form.append(("ue_" + str(ue.id), descr))
tf = TrivialFormulator(
request.base_url,
@ -1722,12 +1783,12 @@ def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None):
# 1- supprime les coef qui ne sont plus forcés
# 2- modifie ou cree les coefs
ue_deleted = []
ue_modified = []
ue_modified: list[tuple[UniteEns, float]] = []
msg = []
for ue in ues:
val = tf[2]["ue_" + str(ue["ue_id"])]
val = tf[2]["ue_" + str(ue.id)]
coefs = sco_formsemestre.formsemestre_uecoef_list(
cnx, args={"formsemestre_id": formsemestre_id, "ue_id": ue["ue_id"]}
cnx, args={"formsemestre_id": formsemestre_id, "ue_id": ue.id}
)
if val == "" or val == "auto":
# supprime ce coef (il sera donc calculé automatiquement)
@ -1737,13 +1798,11 @@ def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None):
try:
val = float(val)
if (not coefs) or (coefs[0]["coefficient"] != val):
ue["coef"] = val
ue_modified.append(ue)
except:
ue_modified.append((ue, val))
except ValueError:
ok = False
msg.append(
"valeur invalide (%s) pour le coefficient de l'UE %s"
% (val, ue["acronyme"])
f"valeur invalide ({val}) pour le coefficient de l'UE {ue.acronyme}"
)
if not ok:
@ -1755,26 +1814,24 @@ def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None):
)
# apply modifications
for ue in ue_modified:
for ue, val in ue_modified:
sco_formsemestre.do_formsemestre_uecoef_edit_or_create(
cnx, formsemestre_id, ue["ue_id"], ue["coef"]
cnx, formsemestre_id, ue.id, val
)
for ue in ue_deleted:
sco_formsemestre.do_formsemestre_uecoef_delete(
cnx, formsemestre_id, ue["ue_id"]
)
sco_formsemestre.do_formsemestre_uecoef_delete(cnx, formsemestre_id, ue.id)
if ue_modified or ue_deleted:
message = ["""<h3>Modification effectuées</h3>"""]
if ue_modified:
message.append("""<h4>Coefs modifiés dans les UE:<h4><ul>""")
for ue in ue_modified:
message.append("<li>%(acronyme)s : %(coef)s</li>" % ue)
for ue, val in ue_modified:
message.append(f"<li>{ue.acronyme} : {val}</li>")
message.append("</ul>")
if ue_deleted:
message.append("""<h4>Coefs supprimés dans les UE:<h4><ul>""")
for ue in ue_deleted:
message.append("<li>%(acronyme)s</li>" % ue)
message.append(f"<li>{ue.acronyme}</li>")
message.append("</ul>")
else:
message = ["""<h3>Aucune modification</h3>"""]
@ -1792,21 +1849,19 @@ def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None):
"""
def _get_sem_ues_modimpls(formsemestre_id, modimpls=None):
def _get_sem_ues_modimpls(
formsemestre: FormSemestre,
) -> tuple[list[UniteEns], list[ModuleImpl]]:
"""Get liste des UE du semestre (à partir des moduleimpls)
(utilisé quand on ne peut pas construire nt et faire nt.get_ues_stat_dict())
"""
if modimpls is None:
modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id)
uedict = {}
modimpls = formsemestre.modimpls.all()
for modimpl in modimpls:
mod = sco_edit_module.module_list(args={"module_id": modimpl["module_id"]})[0]
modimpl["module"] = mod
if not mod["ue_id"] in uedict:
ue = sco_edit_ue.ue_list(args={"ue_id": mod["ue_id"]})[0]
uedict[ue["ue_id"]] = ue
if not modimpl.module.ue_id in uedict:
uedict[modimpl.module.ue.id] = modimpl.module.ue
ues = list(uedict.values())
ues.sort(key=lambda u: u["numero"])
ues.sort(key=lambda u: u.numero)
return ues, modimpls

View File

@ -191,12 +191,26 @@ def do_formsemestre_inscription_edit(args=None, formsemestre_id=None):
) # > modif inscription semestre
def do_formsemestre_desinscription(etudid, formsemestre_id):
def check_if_has_decision_jury(
formsemestre: FormSemestre, etudids: list[int] | set[int]
):
"raise exception if one of the etuds has a decision in formsemestre"
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
for etudid in etudids:
if nt.etud_has_decision(etudid):
etud = Identite.query.get(etudid)
raise ScoValueError(
f"""désinscription impossible: l'étudiant {etud.nomprenom} a
une décision de jury (la supprimer avant si nécessaire)"""
)
def do_formsemestre_desinscription(
etudid, formsemestre_id: int, check_has_dec_jury=True
):
"""Désinscription d'un étudiant.
Si semestre extérieur et dernier inscrit, suppression de ce semestre.
"""
from app.scodoc import sco_formsemestre_edit
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
etud = Identite.get_etud(etudid)
# -- check lock
@ -204,13 +218,8 @@ def do_formsemestre_desinscription(etudid, formsemestre_id):
raise ScoValueError("désinscription impossible: semestre verrouille")
# -- Si decisions de jury, désinscription interdite
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
if nt.etud_has_decision(etudid):
raise ScoValueError(
f"""désinscription impossible: l'étudiant {etud.nomprenom} a
une décision de jury (la supprimer avant si nécessaire)"""
)
if check_has_dec_jury:
check_if_has_decision_jury(formsemestre, [etudid])
insem = do_formsemestre_inscription_list(
args={"formsemestre_id": formsemestre_id, "etudid": etudid}
@ -247,17 +256,14 @@ def do_formsemestre_desinscription(etudid, formsemestre_id):
sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id)
# --- Semestre extérieur
if formsemestre.modalite == "EXT":
inscrits = do_formsemestre_inscription_list(
args={"formsemestre_id": formsemestre_id}
)
nbinscrits = len(inscrits)
if nbinscrits == 0:
if 0 == len(formsemestre.inscriptions):
log(
f"""do_formsemestre_desinscription:
suppression du semestre extérieur {formsemestre}"""
)
flash("Semestre exterieur supprimé")
sco_formsemestre_edit.do_formsemestre_delete(formsemestre_id)
db.session.delete(formsemestre)
db.session.commit()
flash(f"Semestre extérieur supprimé: {formsemestre.titre_annee()}")
logdb(
cnx,
@ -305,18 +311,15 @@ def do_formsemestre_inscription_with_modules(
# 2- inscrit aux groupes
for group_id in group_ids:
if group_id and group_id not in gdone:
group = GroupDescr.query.get_or_404(group_id)
_ = GroupDescr.query.get_or_404(group_id)
sco_groups.set_group(etudid, group_id)
gdone[group_id] = 1
# Inscription à tous les modules de ce semestre
modimpls = sco_moduleimpl.moduleimpl_withmodule_list(
formsemestre_id=formsemestre_id
)
for mod in modimpls:
if mod["ue"]["type"] != UE_SPORT:
for modimpl in formsemestre.modimpls:
if modimpl.module.ue.type != UE_SPORT:
sco_moduleimpl.do_moduleimpl_inscription_create(
{"moduleimpl_id": mod["moduleimpl_id"], "etudid": etudid},
{"moduleimpl_id": modimpl.id, "etudid": etudid},
formsemestre_id=formsemestre_id,
)
# Mise à jour des inscriptions aux parcours:
@ -531,19 +534,17 @@ def formsemestre_inscription_option(etudid, formsemestre_id):
if not sem["etat"]:
raise ScoValueError("Modification impossible: semestre verrouille")
etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
etud = Identite.get_etud(etudid)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
footer = html_sco_header.sco_footer()
H = [
html_sco_header.sco_header()
+ "<h2>Inscription de %s aux modules de %s (%s - %s)</h2>"
% (etud["nomprenom"], sem["titre_num"], sem["date_debut"], sem["date_fin"])
html_sco_header.sco_header(),
f"""<h2>Inscription de {etud.nomprenom} aux modules de {formsemestre.titre_mois()}</h2>""",
]
# Cherche les moduleimpls et les inscriptions
mods = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id)
inscr = sco_moduleimpl.do_moduleimpl_inscription_list(etudid=etudid)
# Formulaire
modimpls_by_ue_ids = collections.defaultdict(list) # ue_id : [ moduleimpl_id ]
@ -551,26 +552,26 @@ def formsemestre_inscription_option(etudid, formsemestre_id):
ues = []
ue_ids = set()
initvalues = {}
for mod in mods:
ue_id = mod["ue"]["ue_id"]
for modimpl in formsemestre.modimpls:
ue_id = modimpl.module.ue.id
if not ue_id in ue_ids:
ues.append(mod["ue"])
ues.append(modimpl.module.ue)
ue_ids.add(ue_id)
modimpls_by_ue_ids[ue_id].append(mod["moduleimpl_id"])
modimpls_by_ue_ids[ue_id].append(modimpl.id)
modimpls_by_ue_names[ue_id].append(
"%s %s" % (mod["module"]["code"] or "", mod["module"]["titre"] or "")
f"{modimpl.module.code or ''} {modimpl.module.titre or ''}"
)
vals = scu.get_request_args()
if not vals.get("tf_submitted", False):
# inscrit ?
for ins in inscr:
if ins["moduleimpl_id"] == mod["moduleimpl_id"]:
key = "moduleimpls_%s" % ue_id
if ins["moduleimpl_id"] == modimpl.id:
key = f"moduleimpls_{ue_id}"
if key in initvalues:
initvalues[key].append(str(mod["moduleimpl_id"]))
initvalues[key].append(str(modimpl.id))
else:
initvalues[key] = [str(mod["moduleimpl_id"])]
initvalues[key] = [str(modimpl.id)]
break
descr = [
@ -578,35 +579,38 @@ def formsemestre_inscription_option(etudid, formsemestre_id):
("etudid", {"input_type": "hidden"}),
]
for ue in ues:
ue_id = ue["ue_id"]
ue_descr = ue["acronyme"]
if ue["type"] != UE_STANDARD:
ue_descr += " <em>%s</em>" % UE_TYPE_NAME[ue["type"]]
ue_id = ue.id
ue_descr = ue.acronyme
if ue.type != UE_STANDARD:
ue_descr += f" <em>{UE_TYPE_NAME[ue.type]}</em>"
ue_status = nt.get_etud_ue_status(etudid, ue_id)
if ue_status and ue_status["is_capitalized"]:
sem_origin = sco_formsemestre.get_formsemestre(ue_status["formsemestre_id"])
ue_descr += (
' <a class="discretelink" href="formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s" title="%s">(capitalisée le %s)'
% (
sem_origin["formsemestre_id"],
etudid,
sem_origin["titreannee"],
ndb.DateISOtoDMY(ue_status["event_date"]),
)
)
ue_descr += f"""
<a class="discretelink" href="{ url_for(
'notes.formsemestre_bulletinetud', scodoc_dept=g.scodoc_dept,
formsemestre_id=sem_origin["formsemestre_id"],
etudid = etudid
)}" title="{sem_origin['titreannee']}">(capitalisée le {
ndb.DateISOtoDMY(ue_status["event_date"])
})
"""
descr.append(
(
"sec_%s" % ue_id,
f"sec_{ue_id}",
{
"input_type": "separator",
"title": """<b>%s :</b> <a href="#" onclick="chkbx_select('%s', true);">inscrire</a> | <a href="#" onclick="chkbx_select('%s', false);">désinscrire</a> à tous les modules"""
% (ue_descr, ue_id, ue_id),
"title": f"""<b>{ue_descr} :</b>
<a href="#" onclick="chkbx_select('{ue_id}', true);">inscrire</a> | <a
href="#" onclick="chkbx_select('{ue_id}', false);">désinscrire</a>
à tous les modules
""",
},
)
)
descr.append(
(
"moduleimpls_%s" % ue_id,
f"moduleimpls_{ue_id}",
{
"input_type": "checkbox",
"title": "",
@ -654,112 +658,98 @@ function chkbx_select(field_id, state) {
"""
)
return "\n".join(H) + "\n" + tf[1] + footer
elif tf[0] == -1:
if tf[0] == -1:
return flask.redirect(
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
else:
# Inscriptions aux modules choisis
# il faut desinscrire des modules qui ne figurent pas
# et inscrire aux autres, sauf si deja inscrit
a_desinscrire = {}.fromkeys([x["moduleimpl_id"] for x in mods])
insdict = {}
for ins in inscr:
insdict[ins["moduleimpl_id"]] = ins
for ue in ues:
ue_id = ue["ue_id"]
for moduleimpl_id in [int(x) for x in tf[2]["moduleimpls_%s" % ue_id]]:
if moduleimpl_id in a_desinscrire:
del a_desinscrire[moduleimpl_id]
# supprime ceux auxquel pas inscrit
moduleimpls_a_desinscrire = list(a_desinscrire.keys())
for moduleimpl_id in moduleimpls_a_desinscrire:
if moduleimpl_id not in insdict:
# Inscriptions aux modules choisis
# il faut desinscrire des modules qui ne figurent pas
# et inscrire aux autres, sauf si deja inscrit
a_desinscrire = {}.fromkeys([x.id for x in formsemestre.modimpls])
insdict = {}
for ins in inscr:
insdict[ins["moduleimpl_id"]] = ins
for ue in ues:
for moduleimpl_id in [int(x) for x in tf[2][f"moduleimpls_{ue.id}"]]:
if moduleimpl_id in a_desinscrire:
del a_desinscrire[moduleimpl_id]
# supprime ceux auxquel pas inscrit
moduleimpls_a_desinscrire = list(a_desinscrire.keys())
for moduleimpl_id in moduleimpls_a_desinscrire:
if moduleimpl_id not in insdict:
del a_desinscrire[moduleimpl_id]
a_inscrire = set()
for ue in ues:
ue_id = ue["ue_id"]
a_inscrire.update(
int(x) for x in tf[2]["moduleimpls_%s" % ue_id]
) # conversion en int !
# supprime ceux auquel deja inscrit:
for ins in inscr:
if ins["moduleimpl_id"] in a_inscrire:
a_inscrire.remove(ins["moduleimpl_id"])
# dict des modules:
modsdict = {}
for mod in mods:
modsdict[mod["moduleimpl_id"]] = mod
#
if (not a_inscrire) and (not a_desinscrire):
H.append(
"""<h3>Aucune modification à effectuer</h3>
<p><a class="stdlink" href="%s">retour à la fiche étudiant</a></p>
"""
% url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
)
return "\n".join(H) + footer
H.append("<h3>Confirmer les modifications:</h3>")
if a_desinscrire:
H.append(
"<p>%s va être <b>désinscrit%s</b> des modules:<ul><li>"
% (etud["nomprenom"], etud["ne"])
)
H.append(
"</li><li>".join(
[
"%s (%s)"
% (
modsdict[x]["module"]["titre"],
modsdict[x]["module"]["code"] or "(module sans code)",
)
for x in a_desinscrire
]
)
+ "</p>"
)
H.append("</li></ul>")
if a_inscrire:
H.append(
"<p>%s va être <b>inscrit%s</b> aux modules:<ul><li>"
% (etud["nomprenom"], etud["ne"])
)
H.append(
"</li><li>".join(
[
"%s (%s)"
% (
modsdict[x]["module"]["titre"],
modsdict[x]["module"]["code"] or "(module sans code)",
)
for x in a_inscrire
]
)
+ "</p>"
)
H.append("</li></ul>")
modulesimpls_ainscrire = ",".join(str(x) for x in a_inscrire)
modulesimpls_adesinscrire = ",".join(str(x) for x in a_desinscrire)
a_inscrire = set()
for ue in ues:
a_inscrire.update(
int(x) for x in tf[2][f"moduleimpls_{ue.id}"]
) # conversion en int !
# supprime ceux auquel deja inscrit:
for ins in inscr:
if ins["moduleimpl_id"] in a_inscrire:
a_inscrire.remove(ins["moduleimpl_id"])
# dict des modules:
modimpls_by_id = {modimpl.id: modimpl for modimpl in formsemestre.modimpls}
#
if (not a_inscrire) and (not a_desinscrire):
H.append(
"""<form action="do_moduleimpl_incription_options">
<input type="hidden" name="etudid" value="%s"/>
<input type="hidden" name="modulesimpls_ainscrire" value="%s"/>
<input type="hidden" name="modulesimpls_adesinscrire" value="%s"/>
<input type ="submit" value="Confirmer"/>
<input type ="button" value="Annuler" onclick="document.location='%s';"/>
</form>
f"""<h3>Aucune modification à effectuer</h3>
<p><a class="stdlink" href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">retour à la fiche étudiant</a></p>
"""
% (
etudid,
modulesimpls_ainscrire,
modulesimpls_adesinscrire,
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid),
)
)
return "\n".join(H) + footer
H.append("<h3>Confirmer les modifications:</h3>")
if a_desinscrire:
H.append(
f"""<p>{etud.nomprenom} va être <b>désinscrit{etud.e}</b> des modules:<ul><li>"""
)
H.append(
"</li><li>".join(
[
f"""{modimpls_by_id[x].module.titre or ''} ({
modimpls_by_id[x].module.code or '(module sans code)'})"""
for x in a_desinscrire
]
)
+ "</p>"
)
H.append("</li></ul>")
if a_inscrire:
H.append(
f"""<p>{etud.nomprenom} va être <b>inscrit{etud.e}</b> aux modules:<ul><li>"""
)
H.append(
"</li><li>".join(
[
f"""{modimpls_by_id[x].module.titre or ''} ({
modimpls_by_id[x].module.code or '(module sans code)'})"""
for x in a_inscrire
]
)
+ "</p>"
)
H.append("</li></ul>")
modulesimpls_ainscrire = ",".join(str(x) for x in a_inscrire)
modulesimpls_adesinscrire = ",".join(str(x) for x in a_desinscrire)
H.append(
f"""
<form action="do_moduleimpl_incription_options">
<input type="hidden" name="etudid" value="{etudid}"/>
<input type="hidden" name="modulesimpls_ainscrire" value="{modulesimpls_ainscrire}"/>
<input type="hidden" name="modulesimpls_adesinscrire" value="{modulesimpls_adesinscrire}"/>
<input type ="submit" value="Confirmer"/>
<input type ="button" value="Annuler" onclick="document.location='{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}';"/>
</form>
"""
)
return "\n".join(H) + footer
def do_moduleimpl_incription_options(
etudid, modulesimpls_ainscrire, modulesimpls_adesinscrire
@ -784,9 +774,7 @@ def do_moduleimpl_incription_options(
# verifie que ce module existe bien
mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)
if len(mods) != 1:
raise ScoValueError(
"inscription: invalid moduleimpl_id: %s" % moduleimpl_id
)
raise ScoValueError(f"inscription: invalid moduleimpl_id: {moduleimpl_id}")
mod = mods[0]
sco_moduleimpl.do_moduleimpl_inscription_create(
{"moduleimpl_id": moduleimpl_id, "etudid": etudid},
@ -798,7 +786,7 @@ def do_moduleimpl_incription_options(
mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)
if len(mods) != 1:
raise ScoValueError(
"desinscription: invalid moduleimpl_id: %s" % moduleimpl_id
f"desinscription: invalid moduleimpl_id: {moduleimpl_id}"
)
mod = mods[0]
inscr = sco_moduleimpl.do_moduleimpl_inscription_list(
@ -806,8 +794,7 @@ def do_moduleimpl_incription_options(
)
if not inscr:
raise ScoValueError(
"pas inscrit a ce module ! (etudid=%s, moduleimpl_id=%s)"
% (etudid, moduleimpl_id)
f"pas inscrit a ce module ! (etudid={etudid}, moduleimpl_id={moduleimpl_id})"
)
oid = inscr[0]["moduleimpl_inscription_id"]
sco_moduleimpl.do_moduleimpl_inscription_delete(
@ -816,11 +803,13 @@ def do_moduleimpl_incription_options(
H = [
html_sco_header.sco_header(),
"""<h3>Modifications effectuées</h3>
<p><a class="stdlink" href="%s">
Retour à la fiche étudiant</a></p>
"""
% url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid),
f"""<h3>Modifications effectuées</h3>
<p><a class="stdlink" href="{
url_for("scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etudid)
}">
Retour à la fiche étudiant</a>
</p>
""",
html_sco_header.sco_footer(),
]
return "\n".join(H)
@ -864,49 +853,59 @@ def formsemestre_inscrits_ailleurs(formsemestre_id):
"""Page listant les étudiants inscrits dans un autre semestre
dont les dates recouvrent le semestre indiqué.
"""
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
H = [
html_sco_header.html_sem_header(
"Inscriptions multiples parmi les étudiants du semestre ",
init_qtip=True,
javascripts=["js/etud_info.js"],
)
]
insd = list_inscrits_ailleurs(formsemestre_id)
# liste ordonnée par nom
etudlist = [
sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
for etudid in insd.keys()
if insd[etudid]
]
etudlist.sort(key=lambda x: x["nom"])
etudlist = [Identite.get_etud(etudid) for etudid, sems in insd.items() if sems]
etudlist.sort(key=lambda x: x.sort_key)
if etudlist:
H.append("<ul>")
for etud in etudlist:
H.append(
'<li><a href="%s" class="discretelink">%s</a> : '
% (
f"""<li><a id="{etud.id}" class="discretelink etudinfo"
href={
url_for(
"scolar.fiche_etud",
scodoc_dept=g.scodoc_dept,
etudid=etud["etudid"],
),
etud["nomprenom"],
)
etudid=etud.id,
)
}
>{etud.nomprenom}</a> :
"""
)
l = []
for s in insd[etud["etudid"]]:
for s in insd[etud.id]:
l.append(
'<a class="discretelink" href="formsemestre_status?formsemestre_id=%(formsemestre_id)s">%(titremois)s</a>'
% s
f"""<a class="discretelink" href="{
url_for('notes.formsemestre_status',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id
)}">{s['titremois']}</a>"""
)
H.append(", ".join(l))
H.append("</li>")
H.append("</ul>")
H.append("<p>Total: %d étudiants concernés.</p>" % len(etudlist))
H.append(
"""<p class="help">Ces étudiants sont inscrits dans le semestre sélectionné et aussi dans d'autres semestres qui se déroulent en même temps ! <br>Sauf exception, cette situation est anormale:</p>
f"""
</ul>
<p><b>Total: {len(etudlist)} étudiants concernés.</b></p>
<p class="help">Ces étudiants sont inscrits dans le semestre sélectionné et aussi
dans d'autres semestres qui se déroulent en même temps !
</p>
<p>
<b>Sauf exception, cette situation est anormale:</b>
</p>
<ul>
<li>vérifier que les dates des semestres se suivent sans se chevaucher</li>
<li>ou si besoin désinscrire le(s) étudiant(s) de l'un des semestres (via leurs fiches individuelles).</li>
<li>vérifier que les dates des semestres se suivent <em>sans se chevaucher</em>
</li>
<li>ou bien si besoin désinscrire le(s) étudiant(s) de l'un des semestres
(via leurs fiches individuelles).
</li>
</ul>
"""
)

View File

@ -909,37 +909,6 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
return "\n".join(H)
def html_expr_diagnostic(diagnostics):
"""Affiche messages d'erreur des formules utilisateurs"""
H = []
H.append('<div class="ue_warning">Erreur dans des formules utilisateurs:<ul>')
last_id, last_msg = None, None
for diag in diagnostics:
if "moduleimpl_id" in diag:
mod = sco_moduleimpl.moduleimpl_withmodule_list(
moduleimpl_id=diag["moduleimpl_id"]
)[0]
H.append(
'<li>module <a href="moduleimpl_status?moduleimpl_id=%s">%s</a>: %s</li>'
% (
diag["moduleimpl_id"],
mod["module"]["abbrev"] or mod["module"]["code"] or "?",
diag["msg"],
)
)
else:
if diag["ue_id"] != last_id or diag["msg"] != last_msg:
ue = sco_edit_ue.ue_list({"ue_id": diag["ue_id"]})[0]
H.append(
'<li>UE "%s": %s</li>'
% (ue["acronyme"] or ue["titre"] or "?", diag["msg"])
)
last_id, last_msg = diag["ue_id"], diag["msg"]
H.append("</ul></div>")
return "".join(H)
def formsemestre_status_head(formsemestre_id: int = None, page_title: str = None):
"""En-tête HTML des pages "semestre" """
formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
@ -1081,9 +1050,6 @@ def formsemestre_status(formsemestre_id=None, check_parcours=True):
>Toutes évaluations (même incomplètes) visibles</div>"""
)
if nt.expr_diagnostics:
H.append(html_expr_diagnostic(nt.expr_diagnostics))
if nt.parcours.APC_SAE:
# BUT: tableau ressources puis SAE
ressources = [
@ -1207,7 +1173,8 @@ def formsemestre_tableau_modules(
moduleimpl_id=modimpl.id,
)
mod_descr = "Module " + (mod.titre or "")
if mod.is_apc():
is_apc = mod.is_apc() # SAE ou ressource
if is_apc:
coef_descr = ", ".join(
[
f"{ue.acronyme}: {co}"
@ -1227,6 +1194,7 @@ def formsemestre_tableau_modules(
[u.get_nomcomplet() for u in modimpl.enseignants]
)
mod_nb_inscrits = nt.modimpls_results[modimpl.id].nb_inscrits_module
mod_is_conforme = modimpl.check_apc_conformity(nt)
ue = modimpl.module.ue
if show_ues and (prev_ue_id != ue.id):
prev_ue_id = ue.id
@ -1234,10 +1202,12 @@ def formsemestre_tableau_modules(
if use_ue_coefs:
titre += f""" <b>(coef. {ue.coefficient or 0.0})</b>"""
H.append(
f"""<tr class="formsemestre_status_ue"><td colspan="4">
<span class="status_ue_acro">{ue.acronyme}</span>
<span class="status_ue_title">{titre}</span>
</td><td colspan="2">"""
f"""<tr class="formsemestre_status_ue">
<td colspan="4">
<span class="status_ue_acro">{ue.acronyme}</span>
<span class="status_ue_title">{titre}</span>
</td>
<td colspan="2">"""
)
expr = sco_compute_moy.get_ue_expression(
@ -1246,7 +1216,7 @@ def formsemestre_tableau_modules(
if expr:
H.append(
f""" <span class="formula" title="mode de calcul de la moyenne d'UE">{expr}</span>
<span class="warning">formule inutilisée en 9.2: <a href="{
<span class="warning">formule inutilisée en ScoDoc 9: <a href="{
url_for("notes.delete_ue_expr", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id, ue_id=ue.id )
}
">supprimer</a></span>"""
@ -1260,21 +1230,26 @@ def formsemestre_tableau_modules(
fontorange = ""
etat = sco_evaluations.do_evaluation_etat_in_mod(nt, modimpl)
# if nt.parcours.APC_SAE:
# tbd style si module non conforme
if (
etat["nb_evals_completes"] > 0
and etat["nb_evals_en_cours"] == 0
and etat["nb_evals_vides"] == 0
and not etat["attente"]
and not etat["nb_evals_blocked"] > 0
):
H.append(f'<tr class="formsemestre_status_green{fontorange}">')
tr_classes = f"formsemestre_status_green{fontorange}"
else:
H.append(f'<tr class="formsemestre_status{fontorange}">')
tr_classes = f"formsemestre_status{fontorange}"
if etat["attente"]:
tr_classes += " modimpl_attente"
if not mod_is_conforme:
tr_classes += " modimpl_non_conforme"
if etat["nb_evals_blocked"] > 0:
tr_classes += " modimpl_has_blocked"
H.append(
f"""
<td class="formsemestre_status_code""><a
<tr class="{tr_classes}">
<td class="formsemestre_status_code"><a
href="{moduleimpl_status_url}"
title="{mod_descr}" class="stdlink">{mod.code}</a></td>
<td class="scotext"><a href="{moduleimpl_status_url}" title="{mod_descr}"
@ -1312,17 +1287,20 @@ def formsemestre_tableau_modules(
ModuleType.SAE,
):
H.append('<td class="evals">')
nb_evals = (
etat["nb_evals_completes"]
+ etat["nb_evals_en_cours"]
+ etat["nb_evals_vides"]
)
nb_evals = etat["nb_evals"]
if nb_evals != 0:
if etat["nb_evals_blocked"] > 0:
blocked_txt = f"""<span class="nb_evals_blocked">{
etat["nb_evals_blocked"]} bloquée{'s'
if etat["nb_evals_blocked"] > 1 else ''}</span>"""
else:
blocked_txt = ""
H.append(
f"""<a href="{moduleimpl_status_url}"
title="les évaluations 'ok' sont celles prises en compte dans les calculs"
class="formsemestre_status_link">{nb_evals} prévues,
{etat["nb_evals_completes"]} ok</a>"""
{etat["nb_evals_completes"]} ok {blocked_txt}
</a>"""
)
if etat["nb_evals_en_cours"] > 0:
H.append(
@ -1334,7 +1312,12 @@ def formsemestre_tableau_modules(
if etat["attente"]:
H.append(
f""" <span><a class="redlink" href="{moduleimpl_status_url}"
title="Il y a des notes en attente">[en attente]</a></span>"""
title="Il y a des notes en attente"><span class="evals_attente">en attente</span></a></span>"""
)
if not mod_is_conforme:
H.append(
f""" <span><a class="redlink" href="{moduleimpl_status_url}"
title="évaluations non conformes">[non conforme]</a></span>"""
)
elif mod.module_type == ModuleType.MALUS:
nb_malus_notes = sum(

View File

@ -34,7 +34,7 @@ from flask import url_for, flash, g, request
from flask_login import current_user
import sqlalchemy as sa
from app.models.etudiants import Identite
from app.models import Identite, Evaluation
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
from app import db, log
@ -232,7 +232,9 @@ def formsemestre_validation_etud_form(
H.append(
tf_error_message(
f"""Impossible de statuer sur cet étudiant: il a des notes en
attente dans des évaluations de ce semestre (voir <a href="{
attente dans des évaluations de ce semestre (voir
<a class="stdlink"
href="{
url_for( "notes.formsemestre_status",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
}">tableau de bord</a>)
@ -241,6 +243,26 @@ def formsemestre_validation_etud_form(
)
return "\n".join(H + footer)
evaluations_a_debloquer = Evaluation.get_evaluations_blocked_for_etud(
formsemestre, etud
)
if evaluations_a_debloquer:
links_evals = [
f"""<a class="stdlink" href="{url_for(
'notes.evaluation_listenotes', scodoc_dept=g.scodoc_dept, evaluation_id=e.id
)}">{e.description} en {e.moduleimpl.module.code}</a>"""
for e in evaluations_a_debloquer
]
H.append(
tf_error_message(
f"""Impossible de statuer sur cet étudiant:
il a des notes dans des évaluations qui seront débloquées plus tard:
voir {", ".join(links_evals)}
"""
)
)
return "\n".join(H + footer)
# Infos si pas de semestre précédent
if not Se.prev:
if Se.sem["semestre_id"] == 1:
@ -700,8 +722,8 @@ def formsemestre_recap_parcours_table(
f"""<td class="rcp_moy">{scu.fmt_note(nt.get_etud_moy_gen(etudid))}</td>"""
)
# Absences (nb d'abs non just. dans ce semestre)
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, sem)
H.append(f"""<td class="rcp_abs">{nbabs - nbabsjust}</td>""")
nbabsnj = sco_assiduites.get_assiduites_count(etudid, sem)[0]
H.append(f"""<td class="rcp_abs">{nbabsnj}</td>""")
# UEs
for ue in ues:
@ -1217,7 +1239,7 @@ def formsemestre_validate_previous_ue(formsemestre: FormSemestre, etud: Identite
<div id="ue_list_code" class="sco_box sco_green_bg">
<!-- filled by ue_sharing_code -->
</div>
{check_formation_ues(formation.id)[0]}
{check_formation_ues(formation)[0]}
{html_sco_header.sco_footer()}
"""
@ -1376,15 +1398,14 @@ def _invalidate_etud_formation_caches(etudid, formation_id):
) # > modif decision UE (inval tous semestres avec cet etudiant, ok mais conservatif)
def check_formation_ues(formation_id):
def check_formation_ues(formation: Formation) -> tuple[str, dict[int, list[UniteEns]]]:
"""Verifie que les UE d'une formation sont chacune utilisée dans un seul semestre_id
Si ce n'est pas le cas, c'est probablement (mais pas forcément) une erreur de
définition du programme: cette fonction retourne un bout de HTML
à afficher pour prévenir l'utilisateur, ou '' si tout est ok.
"""
ues = sco_edit_ue.ue_list({"formation_id": formation_id})
ue_multiples = {} # { ue_id : [ liste des formsemestre ] }
for ue in ues:
for ue in formation.ues:
# formsemestres utilisant cette ue ?
sems = ndb.SimpleDictFetch(
"""SELECT DISTINCT sem.id AS formsemestre_id, sem.*
@ -1394,13 +1415,13 @@ def check_formation_ues(formation_id):
AND mi.formsemestre_id = sem.id
AND mod.ue_id = %(ue_id)s
""",
{"ue_id": ue["ue_id"], "formation_id": formation_id},
{"ue_id": ue.id, "formation_id": formation.id},
)
semestre_ids = set([x["semestre_id"] for x in sems])
semestre_ids = {x["semestre_id"] for x in sems}
if (
len(semestre_ids) > 1
): # plusieurs semestres d'indices differents dans le cursus
ue_multiples[ue["ue_id"]] = sems
ue_multiples[ue.id] = sems
if not ue_multiples:
return "", {}
@ -1416,20 +1437,20 @@ def check_formation_ues(formation_id):
<ul>
"""
]
for ue in ues:
if ue["ue_id"] in ue_multiples:
for ue in formation.ues:
if ue.id in ue_multiples:
sems = [
sco_formsemestre.get_formsemestre(x["formsemestre_id"])
for x in ue_multiples[ue["ue_id"]]
for x in ue_multiples[ue.id]
]
slist = ", ".join(
[
"""%(titreannee)s (<em>semestre <b class="fontred">%(semestre_id)s</b></em>)"""
% s
f"""{s['titreannee']
} (<em>semestre <b class="fontred">{s['semestre_id']}</b></em>)"""
for s in sems
]
)
H.append("<li><b>%s</b> : %s</li>" % (ue["acronyme"], slist))
H.append(f"<li><b>{ue.acronyme}</b> : {slist}</li>")
H.append("</ul></div>")
return "\n".join(H), ue_multiples

View File

@ -331,6 +331,7 @@ class DisplayedGroupsInfos:
empty_list_select_all=True,
moduleimpl_id=None, # used to find formsemestre when unspecified
):
group_ids = [] if group_ids is None else group_ids
if isinstance(group_ids, int):
if group_ids:
group_ids = [group_ids] # cas ou un seul parametre, pas de liste
@ -466,6 +467,10 @@ class DisplayedGroupsInfos:
else None
)
def get_groups_key(self) -> str:
"clé identifiant les groupes sélectionnés, utile pour cache"
return "-".join(str(x) for x in sorted(self.group_ids))
# Ancien ZScolar.group_list renommé ici en group_table
def groups_table(
@ -514,10 +519,11 @@ def groups_table(
"paiementinscription_str": "Paiement",
"etudarchive": "Fichiers",
"annotations_str": "Annotations",
"bourse_str": "Boursier",
"bourse_str": "Boursier", # requière ViewEtudData
"etape": "Etape",
"semestre_groupe": "Semestre-Groupe", # pour Moodle
"annee": "annee_admission",
"nationalite": "nationalite", # requière ViewEtudData
}
# ajoute colonnes pour groupes
@ -559,53 +565,61 @@ def groups_table(
moodle_sem_name = groups_infos.formsemestre["session_id"]
moodle_groupenames = set()
# ajoute liens
for etud in groups_infos.members:
if etud["email"]:
etud["_email_target"] = "mailto:" + etud["email"]
for etud_info in groups_infos.members:
if etud_info["email"]:
etud_info["_email_target"] = "mailto:" + etud_info["email"]
else:
etud["_email_target"] = ""
if etud["emailperso"]:
etud["_emailperso_target"] = "mailto:" + etud["emailperso"]
etud_info["_email_target"] = ""
if etud_info["emailperso"]:
etud_info["_emailperso_target"] = "mailto:" + etud_info["emailperso"]
else:
etud["_emailperso_target"] = ""
etud_info["_emailperso_target"] = ""
fiche_url = url_for(
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"]
"scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud_info["etudid"]
)
etud["_nom_disp_target"] = fiche_url
etud["_nom_disp_order"] = etud_sort_key(etud)
etud["_prenom_target"] = fiche_url
etud_info["_nom_disp_target"] = fiche_url
etud_info["_nom_disp_order"] = etud_sort_key(etud_info)
etud_info["_prenom_target"] = fiche_url
etud["_nom_disp_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"])
etud["bourse_str"] = "oui" if etud["boursier"] else "non"
if etud["etat"] == "D":
etud["_css_row_class"] = "etuddem"
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":
etud_info["_css_row_class"] = "etuddem"
# et groupes:
for partition_id in etud["partitions"]:
etud[partition_id] = etud["partitions"][partition_id]["group_name"]
for partition_id in etud_info["partitions"]:
etud_info[partition_id] = etud_info["partitions"][partition_id][
"group_name"
]
# Ajoute colonne pour moodle: semestre_groupe, de la forme
# RT-DUT-FI-S3-2021-PARTITION-GROUPE
moodle_groupename = []
if groups_infos.selected_partitions:
# il y a des groupes selectionnes, utilise leurs partitions
for partition_id in groups_infos.selected_partitions:
if partition_id in etud["partitions"]:
if partition_id in etud_info["partitions"]:
moodle_groupename.append(
partitions_name[partition_id]
+ "-"
+ etud["partitions"][partition_id]["group_name"]
+ etud_info["partitions"][partition_id]["group_name"]
)
else:
# pas de groupes sélectionnés: prend le premier s'il y en a un
moodle_groupename = ["tous"]
if etud["partitions"]:
for p in etud["partitions"].items(): # partitions is an OrderedDict
if etud_info["partitions"]:
for p in etud_info[
"partitions"
].items(): # partitions is an OrderedDict
moodle_groupename = [
partitions_name[p[0]] + "-" + p[1]["group_name"]
]
break
moodle_groupenames |= set(moodle_groupename)
etud["semestre_groupe"] = moodle_sem_name + "-" + "+".join(moodle_groupename)
etud_info["semestre_groupe"] = (
moodle_sem_name + "-" + "+".join(moodle_groupename)
)
if groups_infos.nbdem > 1:
s = "s"
@ -714,9 +728,11 @@ def groups_table(
});
</script>
""",
"""<span class="warning_unauthorized">accès aux données personnelles interdit</span>"""
if not can_view_etud_data
else "",
(
"""<span class="warning_unauthorized">accès aux données personnelles interdit</span>"""
if not can_view_etud_data
else ""
),
]
)
H.append("</div></form>")
@ -768,13 +784,7 @@ def groups_table(
return "".join(H)
elif (
fmt == "pdf"
or fmt == "xml"
or fmt == "json"
or fmt == "xls"
or fmt == "moodlecsv"
):
elif fmt in {"pdf", "xml", "json", "xls", "moodlecsv"}:
if fmt == "moodlecsv":
fmt = "csv"
return tab.make_page(fmt=fmt)
@ -789,7 +799,7 @@ def groups_table(
with_paiement=with_paiement,
server_name=request.url_root,
)
filename = "liste_%s" % groups_infos.groups_filename
filename = f"liste_{groups_infos.groups_filename}"
return scu.send_file(xls, filename, scu.XLSX_SUFFIX, scu.XLSX_MIMETYPE)
elif fmt == "allxls":
if not can_view_etud_data:
@ -823,6 +833,7 @@ def groups_table(
"fax",
"date_naissance",
"lieu_naissance",
"nationalite",
"bac",
"specialite",
"annee_bac",
@ -845,16 +856,16 @@ def groups_table(
# remplis infos lycee si on a que le code lycée
# et ajoute infos inscription
for m in groups_infos.members:
etud = sco_etud.get_etud_info(m["etudid"], filled=True)[0]
m.update(etud)
sco_etud.etud_add_lycee_infos(etud)
etud_info = sco_etud.get_etud_info(m["etudid"], filled=True)[0]
m.update(etud_info)
sco_etud.etud_add_lycee_infos(etud_info)
# et ajoute le parcours
Se = sco_cursus.get_situation_etud_cursus(
etud, groups_infos.formsemestre_id
etud_info, groups_infos.formsemestre_id
)
m["parcours"] = Se.get_cursus_descr()
m["code_cursus"], _ = sco_report.get_code_cursus_etud(
etud["etudid"], sems=etud["sems"]
etud_info["etudid"], sems=etud_info["sems"]
)
rows = [[m.get(k, "") for k in keys] for m in groups_infos.members]
title = "etudiants_%s" % groups_infos.groups_filename
@ -905,9 +916,11 @@ def tab_absences_html(groups_infos, etat=None):
% groups_infos.groups_query_args,
"""<li><a class="stdlink" href="trombino?%s&fmt=pdflist">Liste d'appel avec photos</a></li>"""
% groups_infos.groups_query_args,
f"""<li><a class="stdlink" href="groups_export_annotations?{groups_infos.groups_query_args}">Liste des annotations</a></li>"""
if authuser.has_permission(Permission.ViewEtudData)
else """<li class="unauthorized" title="non autorisé">Liste des annotations</li>""",
(
f"""<li><a class="stdlink" href="groups_export_annotations?{groups_infos.groups_query_args}">Liste des annotations</a></li>"""
if authuser.has_permission(Permission.ViewEtudData)
else """<li class="unauthorized" title="non autorisé">Liste des annotations</li>"""
),
"</ul>",
]
)

View File

@ -36,13 +36,14 @@ from flask import url_for, g, request
import app.scodoc.notesdb as ndb
import app.scodoc.sco_utils as scu
from app import db, log
from app.models import Formation, FormSemestre, GroupDescr
from app.comp import res_sem
from app.comp.res_compat import NotesTableCompat
from app.models import Formation, FormSemestre, GroupDescr, Identite
from app.scodoc.gen_tables import GenTable
from app.scodoc import html_sco_header
from app.scodoc import sco_cache
from app.scodoc import codes_cursus
from app.scodoc import sco_etud
from app.scodoc import sco_formsemestre
from app.scodoc import sco_formsemestre_inscriptions
from app.scodoc import sco_groups
from app.scodoc import sco_preferences
@ -50,62 +51,69 @@ from app.scodoc import sco_pv_dict
from app.scodoc.sco_exceptions import ScoValueError
def list_authorized_etuds_by_sem(sem, delai=274, ignore_jury=False):
def _list_authorized_etuds_by_sem(
formsemestre: FormSemestre, ignore_jury=False
) -> tuple[dict[int, dict], list[dict], dict[int, Identite]]:
"""Liste des etudiants autorisés à s'inscrire dans sem.
delai = nb de jours max entre la date de l'autorisation et celle de debut du semestre cible.
ignore_jury: si vrai, considère tous les étudiants comme autorisés, même
s'ils n'ont pas de décision de jury.
"""
src_sems = list_source_sems(sem, delai=delai)
inscrits = list_inscrits(sem["formsemestre_id"])
src_sems = _list_source_sems(formsemestre)
inscrits = list_inscrits(formsemestre.id)
r = {}
candidats = {} # etudid : etud (tous les etudiants candidats)
nb = 0 # debug
for src in src_sems:
src_formsemestre: FormSemestre
for src_formsemestre in src_sems:
if ignore_jury:
# liste de tous les inscrits au semestre (sans dems)
liste = list_inscrits(src["formsemestre_id"]).values()
etud_list = list_inscrits(src_formsemestre.id).values()
else:
# liste des étudiants autorisés par le jury à s'inscrire ici
liste = list_etuds_from_sem(src, sem)
etud_list = _list_etuds_from_sem(src_formsemestre, formsemestre)
liste_filtree = []
for e in liste:
for e in etud_list:
# Filtre ceux qui se sont déjà inscrit dans un semestre APRES le semestre src
auth_used = False # autorisation deja utilisée ?
etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0]
for isem in etud["sems"]:
if ndb.DateDMYtoISO(isem["date_debut"]) >= ndb.DateDMYtoISO(
src["date_fin"]
):
etud = Identite.get_etud(e["etudid"])
for inscription in etud.inscriptions():
if inscription.formsemestre.date_debut >= src_formsemestre.date_fin:
auth_used = True
if not auth_used:
candidats[e["etudid"]] = etud
liste_filtree.append(e)
nb += 1
r[src["formsemestre_id"]] = {
r[src_formsemestre.id] = {
"etuds": liste_filtree,
"infos": {
"id": src["formsemestre_id"],
"title": src["titreannee"],
"title_target": "formsemestre_status?formsemestre_id=%s"
% src["formsemestre_id"],
"id": src_formsemestre.id,
"title": src_formsemestre.titre_annee(),
"title_target": url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=src_formsemestre.id,
),
"filename": "etud_autorises",
},
}
# ajoute attribut inscrit qui indique si l'étudiant est déjà inscrit dans le semestre dest.
for e in r[src["formsemestre_id"]]["etuds"]:
for e in r[src_formsemestre.id]["etuds"]:
e["inscrit"] = e["etudid"] in inscrits
# Ajoute liste des etudiants actuellement inscrits
for e in inscrits.values():
e["inscrit"] = True
r[sem["formsemestre_id"]] = {
r[formsemestre.id] = {
"etuds": list(inscrits.values()),
"infos": {
"id": sem["formsemestre_id"],
"title": "Semestre cible: " + sem["titreannee"],
"title_target": "formsemestre_status?formsemestre_id=%s"
% sem["formsemestre_id"],
"id": formsemestre.id,
"title": "Semestre cible: " + formsemestre.titre_annee(),
"title_target": url_for(
"notes.formsemestre_status",
scodoc_dept=g.scodoc_dept,
formsemestre_id=formsemestre.id,
),
"comment": " actuellement inscrits dans ce semestre",
"help": "Ces étudiants sont actuellement inscrits dans ce semestre. Si vous les décochez, il seront désinscrits.",
"filename": "etud_inscrits",
@ -115,7 +123,7 @@ def list_authorized_etuds_by_sem(sem, delai=274, ignore_jury=False):
return r, inscrits, candidats
def list_inscrits(formsemestre_id, with_dems=False):
def list_inscrits(formsemestre_id: int, with_dems=False) -> list[dict]:
"""Étudiants déjà inscrits à ce semestre
{ etudid : etud }
"""
@ -133,28 +141,27 @@ def list_inscrits(formsemestre_id, with_dems=False):
return inscr
def list_etuds_from_sem(src, dst) -> list[dict]:
"""Liste des etudiants du semestre src qui sont autorisés à passer dans le semestre dst."""
target = dst["semestre_id"]
dpv = sco_pv_dict.dict_pvjury(src["formsemestre_id"])
def _list_etuds_from_sem(src: FormSemestre, dst: FormSemestre) -> list[dict]:
"""Liste des étudiants du semestre src qui sont autorisés à passer dans le semestre dst."""
target_semestre_id = dst.semestre_id
dpv = sco_pv_dict.dict_pvjury(src.id)
if not dpv:
return []
etuds = [
x["identite"]
for x in dpv["decisions"]
if target in [a["semestre_id"] for a in x["autorisations"]]
if target_semestre_id in [a["semestre_id"] for a in x["autorisations"]]
]
return etuds
def list_inscrits_date(sem):
"""Liste les etudiants inscrits dans n'importe quel semestre
du même département
SAUF sem à la date de début de sem.
def list_inscrits_date(formsemestre: FormSemestre):
"""Liste les etudiants inscrits à la date de début de formsemestre
dans n'importe quel semestre du même département
SAUF formsemestre
"""
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
sem["date_debut_iso"] = ndb.DateDMYtoISO(sem["date_debut"])
cursor.execute(
"""SELECT ins.etudid
FROM
@ -166,12 +173,18 @@ def list_inscrits_date(sem):
AND S.date_fin >= %(date_debut_iso)s
AND S.dept_id = %(dept_id)s
""",
sem,
{
"formsemestre_id": formsemestre.id,
"date_debut_iso": formsemestre.date_debut.isoformat(),
"dept_id": formsemestre.dept_id,
},
)
return [x[0] for x in cursor.fetchall()]
def do_inscrit(sem, etudids, inscrit_groupes=False, inscrit_parcours=False):
def do_inscrit(
formsemestre: FormSemestre, etudids, inscrit_groupes=False, inscrit_parcours=False
):
"""Inscrit ces etudiants dans ce semestre
(la liste doit avoir été vérifiée au préalable)
En option:
@ -181,12 +194,11 @@ def do_inscrit(sem, etudids, inscrit_groupes=False, inscrit_parcours=False):
(si les deux sont vrais, inscrit_parcours n'a pas d'effet)
"""
# TODO à ré-écrire pour utiliser les modèles, notamment GroupDescr
formsemestre: FormSemestre = db.session.get(FormSemestre, sem["formsemestre_id"])
formsemestre.setup_parcours_groups()
log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}")
for etudid in etudids:
sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules(
sem["formsemestre_id"],
formsemestre.id,
etudid,
etat=scu.INSCRIT,
method="formsemestre_inscr_passage",
@ -210,7 +222,7 @@ def do_inscrit(sem, etudids, inscrit_groupes=False, inscrit_parcours=False):
cursem_groups_by_name = {
g["group_name"]: g
for g in sco_groups.get_sem_groups(sem["formsemestre_id"])
for g in sco_groups.get_sem_groups(formsemestre.id)
if g["group_name"]
}
@ -234,53 +246,46 @@ def do_inscrit(sem, etudids, inscrit_groupes=False, inscrit_parcours=False):
sco_groups.change_etud_group_in_partition(etudid, group)
def do_desinscrit(sem: dict, etudids: list[int]):
def do_desinscrit(
formsemestre: FormSemestre, etudids: list[int], check_has_dec_jury=True
):
"désinscrit les étudiants indiqués du formsemestre"
log(f"do_desinscrit: {etudids}")
for etudid in etudids:
sco_formsemestre_inscriptions.do_formsemestre_desinscription(
etudid, sem["formsemestre_id"]
etudid, formsemestre.id, check_has_dec_jury=check_has_dec_jury
)
def list_source_sems(sem, delai=None) -> list[dict]:
def _list_source_sems(formsemestre: FormSemestre) -> list[FormSemestre]:
"""Liste des semestres sources
sem est le semestre destination
formsemestre est le semestre destination
"""
# liste des semestres débutant a moins
# de delai (en jours) de la date de fin du semestre d'origine.
sems = sco_formsemestre.do_formsemestre_list()
othersems = []
d, m, y = [int(x) for x in sem["date_debut"].split("/")]
date_debut_dst = datetime.date(y, m, d)
delais = datetime.timedelta(delai)
for s in sems:
if s["formsemestre_id"] == sem["formsemestre_id"]:
continue # saute le semestre destination
if s["date_fin"]:
d, m, y = [int(x) for x in s["date_fin"].split("/")]
date_fin = datetime.date(y, m, d)
if date_debut_dst - date_fin > delais:
continue # semestre trop ancien
if date_fin > date_debut_dst:
continue # semestre trop récent
# Elimine les semestres de formations speciales (sans parcours)
if s["semestre_id"] == codes_cursus.NO_SEMESTRE_ID:
continue
#
formation: Formation = Formation.query.get_or_404(s["formation_id"])
parcours = codes_cursus.get_cursus_from_code(formation.type_parcours)
if not parcours.ALLOW_SEM_SKIP:
if s["semestre_id"] < (sem["semestre_id"] - 1):
continue
othersems.append(s)
return othersems
# liste des semestres du même type de cursus terminant
# pas trop loin de la date de début du semestre destination
date_fin_min = formsemestre.date_debut - datetime.timedelta(days=275)
date_fin_max = formsemestre.date_debut + datetime.timedelta(days=45)
return (
FormSemestre.query.filter(
FormSemestre.dept_id == formsemestre.dept_id,
# saute le semestre destination:
FormSemestre.id != formsemestre.id,
# et les semestres de formations speciales (monosemestres):
FormSemestre.semestre_id != codes_cursus.NO_SEMESTRE_ID,
# semestre pas trop dans le futur
FormSemestre.date_fin <= date_fin_max,
# ni trop loin dans le passé
FormSemestre.date_fin >= date_fin_min,
)
.join(Formation)
.filter_by(type_parcours=formsemestre.formation.type_parcours)
).all()
# view, GET, POST
def formsemestre_inscr_passage(
formsemestre_id,
etuds=[],
etuds: str | list[int] | list[str] | int | None = None,
inscrit_groupes=False,
inscrit_parcours=False,
submitted=False,
@ -300,36 +305,42 @@ def formsemestre_inscr_passage(
- Confirmation: indiquer les étudiants inscrits et ceux désinscrits, le total courant.
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
inscrit_groupes = int(inscrit_groupes)
inscrit_parcours = int(inscrit_parcours)
ignore_jury = int(ignore_jury)
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
# -- check lock
if not sem["etat"]:
if not formsemestre.etat:
raise ScoValueError("opération impossible: semestre verrouille")
header = html_sco_header.sco_header(page_title="Passage des étudiants")
H = [
html_sco_header.sco_header(
page_title="Passage des étudiants",
init_qtip=True,
javascripts=["js/etud_info.js"],
)
]
footer = html_sco_header.sco_footer()
H = [header]
etuds = [] if etuds is None else etuds
if isinstance(etuds, str):
# list de strings, vient du form de confirmation
# string, vient du form de confirmation
etuds = [int(x) for x in etuds.split(",") if x]
elif isinstance(etuds, int):
etuds = [etuds]
elif etuds and isinstance(etuds[0], str):
etuds = [int(x) for x in etuds]
auth_etuds_by_sem, inscrits, candidats = list_authorized_etuds_by_sem(
sem, ignore_jury=ignore_jury
auth_etuds_by_sem, inscrits, candidats = _list_authorized_etuds_by_sem(
formsemestre, ignore_jury=ignore_jury
)
etuds_set = set(etuds)
candidats_set = set(candidats)
inscrits_set = set(inscrits)
candidats_non_inscrits = candidats_set - inscrits_set
inscrits_ailleurs = set(list_inscrits_date(sem))
inscrits_ailleurs = set(list_inscrits_date(formsemestre))
def set_to_sorted_etud_list(etudset):
def set_to_sorted_etud_list(etudset) -> list[Identite]:
etuds = [candidats[etudid] for etudid in etudset]
etuds.sort(key=itemgetter("nom"))
etuds.sort(key=lambda e: e.sort_key)
return etuds
if submitted:
@ -340,7 +351,7 @@ def formsemestre_inscr_passage(
if not submitted:
H += _build_page(
sem,
formsemestre,
auth_etuds_by_sem,
inscrits,
candidats_non_inscrits,
@ -355,30 +366,31 @@ def formsemestre_inscr_passage(
if a_inscrire:
H.append("<h3>Étudiants à inscrire</h3><ol>")
for etud in set_to_sorted_etud_list(a_inscrire):
H.append("<li>%(nomprenom)s</li>" % etud)
H.append(f"<li>{etud.nomprenom}</li>")
H.append("</ol>")
a_inscrire_en_double = inscrits_ailleurs.intersection(a_inscrire)
if a_inscrire_en_double:
H.append("<h3>dont étudiants déjà inscrits:</h3><ul>")
for etud in set_to_sorted_etud_list(a_inscrire_en_double):
H.append('<li class="inscrailleurs">%(nomprenom)s</li>' % etud)
H.append(f'<li class="inscrit-ailleurs">{etud.nomprenom}</li>')
H.append("</ul>")
if a_desinscrire:
H.append("<h3>Étudiants à désinscrire</h3><ol>")
for etudid in a_desinscrire:
H.append(
'<li class="desinscription">%(nomprenom)s</li>'
% inscrits[etudid]
)
a_desinscrire_ident = sorted(
(Identite.query.get(eid) for eid in a_desinscrire),
key=lambda x: x.sort_key,
)
for etud in a_desinscrire_ident:
H.append(f'<li class="desinscription">{etud.nomprenom}</li>')
H.append("</ol>")
todo = a_inscrire or a_desinscrire
if not todo:
H.append("""<h3>Il n'y a rien à modifier !</h3>""")
H.append(
scu.confirm_dialog(
dest_url="formsemestre_inscr_passage"
if todo
else "formsemestre_status",
dest_url=(
"formsemestre_inscr_passage" if todo else "formsemestre_status"
),
message="<p>Confirmer ?</p>" if todo else "",
add_headers=False,
cancel_url="formsemestre_inscr_passage?formsemestre_id="
@ -395,16 +407,26 @@ def formsemestre_inscr_passage(
)
)
else:
# check decisions jury ici pour éviter de recontruire le cache
# après chaque desinscription
sco_formsemestre_inscriptions.check_if_has_decision_jury(
formsemestre, a_desinscrire
)
# check decisions jury ici pour éviter de recontruire le cache
# après chaque desinscription
sco_formsemestre_inscriptions.check_if_has_decision_jury(
formsemestre, a_desinscrire
)
with sco_cache.DeferredSemCacheManager():
# Inscription des étudiants au nouveau semestre:
do_inscrit(
sem,
formsemestre,
a_inscrire,
inscrit_groupes=inscrit_groupes,
inscrit_parcours=inscrit_parcours,
)
# Désinscriptions:
do_desinscrit(sem, a_desinscrire)
do_desinscrit(formsemestre, a_desinscrire, check_has_dec_jury=False)
H.append(
f"""<h3>Opération effectuée</h3>
@ -441,7 +463,7 @@ def formsemestre_inscr_passage(
def _build_page(
sem,
formsemestre: FormSemestre,
auth_etuds_by_sem,
inscrits,
candidats_non_inscrits,
@ -450,7 +472,6 @@ def _build_page(
inscrit_parcours=False,
ignore_jury=False,
):
formsemestre: FormSemestre = db.session.get(FormSemestre, sem["formsemestre_id"])
inscrit_groupes = int(inscrit_groupes)
inscrit_parcours = int(inscrit_parcours)
ignore_jury = int(ignore_jury)
@ -472,7 +493,7 @@ def _build_page(
),
f"""<form name="f" method="post" action="{request.base_url}">
<input type="hidden" name="formsemestre_id" value="{sem['formsemestre_id']}"/>
<input type="hidden" name="formsemestre_id" value="{formsemestre.id}"/>
<input type="submit" name="submitted" value="Appliquer les modifications"/>
&nbsp;<a href="#help">aide</a>
@ -491,7 +512,7 @@ def _build_page(
</div>
<div>{scu.EMO_WARNING}
<em>Seuls les semestres dont la date de fin est antérieure à la date de début
<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
compte.</em>
</div>
@ -499,7 +520,7 @@ def _build_page(
<input type="submit" name="submitted" value="Appliquer les modifications"/>
{formsemestre_inscr_passage_help(sem)}
{formsemestre_inscr_passage_help(formsemestre)}
</form>
""",
@ -524,19 +545,20 @@ def _build_page(
return H
def formsemestre_inscr_passage_help(sem: dict):
def formsemestre_inscr_passage_help(formsemestre: FormSemestre):
"texte d'aide en bas de la page passage des étudiants"
return f"""<div class="pas_help"><h3><a name="help">Explications</a></h3>
<p>Cette page permet d'inscrire des étudiants dans le semestre destination
<a class="stdlink"
href="{
url_for("notes.formsemestre_status", scodoc_dept=g.scodoc_dept, formsemestre_id=sem["formsemestre_id"] )
}">{sem['titreannee']}</a>,
url_for("notes.formsemestre_status",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id )
}">{formsemestre.titre_annee()}</a>,
et d'en désincrire si besoin.
</p>
<p>Les étudiants sont groupés par semestre d'origine. Ceux qui sont en caractères
<span class="inscrit">gras</span> sont déjà inscrits dans le semestre destination.
Ceux qui sont en <span class"inscrailleurs">gras et en rouge</span> sont inscrits
<span class="deja-inscrit">gras</span> sont déjà inscrits dans le semestre destination.
Ceux qui sont en <span class="inscrit-ailleurs">gras et en rouge</span> sont inscrits
dans un <em>autre</em> semestre.
</p>
<p>Au départ, les étudiants déjà inscrits sont sélectionnés; vous pouvez ajouter
@ -555,7 +577,7 @@ def formsemestre_inscr_passage_help(sem: dict):
conserve les groupes, on conserve les parcours ( aussi, pensez à les cocher dans
<a class="stdlink" href="{
url_for("notes.formsemestre_editwithmodules", scodoc_dept=g.scodoc_dept,
formsemestre_id=sem["formsemestre_id"] )
formsemestre_id=formsemestre.id )
}">modifier le semestre</a> avant de faire passer les étudiants).
</a>
@ -656,25 +678,24 @@ def etuds_select_boxes(
H.append("</div>")
for etud in etuds:
if etud.get("inscrit", False):
c = " inscrit"
c = " deja-inscrit"
checked = 'checked="checked"'
else:
checked = ""
if etud["etudid"] in inscrits_ailleurs:
c = " inscrailleurs"
c = " inscrit-ailleurs"
else:
c = ""
sco_etud.format_etud_ident(etud)
if etud["etudid"]:
elink = """<a class="discretelink %s" href="%s">%s</a>""" % (
c,
url_for(
"scolar.fiche_etud",
elink = f"""<a id="{etud['etudid']}" class="discretelink etudinfo {c}"
href="{ url_for(
'scolar.fiche_etud',
scodoc_dept=g.scodoc_dept,
etudid=etud["etudid"],
),
etud["nomprenom"],
)
etudid=etud['etudid'],
)
}">{etud['nomprenom']}</a>
"""
else:
# ce n'est pas un etudiant ScoDoc
elink = etud["nomprenom"]

View File

@ -490,9 +490,9 @@ def _make_table_notes(
rlinks = {"_table_part": "head"}
for e in evaluations:
rlinks[e.id] = "afficher"
rlinks[
"_" + str(e.id) + "_help"
] = "afficher seulement les notes de cette évaluation"
rlinks["_" + str(e.id) + "_help"] = (
"afficher seulement les notes de cette évaluation"
)
rlinks["_" + str(e.id) + "_target"] = url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
@ -709,9 +709,9 @@ def _add_eval_columns(
notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation.id)
if evaluation.date_debut:
titles[
evaluation.id
] = f"{evaluation.description} ({evaluation.date_debut.strftime('%d/%m/%Y')})"
titles[evaluation.id] = (
f"{evaluation.description} ({evaluation.date_debut.strftime('%d/%m/%Y')})"
)
else:
titles[evaluation.id] = f"{evaluation.description} "
@ -820,14 +820,17 @@ def _add_eval_columns(
row_moys[evaluation.id] = scu.fmt_note(
sum_notes / nb_notes, keep_numeric=keep_numeric
)
row_moys[
"_" + str(evaluation.id) + "_help"
] = "moyenne sur %d notes (%s le %s)" % (
nb_notes,
evaluation.description,
evaluation.date_debut.strftime("%d/%m/%Y")
if evaluation.date_debut
else "",
row_moys["_" + str(evaluation.id) + "_help"] = (
"moyenne sur %d notes (%s le %s)"
% (
nb_notes,
evaluation.description,
(
evaluation.date_debut.strftime("%d/%m/%Y")
if evaluation.date_debut
else ""
),
)
)
else:
row_moys[evaluation.id] = ""
@ -884,8 +887,9 @@ def _add_moymod_column(
row["_" + col_id + "_td_attrs"] = ' class="moyenne" '
if etudid in inscrits and not isinstance(val, str):
notes.append(val)
nb_notes = nb_notes + 1
sum_notes += val
if not np.isnan(val):
nb_notes = nb_notes + 1
sum_notes += val
row_coefs[col_id] = "(avec abs)"
if is_apc:
row_poids[col_id] = "à titre indicatif"

View File

@ -56,6 +56,7 @@ _moduleimplEditor = ndb.EditableTable(
def do_moduleimpl_create(args):
"create a moduleimpl"
# TODO remplacer par une methode de ModuleImpl qui appelle super().create_from_dict() puis invalide le formsemestre
cnx = ndb.GetDBConnexion()
r = _moduleimplEditor.create(cnx, args)
sco_cache.invalidate_formsemestre(
@ -90,7 +91,9 @@ def do_moduleimpl_delete(oid, formsemestre_id=None):
) # > moduleimpl_delete
def moduleimpl_list(moduleimpl_id=None, formsemestre_id=None, module_id=None):
def moduleimpl_list(
moduleimpl_id=None, formsemestre_id=None, module_id=None
) -> list[dict]:
"list moduleimpls"
args = locals()
cnx = ndb.GetDBConnexion()
@ -109,91 +112,6 @@ def do_moduleimpl_edit(args, formsemestre_id=None, cnx=None):
) # > modif moduleimpl
def moduleimpl_withmodule_list(
moduleimpl_id=None, formsemestre_id=None, module_id=None, sort_by_ue=False
) -> list:
"""Liste les moduleimpls et ajoute dans chacun
l'UE, la matière et le module auxquels ils appartiennent.
Tri la liste par:
- pour les formations classiques: semestre/UE/numero_matiere/numero_module;
- pour le BUT: ignore UEs sauf si sort_by_ue et matières dans le tri.
NB: Cette fonction faisait partie de l'API ScoDoc 7.
"""
from app.scodoc import sco_edit_ue
from app.scodoc import sco_edit_matiere
from app.scodoc import sco_edit_module
modimpls = moduleimpl_list(
**{
"moduleimpl_id": moduleimpl_id,
"formsemestre_id": formsemestre_id,
"module_id": module_id,
}
)
if not modimpls:
return []
ues = {}
matieres = {}
modules = {}
for mi in modimpls:
module_id = mi["module_id"]
if not mi["module_id"] in modules:
modules[module_id] = sco_edit_module.module_list(
args={"module_id": module_id}
)[0]
mi["module"] = modules[module_id]
ue_id = mi["module"]["ue_id"]
if not ue_id in ues:
ues[ue_id] = sco_edit_ue.ue_list(args={"ue_id": ue_id})[0]
mi["ue"] = ues[ue_id]
matiere_id = mi["module"]["matiere_id"]
if not matiere_id in matieres:
matieres[matiere_id] = sco_edit_matiere.matiere_list(
args={"matiere_id": matiere_id}
)[0]
mi["matiere"] = matieres[matiere_id]
mod = modimpls[0]["module"]
formation = db.session.get(Formation, mod["formation_id"])
if formation.is_apc():
# tri par numero_module
if sort_by_ue:
modimpls.sort(
key=lambda x: (
x["ue"]["numero"],
x["ue"]["ue_id"],
x["module"]["module_type"],
x["module"]["numero"],
x["module"]["code"],
)
)
else:
modimpls.sort(
key=lambda x: (
x["module"]["module_type"],
x["module"]["numero"],
x["module"]["code"],
)
)
else:
# Formations classiques, avec matières:
# tri par semestre/UE/numero_matiere/numero_module
modimpls.sort(
key=lambda x: (
x["ue"]["numero"],
x["ue"]["ue_id"],
x["matiere"]["numero"],
x["matiere"]["matiere_id"],
x["module"]["numero"],
x["module"]["code"],
)
)
return modimpls
def moduleimpls_in_external_ue(ue_id):
"""List of modimpls in this ue"""
cursor = ndb.SimpleQuery(
@ -254,9 +172,9 @@ _moduleimpl_inscriptionEditor = ndb.EditableTable(
)
def do_moduleimpl_inscription_create(args, formsemestre_id=None):
def do_moduleimpl_inscription_create(args, formsemestre_id=None, cnx=None):
"create a moduleimpl_inscription"
cnx = ndb.GetDBConnexion()
cnx = cnx or ndb.GetDBConnexion()
try:
r = _moduleimpl_inscriptionEditor.create(cnx, args)
except psycopg2.errors.UniqueViolation as exc:
@ -270,7 +188,7 @@ def do_moduleimpl_inscription_create(args, formsemestre_id=None):
cnx,
method="moduleimpl_inscription",
etudid=args["etudid"],
msg="inscription module %s" % args["moduleimpl_id"],
msg=f"inscription module {args['moduleimpl_id']}",
commit=False,
)
return r
@ -297,32 +215,29 @@ def do_moduleimpl_inscrit_etuds(moduleimpl_id, formsemestre_id, etudids, reset=F
args={"formsemestre_id": formsemestre_id, "etudid": etudid}
)
if not insem:
raise ScoValueError("%s n'est pas inscrit au semestre !" % etudid)
raise ScoValueError(f"{etudid} n'est pas inscrit au semestre !")
cnx = ndb.GetDBConnexion()
# Desinscriptions
if reset:
cnx = ndb.GetDBConnexion()
cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
cursor.execute(
"delete from notes_moduleimpl_inscription where moduleimpl_id = %(moduleimpl_id)s",
{"moduleimpl_id": moduleimpl_id},
)
# Inscriptions au module:
inmod_set = set(
[
# hum ?
x["etudid"]
for x in do_moduleimpl_inscription_list(moduleimpl_id=moduleimpl_id)
]
)
inmod_set = {
x["etudid"] for x in do_moduleimpl_inscription_list(moduleimpl_id=moduleimpl_id)
}
for etudid in etudids:
# deja inscrit ?
# déja inscrit ?
if not etudid in inmod_set:
do_moduleimpl_inscription_create(
{"moduleimpl_id": moduleimpl_id, "etudid": etudid},
formsemestre_id=formsemestre_id,
cnx=cnx,
)
cnx.commit()
sco_cache.invalidate_formsemestre(
formsemestre_id=formsemestre_id
) # > moduleimpl_inscrit_etuds

View File

@ -409,34 +409,32 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
H.append(
'<h3>Étudiants avec UEs capitalisées (ADM):</h3><ul class="ue_inscr_list">'
)
ues = [
sco_edit_ue.ue_list({"ue_id": ue_id})[0] for ue_id in ues_cap_info.keys()
]
ues.sort(key=lambda u: u["numero"])
ues = [UniteEns.query.get_or_404(ue_id) for ue_id in ues_cap_info.keys()]
ues.sort(key=lambda u: u.numero)
for ue in ues:
H.append(
f"""<li class="tit"><span class="tit">{ue['acronyme']}: {ue['titre']}</span>"""
f"""<li class="tit"><span class="tit">{ue.acronyme}: {ue.titre or ''}</span>"""
)
H.append("<ul>")
for info in ues_cap_info[ue["ue_id"]]:
etud = sco_etud.get_etud_info(etudid=info["etudid"], filled=True)[0]
for info in ues_cap_info[ue.id]:
etud = Identite.get_etud(info["etudid"])
H.append(
f"""<li class="etud"><a class="discretelink etudinfo"
id="{info['etudid']}"
id="{etud.id}"
href="{
url_for(
"scolar.fiche_etud",
scodoc_dept=g.scodoc_dept,
etudid=etud["etudid"],
etudid=etud.id,
)
}">{etud["nomprenom"]}</a>"""
}">{etud.nomprenom}</a>"""
)
if info["ue_status"]["event_date"]:
H.append(
f"""(cap. le {info["ue_status"]["event_date"].strftime("%d/%m/%Y")})"""
)
if is_apc:
is_inscrit_ue = (etud["etudid"], ue["id"]) not in res.dispense_ues
is_inscrit_ue = (etud.id, ue.id) not in res.dispense_ues
else:
# CLASSIQUE
is_inscrit_ue = info["is_ins"]
@ -468,8 +466,8 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
H.append(
f"""<div><a class="stdlink" href="{
url_for("notes.etud_desinscrit_ue",
scodoc_dept=g.scodoc_dept, etudid=etud["etudid"],
formsemestre_id=formsemestre_id, ue_id=ue["ue_id"])
scodoc_dept=g.scodoc_dept, etudid=etud.id,
formsemestre_id=formsemestre_id, ue_id=ue.id)
}">désinscrire {"des modules" if not is_apc else ""} de cette UE</a></div>
"""
)
@ -479,8 +477,8 @@ def moduleimpl_inscriptions_stats(formsemestre_id):
H.append(
f"""<div><a class="stdlink" href="{
url_for("notes.etud_inscrit_ue",
scodoc_dept=g.scodoc_dept, etudid=etud["etudid"],
formsemestre_id=formsemestre_id, ue_id=ue["ue_id"])
scodoc_dept=g.scodoc_dept, etudid=etud.id,
formsemestre_id=formsemestre_id, ue_id=ue.id)
}">inscrire à {"" if is_apc else "tous les modules de"} cette UE</a></div>
"""
)
@ -619,7 +617,7 @@ def _list_but_ue_inscriptions(res: NotesTableCompat, read_only: bool = True) ->
<p>L'inscription ou désinscription aux UEs du BUT n'affecte pas les inscriptions aux modules
mais permet de "dispenser" un étudiant de suivre certaines UEs de son parcours.
</p>
<p>Il peut s'agit d'étudiants redoublants ayant déjà acquis l'UE, ou d'une UE
<p>Il peut s'agir d'étudiants redoublants ayant déjà acquis l'UE, ou d'une UE
présente dans le semestre mais pas dans le parcours de l'étudiant, ou bien d'autres
cas particuliers.
</p>

View File

@ -127,12 +127,12 @@ def moduleimpl_evaluation_menu(evaluation: Evaluation, nbnotes: int = 0) -> str:
"args": {
"group_ids": group_id,
"evaluation_id": evaluation.id,
"date_debut": evaluation.date_debut.isoformat()
if evaluation.date_debut
else "",
"date_fin": evaluation.date_fin.isoformat()
if evaluation.date_fin
else "",
"date_debut": (
evaluation.date_debut.isoformat() if evaluation.date_debut else ""
),
"date_fin": (
evaluation.date_fin.isoformat() if evaluation.date_fin else ""
),
},
"enabled": evaluation.date_debut is not None
and evaluation.date_fin is not None,
@ -355,17 +355,14 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None):
Ses notes ne peuvent pas être prises en compte dans les moyennes d'UE.
</div>"""
)
#
if has_expression and nt.expr_diagnostics:
H.append(sco_formsemestre_status.html_expr_diagnostic(nt.expr_diagnostics))
#
if formsemestre_has_decisions(formsemestre_id):
H.append(
"""<ul class="tf-msg">
<li class="tf-msg warning">Décisions de jury saisies: seul le ou la responsable du
"""<div class="formsemestre-warning-box">
<div class="warning">Décisions de jury saisies: seul le ou la responsable du
semestre peut saisir des notes (elle devra modifier les décisions de jury).
</li>
</ul>"""
</div>
</div>"""
)
#
H.append(
@ -522,16 +519,22 @@ def _ligne_evaluation(
partition_id=partition_id,
select_first_partition=True,
)
if evaluation.evaluation_type in (
scu.EVALUATION_RATTRAPAGE,
scu.EVALUATION_SESSION2,
):
if evaluation.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE:
tr_class = "mievr mievr_rattr"
elif evaluation.evaluation_type == Evaluation.EVALUATION_SESSION2:
tr_class = "mievr mievr_session2"
elif evaluation.evaluation_type == Evaluation.EVALUATION_BONUS:
tr_class = "mievr mievr_bonus"
else:
tr_class = "mievr"
if not evaluation.visibulletin:
tr_class += " non_visible_inter"
tr_class_1 = "mievr"
if evaluation.is_blocked():
tr_class += " evaluation_blocked"
tr_class_1 += " evaluation_blocked"
if not first_eval:
H.append("""<tr><td colspan="8">&nbsp;</td></tr>""")
tr_class_1 += " mievr_spaced"
@ -565,14 +568,18 @@ def _ligne_evaluation(
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}" class="mievr_evalnodate">Évaluation sans date</a>"""
)
H.append(f"&nbsp;&nbsp;&nbsp; <em>{evaluation.description or ''}</em>")
if evaluation.evaluation_type == scu.EVALUATION_RATTRAPAGE:
H.append(f"&nbsp;&nbsp;&nbsp; <em>{evaluation.description}</em>")
if evaluation.evaluation_type == Evaluation.EVALUATION_RATTRAPAGE:
H.append(
"""<span class="mievr_rattr" title="remplace si meilleure note">rattrapage</span>"""
)
elif evaluation.evaluation_type == scu.EVALUATION_SESSION2:
elif evaluation.evaluation_type == Evaluation.EVALUATION_SESSION2:
H.append(
"""<span class="mievr_rattr" title="remplace autres notes">session 2</span>"""
"""<span class="mievr_session2" title="remplace autres notes">session 2</span>"""
)
elif evaluation.evaluation_type == Evaluation.EVALUATION_BONUS:
H.append(
"""<span class="mievr_bonus" title="s'ajoute aux moyennes de ce module">bonus</span>"""
)
#
if etat["last_modif"]:
@ -608,8 +615,15 @@ def _ligne_evaluation(
else:
H.append(arrow_none)
if etat["evalcomplete"]:
etat_txt = f"""(prise en compte{
if evaluation.is_blocked():
etat_txt = f"""évaluation bloquée {
"jusqu'au " + evaluation.blocked_until.strftime("%d/%m/%Y")
if evaluation.blocked_until < Evaluation.BLOCKED_FOREVER
else "" }
"""
etat_descr = """prise en compte bloquée"""
elif etat["evalcomplete"]:
etat_txt = f"""Moyenne (prise en compte{
""
if evaluation.visibulletin
else ", cachée en intermédiaire"})
@ -618,7 +632,7 @@ def _ligne_evaluation(
", évaluation cachée sur les bulletins en version intermédiaire et sur la passerelle"
}"""
elif etat["evalattente"] and not evaluation.publish_incomplete:
etat_txt = "(prise en compte, mais <b>notes en attente</b>)"
etat_txt = "Moyenne (prise en compte, mais <b>notes en attente</b>)"
etat_descr = "il y a des notes en attente"
elif evaluation.publish_incomplete:
etat_txt = """(prise en compte <b>immédiate</b>)"""
@ -626,28 +640,29 @@ def _ligne_evaluation(
"il manque des notes, mais la prise en compte immédiate a été demandée"
)
elif etat["nb_notes"] != 0:
etat_txt = "(<b>non</b> prise en compte)"
etat_txt = "Moyenne (<b>non</b> prise en compte)"
etat_descr = "il manque des notes"
else:
etat_txt = ""
if can_edit_evals and etat_txt:
etat_txt = f"""<a href="{ url_for("notes.evaluation_edit",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}" title="{etat_descr}">{etat_txt}</a>"""
if etat_txt:
if can_edit_evals:
etat_txt = f"""<a href="{ url_for("notes.evaluation_edit",
scodoc_dept=g.scodoc_dept, evaluation_id=evaluation.id)
}" title="{etat_descr}">{etat_txt}</a>"""
H.append(
f"""</span></span></td>
</tr>
<tr class="{tr_class}">
<tr class="{tr_class} mievr_in">
<th class="moduleimpl_evaluations" colspan="2">&nbsp;</th>
<th class="moduleimpl_evaluations">Durée</th>
<th class="moduleimpl_evaluations">Coef.</th>
<th class="moduleimpl_evaluations">Notes</th>
<th class="moduleimpl_evaluations">Abs</th>
<th class="moduleimpl_evaluations">N</th>
<th class="moduleimpl_evaluations" colspan="2">Moyenne {etat_txt}</th>
<th class="moduleimpl_evaluations moduleimpl_evaluation_moy" colspan="2"><span>{etat_txt}</span></th>
</tr>
<tr class="{tr_class}">
<tr class="{tr_class} mievr_in">
<td class="mievr">"""
)
if can_edit_evals:
@ -829,7 +844,7 @@ def _evaluation_poids_html(evaluation: Evaluation, max_poids: float = 0.0) -> st
+ "\n".join(
[
f"""<div title="poids vers {ue.acronyme}: {poids:g}">
<div style="--size:{math.sqrt(poids*(evaluation.coefficient or 0.)/max_poids*144)}px;
<div style="--size:{math.sqrt(poids*(evaluation.coefficient)/max_poids*144)}px;
{'background-color: ' + ue.color + ';' if ue.color else ''}
"></div>
</div>"""

View File

@ -36,7 +36,7 @@ import sqlalchemy as sa
from app import log
from app.auth.models import User
from app.but import cursus_but
from app.but import cursus_but, validations_view
from app.models import Adresse, EtudAnnotation, FormSemestre, Identite, ScoDocSiteConfig
from app.scodoc import (
codes_cursus,
@ -445,6 +445,14 @@ def fiche_etud(etudid=None):
# Liens vers compétences BUT
if last_formsemestre and last_formsemestre.formation.is_apc():
but_cursus = cursus_but.EtudCursusBUT(etud, last_formsemestre.formation)
refcomp = last_formsemestre.formation.referentiel_competence
if refcomp:
ue_validation_by_niveau = validations_view.get_ue_validation_by_niveau(
refcomp, etud
)
ects_total = sum((v.ects() for v in ue_validation_by_niveau.values()))
else:
ects_total = ""
info[
"but_cursus_mkup"
] = f"""
@ -454,15 +462,20 @@ def fiche_etud(etudid=None):
cursus=but_cursus,
scu=scu,
)}
<div class="link_validation_rcues">
<a class="stdlink" href="{url_for("notes.validation_rcues",
scodoc_dept=g.scodoc_dept, etudid=etudid,
formsemestre_id=last_formsemestre.id)}"
title="Visualiser les compétences BUT"
>
<img src="/ScoDoc/static/icons/parcours-but.png" alt="validation_rcues" height="100px"/>
<div>Compétences BUT</div>
</a>
<div class="fiche_but_col2">
<div class="link_validation_rcues">
<a class="stdlink" href="{url_for("notes.validation_rcues",
scodoc_dept=g.scodoc_dept, etudid=etudid,
formsemestre_id=last_formsemestre.id)}"
title="Visualiser les compétences BUT"
>
<img src="/ScoDoc/static/icons/parcours-but.png" alt="validation_rcues" height="100px"/>
<div style="text-align: center;">Compétences BUT</div>
</a>
</div>
<div class="fiche_total_etcs">
Total ECTS BUT: {ects_total:g}
</div>
</div>
</div>
"""

View File

@ -48,20 +48,17 @@ from wtforms import (
HiddenField,
SelectMultipleField,
)
from app.models import ModuleImpl
from app.models import Evaluation, ModuleImpl
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app import ScoValueError
from app.scodoc import html_sco_header, sco_preferences
from app.scodoc import sco_edit_module
from app.scodoc import sco_evaluations
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_excel
from app.scodoc.sco_excel import ScoExcelBook, COLORS
from app.scodoc import sco_formsemestre
from app.scodoc import sco_groups
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_permissions_check
from app.scodoc.gen_tables import GenTable
from app.scodoc import sco_etud
import sco_version
@ -138,11 +135,7 @@ class PlacementForm(FlaskForm):
def set_evaluation_infos(self, evaluation_id):
"""Initialise les données du formulaire avec les données de l'évaluation."""
eval_data = sco_evaluation_db.get_evaluations_dict(
{"evaluation_id": evaluation_id}
)
if not eval_data:
raise ScoValueError("invalid evaluation_id")
_ = Evaluation.get_evaluation(evaluation_id) # check exist ?
self.groups_tree, self.has_groups, self.nb_groups = _get_group_info(
evaluation_id
)
@ -239,14 +232,12 @@ class PlacementRunner:
self.groups_ids = [
gid if gid != TOUS else form.tous_id for gid in form["groups"].data
]
self.eval_data = sco_evaluation_db.get_evaluations_dict(
{"evaluation_id": self.evaluation_id}
)[0]
self.evaluation = Evaluation.get_evaluation(self.evaluation_id)
self.groups = sco_groups.listgroups(self.groups_ids)
self.gr_title_filename = sco_groups.listgroups_filename(self.groups)
# gr_title = sco_groups.listgroups_abbrev(d['groups'])
self.current_user = current_user
self.moduleimpl_id = self.eval_data["moduleimpl_id"]
self.moduleimpl_id = self.evaluation.moduleimpl_id
self.moduleimpl: ModuleImpl = ModuleImpl.query.get_or_404(self.moduleimpl_id)
# TODO: à revoir pour utiliser modèle ModuleImpl
self.moduleimpl_data = sco_moduleimpl.moduleimpl_list(
@ -260,20 +251,25 @@ class PlacementRunner:
)
self.evalname = "%s-%s" % (
self.module_data["code"] or "?",
ndb.DateDMYtoISO(self.eval_data["jour"]),
(
self.evaluation.date_debut.strftime("%Y-%m-%d_%Hh%M")
if self.evaluation.date_debut
else ""
),
)
if self.eval_data["description"]:
self.evaltitre = self.eval_data["description"]
if self.evaluation.description:
self.evaltitre = self.evaluation.description
else:
self.evaltitre = "évaluation du %s" % self.eval_data["jour"]
self.evaltitre = f"""évaluation{
self.evaluation.date_debut.strftime(' du %d/%m/%Y à %Hh%M')
if self.evaluation.date_debut else ''}"""
self.desceval = [ # une liste de chaines: description de l'evaluation
"%s" % self.sem["titreannee"],
self.sem["titreannee"],
"Module : %s - %s"
% (self.module_data["code"] or "?", self.module_data["abbrev"] or ""),
"Surveillants : %s" % self.surveillants,
"Batiment : %(batiment)s - Salle : %(salle)s" % self.__dict__,
"Controle : %s (coef. %g)"
% (self.evaltitre, self.eval_data["coefficient"]),
"Controle : %s (coef. %g)" % (self.evaltitre, self.evaluation.coefficient),
]
self.styles = None
self.plan = None
@ -339,10 +335,10 @@ class PlacementRunner:
def _production_pdf(self):
pdf_title = "<br>".join(self.desceval)
pdf_title += (
"\nDate : %(jour)s - Horaire : %(heure_debut)s à %(heure_fin)s"
% self.eval_data
)
pdf_title += f"""\nDate : {self.evaluation.date_debut.strftime("%d/%m/%Y")
if self.evaluation.date_debut else '-'
} - Horaire : {self.evaluation.heure_debut()} à {self.evaluation.heure_fin()
}"""
filename = "placement_%(evalname)s_%(gr_title_filename)s" % self.__dict__
titles = {
"nom": "Nom",
@ -489,8 +485,10 @@ class PlacementRunner:
worksheet.append_blank_row()
worksheet.append_single_cell_row(desceval, self.styles["titres"])
worksheet.append_single_cell_row(
"Date : %(jour)s - Horaire : %(heure_debut)s à %(heure_fin)s"
% self.eval_data,
f"""Date : {self.evaluation.date_debut.strftime("%d/%m/%Y")
if self.evaluation.date_debut else '-'
} - Horaire : {self.evaluation.heure_debut()} à {self.evaluation.heure_fin()
}""",
self.styles["titres"],
)

View File

@ -72,9 +72,7 @@ def etud_get_poursuite_info(sem: dict, etud: dict) -> dict:
moy_ues.append(
(
ue["acronyme"],
scu.fmt_note(
nt.get_etud_ue_status(etudid, ue["ue_id"])["moy"]
),
scu.fmt_note(ue_status["moy"]),
)
)
else:
@ -107,7 +105,9 @@ def etud_get_poursuite_info(sem: dict, etud: dict) -> dict:
rangs.append(["rang_" + code_module, rang_module])
# Absences
nbabs, nbabsjust = sco_assiduites.get_assiduites_count(etudid, nt.sem)
nbabsnj, nbabsjust, _ = sco_assiduites.get_assiduites_count(
etudid, nt.sem
)
# En BUT, prend tout, sinon ne prend que les semestre validés par le jury
if nt.is_apc or (
dec
@ -127,7 +127,7 @@ def etud_get_poursuite_info(sem: dict, etud: dict) -> dict:
("date_debut", s["date_debut"]),
("date_fin", s["date_fin"]),
("periode", "%s - %s" % (s["mois_debut"], s["mois_fin"])),
("AbsNonJust", nbabs - nbabsjust),
("AbsNonJust", nbabsnj),
("AbsJust", nbabsjust),
]
# ajout des 2 champs notes des modules et classement dans chaque module

View File

@ -1606,7 +1606,7 @@ class BasePreferences:
{
"initvalue": 1,
"title": "Afficher icône indiquant si les bulletins sont publiés",
"explanation": "décocher si vous n'avez pas de portail étudiant publiant les bulletins",
"explanation": "décocher si vous n'avez pas de passerelle ou portail étudiant publiant les bulletins",
"input_type": "boolcheckbox",
"labels": ["non", "oui"],
"category": "bul",

View File

@ -139,9 +139,9 @@ def feuille_preparation_jury(formsemestre_id):
main_partition_id, ""
)
# absences:
e_nbabs, e_nbabsjust = sco_assiduites.get_assiduites_count(etud.id, sem)
nbabs[etud.id] = e_nbabs
nbabsjust[etud.id] = e_nbabs - e_nbabsjust
_, nbabsjust[etud.id], nbabs[etud.id] = sco_assiduites.get_assiduites_count(
etud.id, sem
)
# Codes des UE "semestre précédent":
ue_prev_codes = list(prev_moy_ue.keys())

View File

@ -139,9 +139,8 @@ def dict_pvjury(
dec_ue_list = _descr_decisions_ues(
nt, etudid, d["decisions_ue"], d["decision_sem"]
)
d["decisions_ue_nb"] = len(
dec_ue_list
) # avec les UE capitalisées, donc des éventuels doublons
# avec les UE capitalisées, donc des éventuels doublons:
d["decisions_ue_nb"] = len(dec_ue_list)
# Mais sur la description (eg sur les bulletins), on ne veut pas
# afficher ces doublons: on uniquifie sur ue_code
_codes = set()
@ -291,8 +290,10 @@ def _descr_decisions_ues(nt, etudid, decisions_ue, decision_sem) -> list[dict]:
)
)
):
ue = sco_edit_ue.ue_list(args={"ue_id": ue_id})[0]
uelist.append(ue)
ue = UniteEns.query.get(ue_id)
assert ue
# note modernisation code: on utilise des dict tant que get_etud_ue_status renvoie des dicts
uelist.append(ue.to_dict())
# Les UE capitalisées dans d'autres semestres:
if etudid in nt.validations.ue_capitalisees.index:
for ue_id in nt.validations.ue_capitalisees.loc[[etudid]]["ue_id"]:

View File

@ -81,7 +81,7 @@ def formsemestre_recapcomplet(
mode_jury: cache modules, affiche lien saisie decision jury
xml_with_decisions: publie décisions de jury dans xml et json
force_publishing: publie les xml et json même si bulletins non publiés
force_publishing: publie les xml et json même si bulletins non publiés (sur la passerelle)
selected_etudid: etudid sélectionné (pour scroller au bon endroit)
"""
if not isinstance(formsemestre_id, int):
@ -398,7 +398,7 @@ def gen_formsemestre_recapcomplet_json(
) -> dict:
"""JSON export: liste tous les bulletins JSON
:param xml_nodate(bool): indique la date courante (attribut docdate)
:param force_publishing: donne les bulletins même si non "publiés sur portail"
:param force_publishing: donne les bulletins même si non "publiés sur la passerelle"
:returns: dict
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
@ -442,13 +442,13 @@ def gen_formsemestre_recapcomplet_json(
def formsemestres_bulletins(annee_scolaire):
"""Tous les bulletins des semestres publiés des semestres de l'année indiquée.
"""Tous les bulletins des semestres de l'année indiquée.
:param annee_scolaire(int): année de début de l'année scolaire
:returns: JSON
"""
js_list = []
sems = sco_formsemestre.list_formsemestre_by_etape(annee_scolaire=annee_scolaire)
log("formsemestres_bulletins(%s): %d sems" % (annee_scolaire, len(sems)))
log(f"formsemestres_bulletins({annee_scolaire}): {len(sems)} sems")
for sem in sems:
js_data = gen_formsemestre_recapcomplet_json(
sem["formsemestre_id"], force_publishing=False

View File

@ -134,12 +134,12 @@ def _displayNote(val):
return val
def _check_notes(notes: list[(int, float)], evaluation: Evaluation):
# XXX typehint : float or str
def _check_notes(notes: list[(int, float | str)], evaluation: Evaluation):
"""notes is a list of tuples (etudid, value)
mod is the module (used to ckeck type, for malus)
returns list of valid notes (etudid, float value)
and 4 lists of etudid: etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress
and 4 lists of etudid:
etudids_invalids, etudids_without_notes, etudids_absents, etudid_to_suppress
"""
note_max = evaluation.note_max or 0.0
module: Module = evaluation.moduleimpl.module
@ -148,7 +148,10 @@ def _check_notes(notes: list[(int, float)], evaluation: Evaluation):
scu.ModuleType.RESSOURCE,
scu.ModuleType.SAE,
):
note_min = scu.NOTES_MIN
if evaluation.evaluation_type == Evaluation.EVALUATION_BONUS:
note_min, note_max = -20, 20
else:
note_min = scu.NOTES_MIN
elif module.module_type == ModuleType.MALUS:
note_min = -20.0
else:
@ -528,6 +531,7 @@ def notes_add(
Return: tuple (etudids_changed, nb_suppress, etudids_with_decision)
"""
assert evaluation_id is not None
now = psycopg2.Timestamp(*time.localtime()[:6])
# Vérifie inscription et valeur note
@ -539,7 +543,7 @@ def notes_add(
}
for etudid, value in notes:
if check_inscription and (etudid not in inscrits):
raise NoteProcessError(f"etudiant {etudid} non inscrit dans ce module")
raise NoteProcessError(f"étudiant {etudid} non inscrit dans ce module")
if (value is not None) and not isinstance(value, float):
raise NoteProcessError(
f"etudiant {etudid}: valeur de note invalide ({value})"
@ -880,7 +884,7 @@ def feuille_saisie_notes(evaluation_id, group_ids=[]):
if evaluation.date_debut:
indication_date = evaluation.date_debut.date().isoformat()
else:
indication_date = scu.sanitize_filename(evaluation.description or "")[:12]
indication_date = scu.sanitize_filename(evaluation.description)[:12]
eval_name = f"{evaluation.moduleimpl.module.code}-{indication_date}"
date_str = (

View File

@ -35,7 +35,7 @@ from flask import g, url_for
from flask_login import current_user
from app import db, log
from app.models import Admission, Adresse, Identite, ScolarNews
from app.models import Admission, Adresse, FormSemestre, Identite, ScolarNews
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
@ -94,6 +94,7 @@ def formsemestre_synchro_etuds(
que l'on va importer/inscrire
"""
etuds = etuds or []
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
inscrits_without_key = inscrits_without_key or []
log(f"formsemestre_synchro_etuds: formsemestre_id={formsemestre_id}")
sem = sco_formsemestre.get_formsemestre(formsemestre_id)
@ -109,12 +110,13 @@ def formsemestre_synchro_etuds(
raise ScoValueError("opération impossible: semestre verrouille")
if not sem["etapes"]:
raise ScoValueError(
"""opération impossible: ce semestre n'a pas de code étape
(voir "<a href="formsemestre_editwithmodules?formsemestre_id=%(formsemestre_id)s">Modifier ce semestre</a>")
f"""opération impossible: ce semestre n'a pas de code étape
(voir <a class="stdlink" href="{
url_for('notes.formsemestre_editwithmodules',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
}">Modifier ce semestre</a>)
"""
% sem
)
header = html_sco_header.sco_header(page_title="Synchronisation étudiants")
footer = html_sco_header.sco_footer()
base_url = url_for(
"notes.formsemestre_synchro_etuds",
@ -165,7 +167,13 @@ def formsemestre_synchro_etuds(
suffix=scu.XLSX_SUFFIX,
)
H = [header]
H = [
html_sco_header.sco_header(
page_title="Synchronisation étudiants",
init_qtip=True,
javascripts=["js/etud_info.js"],
)
]
if not submitted:
H += _build_page(
sem,
@ -184,7 +192,7 @@ def formsemestre_synchro_etuds(
inscrits_without_key
)
log("a_desinscrire_without_key=%s" % a_desinscrire_without_key)
inscrits_ailleurs = set(sco_inscr_passage.list_inscrits_date(sem))
inscrits_ailleurs = set(sco_inscr_passage.list_inscrits_date(formsemestre))
a_inscrire = a_inscrire.intersection(etuds_set)
if not dialog_confirmed:
@ -205,10 +213,12 @@ def formsemestre_synchro_etuds(
a_inscrire_en_double = inscrits_ailleurs.intersection(a_inscrire)
if a_inscrire_en_double:
H.append("<h3>dont étudiants déjà inscrits:</h3><ol>")
H.append(
"<h3>dont étudiants déjà inscrits dans un autre semestre:</h3><ol>"
)
for key in a_inscrire_en_double:
nom = f"""{etudsapo_ident[key]['nom']} {etudsapo_ident[key].get("prenom", "")}"""
H.append(f'<li class="inscrailleurs">{nom}</li>')
H.append(f'<li class="inscrit-ailleurs">{nom}</li>')
H.append("</ol>")
if a_desinscrire:
@ -260,16 +270,26 @@ def formsemestre_synchro_etuds(
etudids_a_desinscrire = [nip2etudid(x) for x in a_desinscrire]
etudids_a_desinscrire += a_desinscrire_without_key
#
# check decisions jury ici pour éviter de recontruire le cache
# après chaque desinscription
sco_formsemestre_inscriptions.check_if_has_decision_jury(
formsemestre, a_desinscrire
)
with sco_cache.DeferredSemCacheManager():
do_import_etuds_from_portal(sem, a_importer, etudsapo_ident)
sco_inscr_passage.do_inscrit(sem, etudids_a_inscrire)
sco_inscr_passage.do_desinscrit(sem, etudids_a_desinscrire)
do_import_etuds_from_portal(formsemestre, a_importer, etudsapo_ident)
sco_inscr_passage.do_inscrit(formsemestre, etudids_a_inscrire)
sco_inscr_passage.do_desinscrit(
formsemestre, etudids_a_desinscrire, check_has_dec_jury=False
)
H.append(
"""<h3>Opération effectuée</h3>
f"""<h3>Opération effectuée</h3>
<ul>
<li><a class="stdlink" href="formsemestre_synchro_etuds?formsemestre_id=%s">Continuer la synchronisation</a></li>"""
% formsemestre_id
<li><a class="stdlink" href="{
url_for('notes.formsemestre_synchro_etuds',
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id
)}">Continuer la synchronisation</a>
</li>"""
)
#
partitions = sco_groups.get_partitions_list(
@ -279,8 +299,9 @@ def formsemestre_synchro_etuds(
H.append(
f"""<li><a class="stdlink" href="{
url_for("scolar.partition_editor",
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
}">Répartir les groupes de {partitions[0]["partition_name"]}</a></li>
scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id
)}">Répartir les groupes de {partitions[0]["partition_name"]}</a>
</li>
"""
)
@ -618,7 +639,7 @@ def get_annee_naissance(ddmmyyyyy: str) -> int:
return None
def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident):
def do_import_etuds_from_portal(formsemestre: FormSemestre, a_importer, etudsapo_ident):
"""Inscrit les etudiants Apogee dans ce semestre."""
log(f"do_import_etuds_from_portal: a_importer={a_importer}")
if not a_importer:
@ -672,7 +693,7 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident):
# Inscription au semestre
sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules(
sem["formsemestre_id"],
formsemestre.id,
etud.id,
etat=scu.INSCRIT,
etape=args["etape"],
@ -716,7 +737,7 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident):
ScolarNews.add(
typ=ScolarNews.NEWS_INSCR,
text=f"Import Apogée de {len(created_etudids)} étudiants en ",
obj=sem["formsemestre_id"],
obj=formsemestre.id,
)

View File

@ -60,16 +60,14 @@ from app.models.formsemestre import FormSemestre
from app import db, log
from app.models import Evaluation, ModuleImpl, UniteEns
from app.models import Evaluation, Identite, ModuleImpl, UniteEns
from app.scodoc import html_sco_header
from app.scodoc import codes_cursus
from app.scodoc import sco_edit_matiere
from app.scodoc import sco_edit_module
from app.scodoc import sco_edit_ue
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_saisie_notes
from app.scodoc import sco_etud
from app.scodoc.sco_exceptions import AccessDenied, ScoValueError
from app.scodoc.sco_permissions import Permission
from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
@ -83,10 +81,10 @@ def external_ue_create(
acronyme="",
ue_type=codes_cursus.UE_STANDARD,
ects=0.0,
) -> int:
) -> ModuleImpl:
"""Crée UE/matiere/module dans la formation du formsemestre
puis un moduleimpl.
Return: moduleimpl_id
Return: moduleimpl
"""
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
log(f"creating external UE in {formsemestre}: {acronyme}")
@ -139,28 +137,30 @@ def external_ue_create(
"module_id": module_id,
"formsemestre_id": formsemestre_id,
# affecte le 1er responsable du semestre comme resp. du module
"responsable_id": formsemestre.responsables[0].id
if len(formsemestre.responsables)
else None,
"responsable_id": (
formsemestre.responsables[0].id
if len(formsemestre.responsables)
else None
),
},
)
return moduleimpl_id
modimpl = ModuleImpl.query.get(moduleimpl_id)
assert modimpl
return modimpl
def external_ue_inscrit_et_note(
moduleimpl_id: int, formsemestre_id: int, notes_etuds: dict
moduleimpl: ModuleImpl, formsemestre_id: int, notes_etuds: dict
):
"""Inscrit les étudiants au moduleimpl, crée au besoin une évaluation
et enregistre les notes.
"""
moduleimpl: ModuleImpl = db.session.get(ModuleImpl, moduleimpl_id)
log(
f"external_ue_inscrit_et_note(moduleimpl_id={moduleimpl_id}, notes_etuds={notes_etuds})"
f"external_ue_inscrit_et_note(moduleimpl_id={moduleimpl.id}, notes_etuds={notes_etuds})"
)
# Inscription des étudiants
sco_moduleimpl.do_moduleimpl_inscrit_etuds(
moduleimpl_id,
moduleimpl.id,
formsemestre_id,
list(notes_etuds.keys()),
)
@ -175,7 +175,7 @@ def external_ue_inscrit_et_note(
note_max=20.0,
coefficient=1.0,
publish_incomplete=True,
evaluation_type=scu.EVALUATION_NORMALE,
evaluation_type=Evaluation.EVALUATION_NORMALE,
visibulletin=False,
description="note externe",
)
@ -188,12 +188,12 @@ def external_ue_inscrit_et_note(
)
def get_existing_external_ue(formation_id: int) -> list[dict]:
"Liste de toutes les UE externes définies dans cette formation"
return sco_edit_ue.ue_list(args={"formation_id": formation_id, "is_external": True})
def get_existing_external_ue(formation_id: int) -> list[UniteEns]:
"Liste de toutes les UEs externes définies dans cette formation"
return UniteEns.query.filter_by(formation_id=formation_id, is_external=True).all()
def get_external_moduleimpl_id(formsemestre_id: int, ue_id: int) -> int:
def get_external_moduleimpl(formsemestre_id: int, ue_id: int) -> ModuleImpl:
"moduleimpl correspondant à l'UE externe indiquée de ce formsemestre"
r = ndb.SimpleDictFetch(
"""
@ -205,7 +205,10 @@ def get_external_moduleimpl_id(formsemestre_id: int, ue_id: int) -> int:
{"ue_id": ue_id, "formsemestre_id": formsemestre_id},
)
if r:
return r[0]["moduleimpl_id"]
modimpl_id = r[0]["moduleimpl_id"]
modimpl = ModuleImpl.query.get(modimpl_id)
assert modimpl
return modimpl
else:
raise ScoValueError(
f"""Aucun module externe ne correspond
@ -225,20 +228,20 @@ def external_ue_create_form(formsemestre_id: int, etudid: int):
En BUT, pas d'UEs externes. Voir https://scodoc.org/git/ScoDoc/ScoDoc/issues/542
"""
formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
# Contrôle d'accès:
if not formsemestre.can_be_edited_by(current_user):
raise AccessDenied("vous n'avez pas le droit d'effectuer cette opération")
if formsemestre.formation.is_apc():
raise ScoValueError("Impossible d'ajouter une UE externe en BUT")
etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0]
etud = Identite.get_etud(etudid)
formation_id = formsemestre.formation.id
existing_external_ue = get_existing_external_ue(formation_id)
H = [
html_sco_header.html_sem_header(
"Ajout d'une UE externe pour %(nomprenom)s" % etud,
f"Ajout d'une UE externe pour {etud.nomprenom}",
javascripts=["js/sco_ue_external.js"],
),
"""<p class="help">Cette page permet d'indiquer que l'étudiant a suivi une UE
@ -275,10 +278,10 @@ def external_ue_create_form(formsemestre_id: int, etudid: int):
"input_type": "menu",
"title": "UE externe existante:",
"allowed_values": [""]
+ [str(ue["ue_id"]) for ue in existing_external_ue],
+ [str(ue.id) for ue in existing_external_ue],
"labels": [default_label]
+ [
"%s (%s)" % (ue["titre"], ue["acronyme"])
f"{ue.titre or ''} ({ue.acronyme})"
for ue in existing_external_ue
],
"attributes": ['onchange="update_external_ue_form();"'],
@ -364,7 +367,7 @@ def external_ue_create_form(formsemestre_id: int, etudid: int):
)
if tf[2]["existing_ue"]:
ue_id = int(tf[2]["existing_ue"])
moduleimpl_id = get_external_moduleimpl_id(formsemestre_id, ue_id)
modimpl = get_external_moduleimpl(formsemestre_id, ue_id)
else:
acronyme = tf[2]["acronyme"].strip()
if not acronyme:
@ -375,7 +378,7 @@ def external_ue_create_form(formsemestre_id: int, etudid: int):
+ tf[1]
+ html_footer
)
moduleimpl_id = external_ue_create(
modimpl = external_ue_create(
formsemestre_id,
titre=tf[2]["titre"],
acronyme=acronyme,
@ -384,7 +387,7 @@ def external_ue_create_form(formsemestre_id: int, etudid: int):
)
external_ue_inscrit_et_note(
moduleimpl_id,
modimpl,
formsemestre_id,
{etudid: note_value},
)

View File

@ -48,16 +48,15 @@ Opérations:
import datetime
from flask import request
from app.models import FormSemestre
from app.models import Evaluation, FormSemestre
from app.scodoc.intervals import intervalmap
import app.scodoc.sco_utils as scu
import app.scodoc.notesdb as ndb
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_moduleimpl
from app.scodoc import sco_preferences
from app.scodoc import sco_users
import sco_version
from app.scodoc.gen_tables import GenTable
import sco_version
# deux notes (de même uid) sont considérées comme de la même opération si
# elles sont séparées de moins de 2*tolerance:
@ -149,10 +148,8 @@ def list_operations(evaluation_id):
def evaluation_list_operations(evaluation_id):
"""Page listing operations on evaluation"""
E = sco_evaluation_db.get_evaluations_dict({"evaluation_id": evaluation_id})[0]
M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
Ops = list_operations(evaluation_id)
evaluation = Evaluation.get_evaluation(evaluation_id)
operations = list_operations(evaluation_id)
columns_ids = ("datestr", "user_name", "nb_notes", "comment")
titles = {
@ -164,11 +161,14 @@ def evaluation_list_operations(evaluation_id):
tab = GenTable(
titles=titles,
columns_ids=columns_ids,
rows=Ops,
rows=operations,
html_sortable=False,
html_title="<h2>Opérations sur l'évaluation %s du %s</h2>"
% (E["description"], E["jour"]),
preferences=sco_preferences.SemPreferences(M["formsemestre_id"]),
html_title=f"""<h2>Opérations sur l'évaluation {evaluation.description} {
evaluation.date_debut.strftime("du %d/%m/%Y") if evaluation.date_debut else "(sans date)"
}</h2>""",
preferences=sco_preferences.SemPreferences(
evaluation.moduleimpl.formsemestre_id
),
)
return tab.make_page()

View File

@ -130,7 +130,8 @@ def print_progress_bar(
decimals - Optional : nombres de chiffres après la virgule (Int)
length - Optional : taille de la barre en nombre de caractères (Int)
fill - Optional : charactère de remplissange de la barre (Str)
autosize - Optional : Choisir automatiquement la taille de la barre en fonction du terminal (Bool)
autosize - Optional : Choisir automatiquement la taille de la barre
en fonction du terminal (Bool)
"""
percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
color = TerminalColor.RED
@ -174,11 +175,15 @@ class BiDirectionalEnum(Enum):
@classmethod
def contains(cls, attr: str):
"""Vérifie sur un attribut existe dans l'enum"""
# Existe dans la classe parent de Enum (EnumType)
# pylint: disable-next=no-member
return attr.upper() in cls._member_names_
@classmethod
def all(cls, keys=True):
"""Retourne toutes les clés de l'enum"""
# pylint: disable-next=no-member
return cls._member_names_ if keys else list(cls._value2member_map_.keys())
@classmethod
@ -207,6 +212,9 @@ class EtatAssiduite(int, BiDirectionalEnum):
ABSENT = 2
def version_lisible(self) -> str:
"""Retourne une version lisible des états d'assiduités
Est utilisé pour les vues.
"""
return {
EtatAssiduite.PRESENT: "Présence",
EtatAssiduite.ABSENT: "Absence",
@ -225,6 +233,9 @@ class EtatJustificatif(int, BiDirectionalEnum):
MODIFIE = 3
def version_lisible(self) -> str:
"""Retourne une version lisible des états de justificatifs
Est utilisé pour les vues.
"""
return {
EtatJustificatif.VALIDE: "valide",
EtatJustificatif.ATTENTE: "soumis",
@ -254,11 +265,13 @@ class NonWorkDays(int, BiDirectionalEnum):
cls, formsemestre_id: int = None, dept_id: int = None
) -> list["NonWorkDays"]:
"""
get_all_non_work_days Récupère la liste des non workdays (str) depuis les préférences
get_all_non_work_days Récupère la liste des non workdays
(str) depuis les préférences
puis renvoie une liste BiDirectionnalEnum<int> NonWorkDays
Example:
non_work_days : list[NonWorkDays] = NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
non_work_days : list[NonWorkDays] =
NonWorkDays.get_all_non_work_days(dept_id=g.scodoc_dept_id)
if datetime.datetime.now().weekday() in non_work_days:
print("Aujourd'hui est un jour non travaillé")
@ -269,6 +282,8 @@ class NonWorkDays(int, BiDirectionalEnum):
Returns:
list[NonWorkDays]: La liste des NonWorkDays en version BiDirectionnalEnum<int>
"""
# Import circulaire
# pylint: disable=import-outside-toplevel
from app.scodoc import sco_preferences
return [
@ -454,10 +469,6 @@ NOTES_MENTIONS_LABS = (
"Excellent",
)
EVALUATION_NORMALE = 0
EVALUATION_RATTRAPAGE = 1
EVALUATION_SESSION2 = 2
# Dates et années scolaires
# Ces dates "pivot" sont paramétrables dans les préférences générales
# on donne ici les valeurs par défaut.

View File

@ -25,12 +25,14 @@
#
##############################################################################
"""Classe stockant le VDI avec le code étape (noms de fichiers maquettes et code semestres)
"""Apogée: gestion du VDI avec le code étape (noms de fichiers maquettes et code semestres)
"""
from app.scodoc.sco_exceptions import ScoValueError
class ApoEtapeVDI(object):
"""Classe stockant le VDI avec le code étape (noms de fichiers maquettes et code semestres)"""
_ETAPE_VDI_SEP = "!"
def __init__(self, etape_vdi: str = None, etape: str = "", vdi: str = ""):
@ -110,7 +112,8 @@ class ApoEtapeVDI(object):
elif len(t) == 2:
etape, vdi = t
else:
raise ValueError("invalid code etape")
# code étape invalide
etape, vdi = "", ""
return etape, vdi
else:
return etape_vdi, ""

View File

@ -35,6 +35,11 @@
min-width: var(--sco-content-min-width);
max-width: var(--sco-content-max-width);
}
div.jury_but_warning {
background-color: yellow;
border-color: red;
padding-bottom: 4px;
}
div.jury_but_box_title {
margin-bottom: 10px;
}

View File

@ -273,6 +273,10 @@ section>div:nth-child(1) {
min-width: 80px;
display: inline-block;
}
div.eval-bonus {
color: #197614;
background-color: pink;
}
.ueBonus,
.ueBonus h3 {

View File

@ -962,10 +962,18 @@ td.fichetitre2 .fl {
div.section_but {
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-end;
justify-content: space-evenly;
}
div.fiche_but_col2 {
display: flex;
flex-direction: column;
justify-content: space-between;
}
div.fiche_total_etcs {
font-weight: bold;
margin-top: 16px;
}
div.section_but > div.link_validation_rcues {
align-self: center;
text-align: center;
@ -1461,6 +1469,9 @@ span.eval_title {
font-size: 14pt;
}
#evaluation-edit-blocked td, #evaluation-edit-coef td {
padding-top: 24px;
}
/* #saisie_notes span.eval_title {
border-bottom: 1px solid rgb(100,100,100);
}
@ -1793,11 +1804,42 @@ table.formsemestre_status {
tr.formsemestre_status {
background-color: rgb(90%, 90%, 90%);
}
table.formsemestre_status tr td:first-child {
padding-left: 4px;
}
table.formsemestre_status tr td:last-child {
padding-right: 8px;
}
tr.formsemestre_status_green {
background-color: #eff7f2;
}
tr.modimpl_non_conforme td {
background-color: #ffc458;
}
tr.modimpl_non_conforme td, tr.modimpl_attente td {
padding-top: 4px;
padding-bottom: 4px;
}
tr.modimpl_has_blocked span.nb_evals_blocked, tr span.evals_attente {
background-color: yellow;
border-radius: 4px;
font-weight: bold;
margin-left: 8px;
padding-left: 4px;
padding-right: 4px;
}
tr.modimpl_has_blocked span.nb_evals_blocked {
color: red;
}
tr span.evals_attente {
background-color: orange;
color: green;
}
table.formsemestre_status a.redlink {
text-decoration: none;
}
tr.formsemestre_status_ue {
background-color: rgb(90%, 90%, 90%);
}
@ -2075,15 +2117,23 @@ th.moduleimpl_evaluations a:hover {
text-decoration: underline;
}
tr.mievr_in.evaluation_blocked th.moduleimpl_evaluation_moy span, tr.evaluation_blocked th.moduleimpl_evaluation_moy a {
font-weight: bold;
color: red;
background-color: yellow;
padding: 2px;
border-radius: 2px;
}
tr.mievr {
background-color: #eeeeee;
}
tr.mievr_rattr {
tr.mievr_rattr, tr.mievr_session2, tr.mievr_bonus {
background-color: #dddddd;
}
span.mievr_rattr {
span.mievr_rattr, span.mievr_session2, span.mievr_bonus {
display: inline-block;
font-weight: bold;
font-size: 80%;
@ -2129,6 +2179,16 @@ tr.mievr.non_visible_inter th {
);
}
tr.mievr_tit.evaluation_blocked td,tr.mievr_tit.evaluation_blocked th {
background-image: radial-gradient(#bd7777 1px, transparent 1px);
background-size: 10px 10px;
}
tr.mievr_in.evaluation_blocked td, tr.mievr_in.evaluation_blocked th {
background-color: rgb(195, 235, 255);
padding-top: 4px;
}
tr.mievr th {
background-color: white;
}
@ -2139,6 +2199,7 @@ tr.mievr td.mievr {
tr.mievr td.mievr_menu {
width: 110px;
padding-bottom: 4px;
}
tr.mievr td.mievr_dur {
@ -2411,6 +2472,29 @@ div.formation_list_ues_titre {
color: #eee;
}
div.formation_semestre_niveaux_warning {
font-weight: bold;
color: red;
padding: 4px;
margin-top: 8px;
margin-left: 24px;
margin-right: 24px;
background-color: yellow;
border-radius: 8px;
}
div.formation_semestre_niveaux_warning div {
color: black;
font-size: 110%;
}
div.formation_semestre_niveaux_warning ul {
list-style-type: none;
padding-left: 0;
}
div.formation_semestre_niveaux_warning ul li:before {
content: '⚠️';
margin-right: 10px; /* Adjust space between emoji and text */
}
div.formation_list_modules,
div.formation_list_ues {
border-radius: 18px;
@ -2426,6 +2510,7 @@ div.formation_list_ues {
}
div.formation_list_ues_content {
margin-top: 4px;
}
div.formation_list_modules {
@ -2508,7 +2593,13 @@ div.formation_parcs > div {
opacity: 0.7;
border-radius: 4px;
text-align: center;
padding: 4px 8px;
padding: 2px 6px;
margin-top: 8px;
margin-bottom: 2px;
}
div.formation_parcs > div.ue_tc {
color: black;
font-style: italic;
}
div.formation_parcs > div.focus {
@ -3316,14 +3407,35 @@ li.tf-msg {
padding-bottom: 5px;
}
.warning {
font-weight: bold;
div.formsemestre-warning-box {
background-color: yellow;
border-radius: 4px;
margin-top: 12px;
margin-left: 0px;
padding-left: 0px;
padding-right: 4px;
padding-top: 2px;
/* padding-bottom: 1px; */
}
.warning, .warning-bloquant {
color: red;
margin-left: 16px;
margin-bottom: 8px;
min-width: var(--sco-content-min-width);
max-width: var(--sco-content-max-width);
}
.warning::before {
content: url(/ScoDoc/static/icons/warning_img.png);
vertical-align: -80%;
content:"";
margin-right: 8px;
height:32px;
width: 32px;
background-size: 32px 32px;
background-image: url(/ScoDoc/static/icons/warning_std.svg);
background-repeat: no-repeat;
display: inline-block;
vertical-align: -40%;
}
.warning-light {
@ -3336,6 +3448,19 @@ li.tf-msg {
/* EMO_WARNING, "&#9888;&#65039;" */
}
.warning-bloquant::before {
content:"";
margin-right: 8px;
height:32px;
width: 32px;
background-size: 32px 32px;
background-image: url(/ScoDoc/static/icons/warning_bloquant.svg);
background-repeat: no-repeat;
display: inline-block;
vertical-align: -40%;
}
p.error {
font-weight: bold;
color: red;
@ -3714,10 +3839,17 @@ span.sp_etape {
color: black;
}
.inscrailleurs {
.deja-inscrit {
font-weight: bold;
color: rgb(1, 76, 1) !important;
}
.inscrit-ailleurs {
font-weight: bold;
color: red !important;
}
div.etuds_select_boxes {
margin-bottom: 16px;
}
span.paspaye,
span.paspaye a {
@ -4682,6 +4814,10 @@ table.table_recap th.col_malus {
font-weight: bold;
color: rgb(165, 0, 0);
}
table.table_recap td.col_eval_bonus,
table.table_recap th.col_eval_bonus {
color: #90c;
}
table.table_recap tr.ects td {
color: rgb(160, 86, 3);

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1 @@
<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m208.587 54.407-201.17 348.437c-21.073 36.499 5.268 82.122 47.413 82.122h402.34c42.145 0 68.486-45.624 47.413-82.122l-201.17-348.437c-21.072-36.498-73.754-36.498-94.826 0z" fill="#da4a54"/><path d="m54.83 443.76c-6.802 0-10.267-4.242-11.727-6.771s-3.401-7.65 0-13.542l201.17-348.436c3.401-5.891 8.807-6.771 11.727-6.771s8.326.88 11.727 6.771l201.17 348.436c3.401 5.891 1.46 11.013 0 13.541-1.46 2.529-4.925 6.771-11.727 6.771h-402.34z" fill="#f6e266"/><g fill="#544f57"><path d="m256 327.138c-14.379 0-26.036-11.657-26.036-26.036v-119.216c0-14.379 11.657-26.036 26.036-26.036 14.379 0 26.036 11.657 26.036 26.036v119.217c0 14.379-11.657 26.035-26.036 26.035z"/><circle cx="256" cy="381.152" r="26.036"/></g></svg>

After

Width:  |  Height:  |  Size: 857 B

View File

@ -491,14 +491,15 @@ class releveBUT extends HTMLElement {
let output = "";
evaluations.forEach((evaluation) => {
output += `
<div class=eval>
<div class="eval ${evaluation.evaluation_type == 3 ? "eval-bonus" : ""}">
<div>${this.URL(evaluation.url, evaluation.description || "Évaluation")}</div>
<div>
${evaluation.note.value}
<em>Coef.&nbsp;${evaluation.coef ?? "*"}</em>
<em>${evaluation.evaluation_type == 0 ? "Coef." : evaluation.evaluation_type == 3 ? "Bonus" : ""
}&nbsp;${evaluation.coef ?? ""}</em>
</div>
<div class=complement>
<div>Coef</div><div>${evaluation.coef}</div>
<div>${evaluation.evaluation_type == 0 ? "Coef." : ""}</div><div>${evaluation.coef ?? ""}</div>
<div>Max. promo.</div><div>${evaluation.note.max}</div>
<div>Moy. promo.</div><div>${evaluation.note.moy}</div>
<div>Min. promo.</div><div>${evaluation.note.min}</div>

View File

@ -1,3 +1,8 @@
"""
Gestion des listes d'assiduités et justificatifs
(affichage, pagination, filtrage, options d'affichage, tableaux)
"""
from datetime import datetime
from flask import url_for
@ -8,10 +13,18 @@ from sqlalchemy import desc, literal, union, asc
from app import db, g
from app.auth.models import User
from app.models import Assiduite, Identite, Justificatif
from app.scodoc.sco_utils import EtatAssiduite, EtatJustificatif, to_bool
from app.scodoc.sco_utils import (
EtatAssiduite,
EtatJustificatif,
to_bool,
date_debut_annee_scolaire,
date_fin_annee_scolaire,
localize_datetime,
)
from app.tables import table_builder as tb
from app.scodoc.sco_cache import RequeteTableauAssiduiteCache
from app.scodoc.sco_permissions import Permission
from app.scodoc.sco_preferences import get_preference
class Pagination:
@ -26,9 +39,11 @@ class Pagination:
On peut ensuite récupérer les éléments de la page courante avec la méthode `items()`
Cette classe ne permet pas de changer de page.
(Pour cela, il faut créer une nouvelle instance, avec la collection originelle et la nouvelle page)
(Pour cela, il faut créer une nouvelle instance,
avec la collection originelle et la nouvelle page)
l'intéret est de ne pas garder en mémoire toute la collection, mais seulement la page courante
l'intéret est de ne pas garder en mémoire toute la collection,
mais seulement la page courante
"""
@ -37,9 +52,11 @@ class Pagination:
__init__ Instancie un nouvel objet Pagination
Args:
collection (list): La collection à paginer. Il s'agit par exemple d'une requête
collection (list): La collection à paginer.
Il s'agit par exemple d'une requête
page (int, optional): le numéro de la page à voir. Defaults to 1.
per_page (int, optional): le nombre d'éléments par page. Defaults to -1. (-1 = pas de pagination/tout afficher)
per_page (int, optional): le nombre d'éléments par page.
Defaults to -1. (-1 = pas de pagination/tout afficher)
"""
# par défaut le total des pages est 1 (même si la collection est vide)
self.total_pages = 1
@ -195,6 +212,17 @@ class ListeAssiJusti(tb.Table):
r = query_finale.all()
RequeteTableauAssiduiteCache.set(cle_cache, r)
# Filtrer Si préférence "Limiter les assiduités à l'année courante"
if get_preference("assi_limit_annee"):
annee_debut = localize_datetime(date_debut_annee_scolaire())
annee_fin = localize_datetime(date_fin_annee_scolaire())
r = [
obj
for obj in r
if obj._asdict()["date_debut"] >= annee_debut
and obj._asdict()["date_fin"] <= annee_fin
]
# Paginer la requête pour ne pas envoyer trop d'informations au client
pagination: Pagination = self.paginer(r, no_pagination=self.no_pagination)
self.total_pages = pagination.total_pages
@ -212,15 +240,17 @@ class ListeAssiJusti(tb.Table):
attributs `page` et `NB_PAR_PAGE` de la classe `ListeAssiJusti`.
Args:
collection (list): La collection à paginer. Il s'agit par exemple d'une requête qui a déjà
collection (list): La collection à paginer.
Il s'agit par exemple d'une requête qui a déjà
été construite et qui est prête à être exécutée.
Returns:
Pagination: Un objet Pagination qui encapsule les résultats de la requête paginée.
Pagination: Un objet Pagination qui encapsule les résultats de
la requête paginée.
Note:
Cette méthode ne modifie pas la collection originelle; elle renvoie plutôt un nouvel
objet qui contient les résultats paginés.
Cette méthode ne modifie pas la collection originelle;
elle renvoie plutôt un nouvel objet qui contient les résultats paginés.
"""
return Pagination(
collection,
@ -232,29 +262,35 @@ class ListeAssiJusti(tb.Table):
"""
Combine les requêtes d'assiduités et de justificatifs en une seule requête.
Cette fonction prend en entrée deux requêtes optionnelles, une pour les assiduités
et une pour les justificatifs, et renvoie une requête combinée qui sélectionne
un ensemble spécifique de colonnes pour chaque type d'objet.
Cette fonction prend en entrée deux requêtes optionnelles,
une pour les assiduités et une pour les justificatifs,
et renvoie une requête combinée qui sélectionne un ensemble
spécifique de colonnes pour chaque type d'objet.
Les colonnes sélectionnées sont:
- obj_id: l'identifiant de l'objet (assiduite_id pour les assiduités, justif_id pour les justificatifs)
- obj_id: l'identifiant de l'objet
(assiduite_id pour les assiduités, justif_id pour les justificatifs)
- etudid: l'identifiant de l'étudiant
- entry_date: la date de saisie de l'objet
- date_debut: la date de début de l'objet
- date_fin: la date de fin de l'objet
- etat: l'état de l'objet
- type: le type de l'objet ("assiduite" pour les assiduités, "justificatif" pour les justificatifs)
- type: le type de l'objet
("assiduite" pour les assiduités, "justificatif" pour les justificatifs)
- est_just : si l'assiduité est justifié (booléen) None pour les justificatifs
- user_id : l'identifiant de l'utilisateur qui a signalé l'assiduité ou le justificatif
- user_id : l'identifiant de l'utilisateur qui a
signalé l'assiduité ou le justificatif
Args:
query_assiduite (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy
pour les assiduités.
Si None (default), aucune assiduité ne sera incluse dans la requête combinée.
Si None (default), aucune assiduité ne sera incluse
dans la requête combinée.
query_justificatif (sqlalchemy.orm.Query, optional): Une requête SQLAlchemy
pour les justificatifs.
Si None (default), aucun justificatif ne sera inclus dans la requête combinée.
Si None (default), aucun justificatif ne sera
inclus dans la requête combinée.
Returns:
sqlalchemy.orm.Query: Une requête combinée qui peut être exécutée pour
@ -599,10 +635,15 @@ class AssiFiltre:
Args:
type_obj (int, optional): type d'objet (0:Tout, 1: Assi, 2:Justi). Defaults to 0.
entry_date (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
date_debut (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
date_fin (tuple[int, datetime], optional): (0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
etats (list[int | EtatJustificatif | EtatAssiduite], optional): liste d'états valides (int | EtatJustificatif | EtatAssiduite). Defaults to None.
entry_date (tuple[int, datetime], optional):
(0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
date_debut (tuple[int, datetime], optional):
(0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
date_fin (tuple[int, datetime], optional):
(0: egal, 1: avant, 2: après) + datetime(avec TZ). Defaults to None.
etats (list[int | EtatJustificatif | EtatAssiduite], optional):
liste d'états valides (int | EtatJustificatif | EtatAssiduite).
Defaults to None.
"""
self.filtres = {"type_obj": type_obj}
@ -637,7 +678,7 @@ class AssiFiltre:
type_filtrage, date = val_filtre
match (type_filtrage):
match type_filtrage:
# On garde uniquement les dates supérieures au filtre
case 2:
query_filtree = query_filtree.filter(
@ -734,6 +775,10 @@ class AssiJustifData:
@staticmethod
def from_etudiants(*etudiants: Identite) -> "AssiJustifData":
"""
Génère un object AssiJustifData à partir d'une liste d'étudiants
(Récupère les assiduités et justificatifs des étudiants)
"""
data = AssiJustifData()
data.assiduites_query = Assiduite.query.filter(
Assiduite.etudid.in_([e.etudid for e in etudiants])
@ -745,4 +790,5 @@ class AssiJustifData:
return data
def get(self) -> tuple[Query, Query]:
"Renvoi les requêtes d'assiduités et justificatifs"
return self.assiduites_query, self.justificatifs_query

View File

@ -13,7 +13,7 @@ import numpy as np
from app import db
from app.auth.models import User
from app.comp.res_common import ResultatsSemestre
from app.models import Identite, FormSemestre, UniteEns
from app.models import Identite, Evaluation, FormSemestre, UniteEns
from app.scodoc.codes_cursus import UE_SPORT, DEF
from app.scodoc import sco_evaluation_db
from app.scodoc import sco_groups
@ -405,15 +405,22 @@ class TableRecap(tb.Table):
val = notes_db[etudid]["value"]
else:
# Note manquante mais prise en compte immédiate: affiche ATT
val = scu.NOTES_ATTENTE
val = (
scu.NOTES_ATTENTE
if e.evaluation_type != Evaluation.EVALUATION_BONUS
else ""
)
content = self.fmt_note(val)
classes = col_classes + [
{
"ABS": "abs",
"ATT": "att",
"EXC": "exc",
}.get(content, "")
]
if e.evaluation_type != Evaluation.EVALUATION_BONUS:
classes = col_classes + [
{
"ABS": "abs",
"ATT": "att",
"EXC": "exc",
}.get(content, "")
]
else:
classes = col_classes + ["col_eval_bonus"]
row.add_cell(
col_id, title, content, group="eval", classes=classes
)
@ -450,7 +457,7 @@ class TableRecap(tb.Table):
row_descr_eval.add_cell(
col_id,
None,
e.description or "",
e.description,
target=url_for(
"notes.evaluation_listenotes",
scodoc_dept=g.scodoc_dept,
@ -613,9 +620,11 @@ class RowRecap(tb.Row):
def add_abs(self):
"Ajoute les colonnes absences"
# Absences (nb d'abs non just. dans ce semestre)
nbabs, nbabsjust = self.table.res.formsemestre.get_abs_count(self.etud.id)
self.add_cell("nbabs", "Abs", nbabs, "abs")
self.add_cell("nbabsjust", "Just.", nbabsjust, "abs")
_, nbabsjust, nbabs = self.table.res.formsemestre.get_abs_count(self.etud.id)
self.add_cell("nbabs", "Abs", f"{nbabs:1.0f}", "abs", raw_content=nbabs)
self.add_cell(
"nbabsjust", "Just.", f"{nbabsjust:1.0f}", "abs", raw_content=nbabsjust
)
def add_moyennes_cols(
self,

View File

@ -37,7 +37,7 @@ class TableAssi(tb.Table):
convert_values=False,
**kwargs,
):
self.rows: list["RowEtud"] = [] # juste pour que VSCode nous aide sur .rows
self.rows: list["RowAssi"] = [] # juste pour que VSCode nous aide sur .rows
classes = ["gt_table"]
self.dates = [str(dates[0]) + "T00:00", str(dates[1]) + "T23:59"]
self.formsemestre = formsemestre

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