diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py index 098bd128..b2721941 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -21,6 +21,7 @@ from flask import g from app.models.formsemestre import FormSemestre from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_codes_parcours import ParcoursDUT, ParcoursDUTMono from app.scodoc.sco_utils import ModuleType @@ -53,7 +54,7 @@ class BonusSport: etud_moy_gen et etud_moy_ue ne sont PAS modifiés (mais utilisés par certains bonus non additifs). """ - # En classique, active un bonus sur les UEs: (dans ce cas bonus_moy_gen reste None) + # En classique, active un bonus sur les UEs: (dans ce cas bonus_moy_gen est ajusté pour le prendre en compte) classic_use_bonus_ues = False # Attributs virtuels: @@ -198,23 +199,29 @@ class BonusSportAdditif(BonusSport): à la moyenne générale du semestre déjà obtenue par l'étudiant. """ - seuil_moy_gen = 10.0 # seuls les points au dessus du seuil sont comptés + seuil_moy_gen = 10.0 # seuls les bonus au dessus du seuil sont pris en compte + seuil_comptage = ( + None # les points au dessus du seuil sont comptés (defaut: seuil_moy_gen) + ) proportion_point = 0.05 # multiplie les points au dessus du seuil def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus sem_modimpl_moys_inscrits: les notes de sport En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus) - modimpl_coefs_etuds_no_nan: + En classic: ndarray (nb_etuds, nb_mod_sport) + modimpl_coefs_etuds_no_nan: même shape, les coefs. """ if 0 in sem_modimpl_moys_inscrits.shape: # pas d'étudiants ou pas d'UE ou pas de module... return + seuil_comptage = ( + self.seuil_moy_gen if self.seuil_comptage is None else self.seuil_comptage + ) bonus_moy_arr = np.sum( np.where( sem_modimpl_moys_inscrits > self.seuil_moy_gen, - (sem_modimpl_moys_inscrits - self.seuil_moy_gen) - * self.proportion_point, + (sem_modimpl_moys_inscrits - seuil_comptage) * self.proportion_point, 0.0, ), axis=1, @@ -227,13 +234,27 @@ class BonusSportAdditif(BonusSport): else: # necessaire pour éviter bonus négatifs ! bonus_moy_arr = np.clip(bonus_moy_arr, 0.0, 20.0, out=bonus_moy_arr) + self.bonus_additif(bonus_moy_arr) + + def bonus_additif(self, bonus_moy_arr: np.array): + "Set bonus_ues et bonus_moy_gen" # en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus) - if self.formsemestre.formation.is_apc() or self.classic_use_bonus_ues: + if self.formsemestre.formation.is_apc(): # Bonus sur les UE et None sur moyenne générale ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] self.bonus_ues = pd.DataFrame( bonus_moy_arr, index=self.etuds_idx, columns=ues_idx, dtype=float ) + elif self.classic_use_bonus_ues: + # Formations classiques apppliquant le bonus sur les UEs + # ici bonus_moy_arr = ndarray 1d nb_etuds + ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] + self.bonus_ues = pd.DataFrame( + np.stack([bonus_moy_arr] * len(ues_idx)).T, + index=self.etuds_idx, + columns=ues_idx, + dtype=float, + ) else: # Bonus sur la moyenne générale seulement self.bonus_moy_gen = pd.Series( @@ -284,6 +305,7 @@ class BonusSportMultiplicatif(BonusSport): class BonusDirect(BonusSportAdditif): """Bonus direct: les points sont directement ajoutés à la moyenne générale. + Les coefficients sont ignorés: tous les points de bonus sont sommés. (rappel: la note est ramenée sur 20 avant application). """ @@ -294,47 +316,108 @@ class BonusDirect(BonusSportAdditif): proportion_point = 1.0 -class BonusAnnecy(BonusSport): - """Calcul bonus modules optionnels (sport), règle IUT d'Annecy. - Il peut y avoir plusieurs modules de bonus. - Prend pour chaque étudiant la meilleure de ses notes bonus et - ajoute à chaque UE : - 0.05 point si >=10, - 0.1 point si >=12, - 0.15 point si >=14, - 0.2 point si >=16, - 0.25 point si >=18. +class BonusAisneStQuentin(BonusSportAdditif): + """Calcul bonus modules optionels (sport, culture), règle IUT Aisne St Quentin + +

Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université de St Quentin non rattachés à une unité d'enseignement. +

+ +

+ Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par + l'étudiant (en BUT, s'ajoute à la moyenne de chaque UE). +

""" - name = "bonus_iut_annecy" - displayed_name = "IUT d'Annecy" + name = "bonus_iutstq" + displayed_name = "IUT de Saint-Quentin" def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus""" - # if math.prod(sem_modimpl_moys_inscrits.shape) == 0: - # return # no etuds or no mod sport - # Prend la note de chaque modimpl, sans considération d'UE - if len(sem_modimpl_moys_inscrits.shape) > 2: # apc - sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] - # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic - note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds - bonus = np.zeros(note_bonus_max.shape) - bonus[note_bonus_max >= 18.0] = 0.25 - bonus[note_bonus_max >= 16.0] = 0.20 - bonus[note_bonus_max >= 14.0] = 0.15 - bonus[note_bonus_max >= 12.0] = 0.10 - bonus[note_bonus_max >= 10.0] = 0.05 + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return + # Calcule moyenne pondérée des notes de sport: + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + bonus_moy_arr = np.sum( + sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + np.nan_to_num(bonus_moy_arr, nan=0.0, copy=False) - # Bonus moyenne générale et sur les UE - self.bonus_moy_gen = pd.Series(bonus, index=self.etuds_idx, dtype=float) - ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] - nb_ues_no_bonus = len(ues_idx) - self.bonus_ues = pd.DataFrame( - np.stack([bonus] * nb_ues_no_bonus, axis=1), - columns=ues_idx, - index=self.etuds_idx, - dtype=float, - ) + bonus_moy_arr[bonus_moy_arr < 10.0] = 0.0 + bonus_moy_arr[bonus_moy_arr >= 18.1] = 0.5 + bonus_moy_arr[bonus_moy_arr >= 16.1] = 0.4 + bonus_moy_arr[bonus_moy_arr >= 14.1] = 0.3 + bonus_moy_arr[bonus_moy_arr >= 12.1] = 0.2 + bonus_moy_arr[bonus_moy_arr >= 10] = 0.1 + + self.bonus_additif(bonus_moy_arr) + + +class BonusAmiens(BonusSportAdditif): + """Bonus IUT Amiens pour les modules optionnels (sport, culture, ...). + + Toute note non nulle, peu importe sa valeur, entraine un bonus de 0,1 point + sur toutes les moyennes d'UE. + """ + + name = "bonus_amiens" + displayed_name = "IUT d'Amiens" + seuil_moy_gen = 0.0 # tous les points sont comptés + proportion_point = 1e10 + bonus_max = 0.1 + classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP + + +# Finalement ils n'en veulent pas. +# class BonusAnnecy(BonusSport): +# """Calcul bonus modules optionnels (sport), règle IUT d'Annecy. + +# Il peut y avoir plusieurs modules de bonus. +# Prend pour chaque étudiant la meilleure de ses notes bonus et +# ajoute à chaque UE :
+# 0.05 point si >=10,
+# 0.1 point si >=12,
+# 0.15 point si >=14,
+# 0.2 point si >=16,
+# 0.25 point si >=18. +# """ + +# name = "bonus_iut_annecy" +# displayed_name = "IUT d'Annecy" + +# def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): +# """calcul du bonus""" +# # if math.prod(sem_modimpl_moys_inscrits.shape) == 0: +# # return # no etuds or no mod sport +# # Prend la note de chaque modimpl, sans considération d'UE +# if len(sem_modimpl_moys_inscrits.shape) > 2: # apc +# sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] +# # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic +# note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds +# bonus = np.zeros(note_bonus_max.shape) +# bonus[note_bonus_max >= 10.0] = 0.05 +# bonus[note_bonus_max >= 12.0] = 0.10 +# bonus[note_bonus_max >= 14.0] = 0.15 +# bonus[note_bonus_max >= 16.0] = 0.20 +# bonus[note_bonus_max >= 18.0] = 0.25 + +# # Bonus moyenne générale et sur les UE +# self.bonus_moy_gen = pd.Series(bonus, index=self.etuds_idx, dtype=float) +# ues_idx = [ue.id for ue in self.formsemestre.query_ues(with_sport=False)] +# nb_ues_no_bonus = len(ues_idx) +# self.bonus_ues = pd.DataFrame( +# np.stack([bonus] * nb_ues_no_bonus, axis=1), +# columns=ues_idx, +# index=self.etuds_idx, +# dtype=float, +# ) class BonusBethune(BonusSportMultiplicatif): @@ -373,26 +456,150 @@ class BonusBezier(BonusSportAdditif): class BonusBordeaux1(BonusSportMultiplicatif): - """Calcul bonus modules optionnels (sport, culture), règle IUT Bordeaux 1, sur moyenne générale - et UE. - + """Calcul bonus modules optionnels (sport, culture), règle IUT Bordeaux 1, + sur moyenne générale et UEs. +

Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Bordeaux 1 (sport, théâtre) non rattachés à une unité d'enseignement. - +

Chaque point au-dessus de 10 sur 20 obtenus dans cet enseignement correspond à un % - qui augmente la moyenne de chaque UE et la moyenne générale. - Formule : le % = points>moyenne / 2 + qui augmente la moyenne de chaque UE et la moyenne générale.
+ Formule : pourcentage = (points au dessus de 10) / 2 +

Par exemple : sport 13/20 : chaque UE sera multipliée par 1+0,015, ainsi que la moyenne générale. - +

""" name = "bonus_iutBordeaux1" - displayed_name = "IUT de Bordeaux 1" + displayed_name = "IUT de Bordeaux" classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP seuil_moy_gen = 10.0 amplitude = 0.005 +# Exactement le même que Bordeaux: +class BonusBrest(BonusSportMultiplicatif): + """Calcul bonus modules optionnels (sport, culture), règle IUT de Brest, + sur moyenne générale et UEs. +

+ Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université (sport, théâtre) non rattachés à une unité d'enseignement. +

+ Chaque point au-dessus de 10 sur 20 obtenus dans cet enseignement correspond à un % + qui augmente la moyenne de chaque UE et la moyenne générale.
+ Formule : pourcentage = (points au dessus de 10) / 2 +

+ Par exemple : sport 13/20 : chaque UE sera multipliée par 1+0,015, ainsi que la moyenne générale. +

+ """ + + name = "bonus_iut_brest" + displayed_name = "IUT de Brest" + classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP + seuil_moy_gen = 10.0 + amplitude = 0.005 + + +class BonusCachan1(BonusSportAdditif): + """Calcul bonus optionnels (sport, culture), règle IUT de Cachan 1. + + + """ + + name = "bonus_cachan1" + displayed_name = "IUT de Cachan 1" + seuil_moy_gen = 10.0 # tous les points sont comptés + proportion_point = 0.05 + classic_use_bonus_ues = True + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus, avec réglage différent suivant le type de formation""" + # Prend la note de chaque modimpl, sans considération d'UE + if len(sem_modimpl_moys_inscrits.shape) > 2: # apc + sem_modimpl_moys_inscrits = sem_modimpl_moys_inscrits[:, :, 0] + # ici sem_modimpl_moys_inscrits est nb_etuds x nb_mods_bonus, en APC et en classic + note_bonus_max = np.max(sem_modimpl_moys_inscrits, axis=1) # 1d, nb_etuds + ues = self.formsemestre.query_ues(with_sport=False).all() + ues_idx = [ue.id for ue in ues] + + if self.formsemestre.formation.is_apc(): # --- BUT + bonus_moy_arr = np.where( + note_bonus_max > self.seuil_moy_gen, + (note_bonus_max - self.seuil_moy_gen) * self.proportion_point, + 0.0, + ) + self.bonus_ues = pd.DataFrame( + np.stack([bonus_moy_arr] * len(ues)).T, + index=self.etuds_idx, + columns=ues_idx, + dtype=float, + ) + else: # --- DUT + # pareil mais proportion différente et exclusion d'une UE + proportion_point = 0.1 + bonus_moy_arr = np.where( + note_bonus_max > self.seuil_moy_gen, + (note_bonus_max - self.seuil_moy_gen) * proportion_point, + 0.0, + ) + self.bonus_ues = pd.DataFrame( + np.stack([bonus_moy_arr] * len(ues)).T, + index=self.etuds_idx, + columns=ues_idx, + dtype=float, + ) + # Pas de bonus sur la ou les ue de code "UE41_E" + ue_exclues = [ue for ue in ues if ue.ue_code == "UE41_E"] + for ue in ue_exclues: + self.bonus_ues[ue.id] = 0.0 + + +class BonusCalais(BonusSportAdditif): + """Calcul bonus modules optionnels (sport, culture), règle IUT LCO. + + Les étudiants de l'IUT LCO peuvent suivre des enseignements optionnels non + rattachés à une unité d'enseignement. Les points au-dessus de 10 + sur 20 obtenus dans chacune des matières optionnelles sont cumulés + dans la limite de 10 points. 6% de ces points cumulés s'ajoutent : + + """ + + name = "bonus_calais" + displayed_name = "IUT du Littoral" + bonus_max = 0.6 + seuil_moy_gen = 10.0 # au dessus de 10 + proportion_point = 0.06 # 6% + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + parcours = self.formsemestre.formation.get_parcours() + # Variantes de DUT ? + if ( + isinstance(parcours, ParcoursDUT) + or parcours.TYPE_PARCOURS == ParcoursDUTMono.TYPE_PARCOURS + ): # DUT + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + else: + self.classic_use_bonus_ues = True # pour les LP + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + ues = self.formsemestre.query_ues(with_sport=False).all() + ues_sans_bs = [ + ue for ue in ues if ue.acronyme[-2:].upper() != "BS" + ] # les 2 derniers cars forcés en majus + for ue in ues_sans_bs: + self.bonus_ues[ue.id] = 0.0 + + class BonusColmar(BonusSportAdditif): """Calcul bonus modules optionnels (sport, culture), règle IUT Colmar. @@ -417,19 +624,21 @@ class BonusColmar(BonusSportAdditif): class BonusGrenobleIUT1(BonusSportMultiplicatif): """Bonus IUT1 de Grenoble +

À compter de sept. 2021: La note de sport est sur 20, et on calcule une bonification (en %) qui va s'appliquer à la moyenne de chaque UE du semestre en appliquant la formule : bonification (en %) = (note-10)*0,5. - - Bonification qui ne s'applique que si la note est >10. - - (Une note de 10 donne donc 0% de bonif ; note de 20 : 5% de bonif) - +

+ La bonification ne s'applique que si la note est supérieure à 10. +

+ (Une note de 10 donne donc 0% de bonif, et une note de 20 : 5% de bonif) +

Avant sept. 2021, la note de sport allait de 0 à 5 points (sur 20). Chaque point correspondait à 0.25% d'augmentation de la moyenne générale. Par exemple : note de sport 2/5 : la moyenne générale était augmentée de 0.5%. +

""" name = "bonus_iut1grenoble_2017" @@ -456,15 +665,18 @@ class BonusGrenobleIUT1(BonusSportMultiplicatif): class BonusLaRochelle(BonusSportAdditif): """Calcul bonus modules optionnels (sport, culture), règle IUT de La Rochelle. - Si la note de sport est comprise entre 0 et 10 : pas d'ajout de point. - Si la note de sport est comprise entre 10 et 20 : ajout de 1% de cette - note sur la moyenne générale du semestre (ou sur les UE en BUT). + """ name = "bonus_iutlr" displayed_name = "IUT de La Rochelle" - seuil_moy_gen = 10.0 # tous les points sont comptés - proportion_point = 0.01 + seuil_moy_gen = 10.0 # si bonus > 10, + seuil_comptage = 0.0 # tous les points sont comptés + proportion_point = 0.01 # 1% class BonusLeHavre(BonusSportMultiplicatif): @@ -483,16 +695,17 @@ class BonusLeHavre(BonusSportMultiplicatif): class BonusLeMans(BonusSportAdditif): """Calcul bonus modules optionnels (sport, culture), règle IUT Le Mans. - Les points au-dessus de 10 sur 20 obtenus dans chacune des matières +

Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles sont cumulés. +

+ +

Dans tous les cas, le bonus est dans la limite de 0,5 point.

""" name = "bonus_iutlemans" @@ -516,12 +729,13 @@ class BonusLeMans(BonusSportAdditif): class BonusLille(BonusSportAdditif): """Calcul bonus modules optionnels (sport, culture), règle IUT Villeneuve d'Ascq - Les étudiants de l'IUT peuvent suivre des enseignements optionnels +

Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Lille (sports, etc) non rattachés à une unité d'enseignement. - +

Les points au-dessus de 10 sur 20 obtenus dans chacune des matières optionnelles sont cumulés et 4% (2% avant août 2010) de ces points cumulés s'ajoutent à la moyenne générale du semestre déjà obtenue par l'étudiant. +

""" name = "bonus_lille" @@ -573,17 +787,19 @@ class BonusMulhouse(BonusSportAdditif): class BonusNantes(BonusSportAdditif): """IUT de Nantes (Septembre 2018) - Nous avons différents types de bonification +

Nous avons différents types de bonification (sport, culture, engagement citoyen). - +

Nous ajoutons aux moyennes une bonification de 0,2 pour chaque item la bonification totale ne doit pas excéder les 0,5 point. Sur le bulletin nous ne mettons pas une note sur 20 mais directement les bonifications. - - Dans ScoDoc: on a déclarera une UE "sport&culture" dans laquelle on aura des modules - pour chaque activité (Sport, Associations, ...) - avec à chaque fois une note (ScoDoc l'affichera comme une note sur 20, mais en fait ce sera la - valeur de la bonification: entrer 0,1/20 signifiera un bonus de 0,1 point la moyenne générale) +

+ Dans ScoDoc: on a déclarera une UE "sport&culture" dans laquelle on aura + des modules pour chaque activité (Sport, Associations, ...) + avec à chaque fois une note (ScoDoc l'affichera comme une note sur 20, + mais en fait ce sera la valeur de la bonification: entrer 0,1/20 signifiera + un bonus de 0,1 point la moyenne générale). +

""" name = "bonus_nantes" @@ -604,7 +820,8 @@ class BonusRoanne(BonusSportAdditif): displayed_name = "IUT de Roanne" seuil_moy_gen = 0.0 bonus_max = 0.6 # plafonnement à 0.6 points - apply_bonus_mg_to_ues = True # sur les UE, même en DUT et LP + classic_use_bonus_ues = True # sur les UE, même en DUT et LP + proportion_point = 1 class BonusStDenis(BonusSportAdditif): @@ -627,13 +844,14 @@ class BonusStDenis(BonusSportAdditif): class BonusTours(BonusDirect): """Calcul bonus sport & culture IUT Tours. - Les notes des UE bonus (ramenées sur 20) sont sommées +

Les notes des UE bonus (ramenées sur 20) sont sommées et 1/40 (2,5%) est ajouté aux moyennes: soit à la moyenne générale, soit pour le BUT à chaque moyenne d'UE. - - Attention: en GEII, facteur 1/40, ailleurs facteur 1. - +

+ Attention: en GEII, facteur 1/40, ailleurs facteur 1. +

Le bonus total est limité à 1 point. +

""" name = "bonus_tours" @@ -658,11 +876,13 @@ class BonusVilleAvray(BonusSport): Les étudiants de l'IUT peuvent suivre des enseignements optionnels de l'Université Paris 10 (C2I) non rattachés à une unité d'enseignement. - Si la note est >= 10 et < 12, bonus de 0.1 point - Si la note est >= 12 et < 16, bonus de 0.2 point - Si la note est >= 16, bonus de 0.3 point - Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par - l'étudiant. + +

Ce bonus s'ajoute à la moyenne générale du semestre déjà obtenue par + l'étudiant.

""" name = "bonus_iutva" @@ -670,21 +890,21 @@ class BonusVilleAvray(BonusSport): def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus""" + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return # Calcule moyenne pondérée des notes de sport: - bonus_moy_arr = np.sum( - sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 - ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) - bonus_moy_arr[bonus_moy_arr >= 10.0] = 0.1 - bonus_moy_arr[bonus_moy_arr >= 12.0] = 0.2 + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + bonus_moy_arr = np.sum( + sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + np.nan_to_num(bonus_moy_arr, nan=0.0, copy=False) + bonus_moy_arr[bonus_moy_arr < 10.0] = 0.0 bonus_moy_arr[bonus_moy_arr >= 16.0] = 0.3 + bonus_moy_arr[bonus_moy_arr >= 12.0] = 0.2 + bonus_moy_arr[bonus_moy_arr >= 10.0] = 0.1 - # Bonus moyenne générale, et 0 sur les UE - self.bonus_moy_gen = pd.Series(bonus_moy_arr, index=self.etuds_idx, dtype=float) - if self.bonus_max is not None: - # Seuil: bonus (sur moy. gen.) limité à bonus_max points - self.bonus_moy_gen = self.bonus_moy_gen.clip(upper=self.bonus_max) - - # Laisse bonus_ues à None, en APC le bonus moy. gen. sera réparti sur les UEs. + self.bonus_additif(bonus_moy_arr) class BonusIUTV(BonusSportAdditif): @@ -700,7 +920,7 @@ class BonusIUTV(BonusSportAdditif): name = "bonus_iutv" displayed_name = "IUT de Villetaneuse" - pass # oui, c'ets le bonus par défaut + pass # oui, c'est le bonus par défaut def get_bonus_class_dict(start=BonusSport, d=None): diff --git a/app/comp/moy_mat.py b/app/comp/moy_mat.py new file mode 100644 index 00000000..0a752263 --- /dev/null +++ b/app/comp/moy_mat.py @@ -0,0 +1,50 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Calcul des moyennes de matières +""" + +# C'est un recalcul (optionnel) effectué _après_ le calcul standard. + +import numpy as np +import pandas as pd +from app.comp import moy_ue +from app.models.formsemestre import FormSemestre + +from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_utils import ModuleType + + +def compute_mat_moys_classic( + formsemestre: FormSemestre, + sem_matrix: np.array, + ues: list, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs: np.array, +) -> dict: + """Calcul des moyennes par matières. + Result: dict, { matiere_id : Series, index etudid } + """ + modimpls_std = [ + m + for m in formsemestre.modimpls_sorted + if (m.module.module_type == ModuleType.STANDARD) + and (m.module.ue.type != UE_SPORT) + ] + matiere_ids = {m.module.matiere.id for m in modimpls_std} + matiere_moy = {} # { matiere_id : moy pd.Series, index etudid } + for matiere_id in matiere_ids: + modimpl_mask = np.array( + [m.module.matiere.id == matiere_id for m in formsemestre.modimpls_sorted] + ) + etud_moy_mat = moy_ue.compute_mat_moys_classic( + sem_matrix=sem_matrix, + modimpl_inscr_df=modimpl_inscr_df, + modimpl_coefs=modimpl_coefs, + modimpl_mask=modimpl_mask, + ) + matiere_moy[matiere_id] = etud_moy_mat + return matiere_moy diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index f30f0491..eea357e8 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -335,15 +335,17 @@ class ModuleImplResultsAPC(ModuleImplResults): notes_rat / (eval_rat.note_max / 20.0), np.nan, ) + # "Étend" le rattrapage sur les UE: la note de rattrapage est la même + # pour toutes les UE mais ne remplace que là où elle est supérieure + notes_rat_ues = np.stack([notes_rat] * nb_ues, axis=1) # prend le max - etuds_use_rattrapage = notes_rat > etuds_moy_module + etuds_use_rattrapage = notes_rat_ues > etuds_moy_module etuds_moy_module = np.where( - etuds_use_rattrapage[:, np.newaxis], - np.tile(notes_rat[:, np.newaxis], nb_ues), - etuds_moy_module, + etuds_use_rattrapage, notes_rat_ues, etuds_moy_module ) + # Serie indiquant que l'étudiant utilise une note de rattarage sur l'une des UE: self.etuds_use_rattrapage = pd.Series( - etuds_use_rattrapage, index=self.evals_notes.index + etuds_use_rattrapage.any(axis=1), index=self.evals_notes.index ) self.etuds_moy_module = pd.DataFrame( etuds_moy_module, @@ -359,6 +361,10 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: Les valeurs manquantes (évaluations sans coef vers des UE) sont remplies: 1 si le coef de ce module dans l'UE est non nul, zéro sinon (sauf pour module bonus, defaut à 1) + + Si le module n'est pas une ressource ou une SAE, ne charge pas de poids + et renvoie toujours les poids par défaut. + Résultat: (evals_poids, liste de UEs du semestre sauf le sport) """ modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) @@ -367,13 +373,17 @@ def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: ue_ids = [ue.id for ue in ues] evaluation_ids = [evaluation.id for evaluation in evaluations] evals_poids = pd.DataFrame(columns=ue_ids, index=evaluation_ids, dtype=float) - for ue_poids in EvaluationUEPoids.query.join( - EvaluationUEPoids.evaluation - ).filter_by(moduleimpl_id=moduleimpl_id): - try: - evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids - except KeyError as exc: - pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre... + if ( + modimpl.module.module_type == ModuleType.RESSOURCE + or modimpl.module.module_type == ModuleType.SAE + ): + for ue_poids in EvaluationUEPoids.query.join( + EvaluationUEPoids.evaluation + ).filter_by(moduleimpl_id=moduleimpl_id): + try: + evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids + except KeyError as exc: + pass # poids vers des UE qui n'existent plus ou sont dans un autre semestre... # Initialise poids non enregistrés: default_poids = ( diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index 5caa3d39..db42616c 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -30,8 +30,10 @@ import numpy as np import pandas as pd +from flask import flash -def compute_sem_moys_apc( + +def compute_sem_moys_apc_using_coefs( etud_moy_ue_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame ) -> pd.Series: """Calcule les moyennes générales indicatives de tous les étudiants @@ -48,6 +50,28 @@ def compute_sem_moys_apc( return moy_gen +def compute_sem_moys_apc_using_ects( + etud_moy_ue_df: pd.DataFrame, ects: list, formation_id=None +) -> pd.Series: + """Calcule les moyennes générales indicatives de tous les étudiants + = moyenne des moyennes d'UE, pondérée par leurs ECTS. + + etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid + ects: liste de floats ou None, 1 par UE + + Result: panda Series, index etudid, valeur float (moyenne générale) + """ + try: + moy_gen = (etud_moy_ue_df * ects).sum(axis=1) / sum(ects) + except TypeError: + if None in ects: + flash("""Calcul moyenne générale impossible: ECTS des UE manquants !""") + moy_gen = pd.Series(np.NaN, index=etud_moy_ue_df.index) + else: + raise + return moy_gen + + def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series): """Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur numérique) en tenant compte des ex-aequos. diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 84a7e2bf..efbe7cd3 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -27,7 +27,6 @@ """Fonctions de calcul des moyennes d'UE (classiques ou BUT) """ -from re import X import numpy as np import pandas as pd @@ -218,21 +217,25 @@ def compute_ue_moys_apc( ues: list, modimpl_inscr_df: pd.DataFrame, modimpl_coefs_df: pd.DataFrame, + modimpl_mask: np.array, ) -> pd.DataFrame: """Calcul de la moyenne d'UE en mode APC (BUT). La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR NI non inscrit à (au moins un) module de cette UE NA pas de notes disponibles - ERR erreur dans une formule utilisateur. [XXX pas encore gérées ici] + ERR erreur dans une formule utilisateurs (pas gérées ici). sem_cube: notes moyennes aux modules ndarray (etuds x modimpls x UEs) (floats avec des NaN) etuds : liste des étudiants (dim. 0 du cube) - modimpls : liste des modules à considérer (dim. 1 du cube) + modimpls : liste des module_impl (dim. 1 du cube) ues : liste des UE (dim. 2 du cube) modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport + modimpl_mask: liste de booléens, indiquants le module doit être pris ou pas. + (utilisé pour éliminer les bonus, et pourra servir à cacluler + sur des sous-ensembles de modules) Résultat: DataFrame columns UE (sans bonus), rows etudid """ @@ -249,7 +252,8 @@ def compute_ue_moys_apc( assert modimpl_coefs_df.shape[0] == nb_ues_no_bonus assert modimpl_coefs_df.shape[1] == nb_modules modimpl_inscr = modimpl_inscr_df.values - modimpl_coefs = modimpl_coefs_df.values + # Met à zéro tous les coefs des modules non sélectionnés dans le masque: + modimpl_coefs = np.where(modimpl_mask, modimpl_coefs_df.values, 0.0) # Duplique les inscriptions sur les UEs non bonus: modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues_no_bonus, axis=2) @@ -290,7 +294,8 @@ def compute_ue_moys_classic( modimpl_coefs: np.array, modimpl_mask: np.array, ) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]: - """Calcul de la moyenne d'UE en mode classique. + """Calcul de la moyenne d'UE et de la moy. générale en mode classique (DUT, LMD, ...). + La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR NI non inscrit à (au moins un) module de cette UE NA pas de notes disponibles @@ -359,7 +364,7 @@ def compute_ue_moys_classic( modimpl_coefs_etuds_no_nan_stacked = np.stack( [modimpl_coefs_etuds_no_nan.T] * nb_ues ) - # nb_ue x nb_etuds x nb_mods : coefs prenant en compte NaN et inscriptions + # nb_ue x nb_etuds x nb_mods : coefs prenant en compte NaN et inscriptions: coefs = (modimpl_coefs_etuds_no_nan_stacked * ue_modules).swapaxes(1, 2) if coefs.dtype == np.object: # arrive sur des tableaux vides coefs = coefs.astype(np.float) @@ -404,6 +409,68 @@ def compute_ue_moys_classic( return etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df +def compute_mat_moys_classic( + sem_matrix: np.array, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs: np.array, + modimpl_mask: np.array, +) -> pd.Series: + """Calcul de la moyenne sur un sous-enemble de modules en formation CLASSIQUE + + La moyenne est un nombre (note/20 ou NaN. + + Le masque modimpl_mask est un tableau de booléens (un par modimpl) qui + permet de sélectionner un sous-ensemble de modules (ceux de la matière d'intérêt). + + sem_matrix: notes moyennes aux modules (tous les étuds x tous les modimpls) + ndarray (etuds x modimpls) + (floats avec des NaN) + etuds : listes des étudiants (dim. 0 de la matrice) + modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) + modimpl_coefs: vecteur des coefficients de modules + modimpl_mask: masque des modimpls à prendre en compte + + Résultat: + - moyennes: pd.Series, index etudid + """ + if (not len(modimpl_mask)) or ( + sem_matrix.shape[0] == 0 + ): # aucun module ou aucun étudiant + # etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df + return pd.Series( + [0.0] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index + ) + # Restreint aux modules sélectionnés: + sem_matrix = sem_matrix[:, modimpl_mask] + modimpl_inscr = modimpl_inscr_df.values[:, modimpl_mask] + modimpl_coefs = modimpl_coefs[modimpl_mask] + + nb_etuds, nb_modules = sem_matrix.shape + assert len(modimpl_coefs) == nb_modules + + # Enlève les NaN du numérateur: + sem_matrix_no_nan = np.nan_to_num(sem_matrix, nan=0.0) + # Ne prend pas en compte les notes des étudiants non inscrits au module: + # Annule les notes: + sem_matrix_inscrits = np.where(modimpl_inscr, sem_matrix_no_nan, 0.0) + # Annule les coefs des modules où l'étudiant n'est pas inscrit: + modimpl_coefs_etuds = np.where( + modimpl_inscr, np.stack([modimpl_coefs.T] * nb_etuds), 0.0 + ) + # Annule les coefs des modules NaN (nb_etuds x nb_mods) + modimpl_coefs_etuds_no_nan = np.where( + np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds + ) + if modimpl_coefs_etuds_no_nan.dtype == np.object: # arrive sur des tableaux vides + modimpl_coefs_etuds_no_nan = modimpl_coefs_etuds_no_nan.astype(np.float) + + etud_moy_mat = (modimpl_coefs_etuds_no_nan * sem_matrix_inscrits).sum( + axis=1 + ) / modimpl_coefs_etuds_no_nan.sum(axis=1) + + return pd.Series(etud_moy_mat, index=modimpl_inscr_df.index) + + def compute_malus( formsemestre: FormSemestre, sem_modimpl_moys: np.array, diff --git a/app/comp/res_but.py b/app/comp/res_but.py index b74efb10..7bd79463 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -14,7 +14,7 @@ from app import log from app.comp import moy_ue, moy_sem, inscr_mod from app.comp.res_common import NotesTableCompat from app.comp.bonus_spo import BonusSport -from app.models import ScoDocSiteConfig +from app.models import ScoDocSiteConfig, formsemestre from app.models.ues import UniteEns from app.scodoc.sco_codes_parcours import UE_SPORT @@ -56,14 +56,11 @@ class ResultatsSemestreBUT(NotesTableCompat): # modimpl_coefs_df.columns.get_loc(modimpl.id) # idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id) - # Elimine les coefs des modimpl bonus sports: - modimpls_sport = [ - modimpl + # Masque de tous les modules _sauf_ les bonus (sport) + modimpls_mask = [ + modimpl.module.ue.type != UE_SPORT for modimpl in self.formsemestre.modimpls_sorted - if modimpl.module.ue.type == UE_SPORT ] - for modimpl in modimpls_sport: - self.modimpl_coefs_df[modimpl.id] = 0 self.etud_moy_ue = moy_ue.compute_ue_moys_apc( self.sem_cube, @@ -72,10 +69,11 @@ class ResultatsSemestreBUT(NotesTableCompat): self.ues, self.modimpl_inscr_df, self.modimpl_coefs_df, + modimpls_mask, ) # Les coefficients d'UE ne sont pas utilisés en APC self.etud_coef_ue_df = pd.DataFrame( - 1.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns + 0.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns ) # --- Modules de MALUS sur les UEs @@ -85,7 +83,7 @@ class ResultatsSemestreBUT(NotesTableCompat): self.etud_moy_ue -= self.malus # --- Bonus Sport & Culture - if len(modimpls_sport) > 0: + if not all(modimpls_mask): # au moins un module bonus bonus_class = ScoDocSiteConfig.get_bonus_sport_class() if bonus_class is not None: bonus: BonusSport = bonus_class( @@ -100,13 +98,20 @@ class ResultatsSemestreBUT(NotesTableCompat): self.bonus_ues = bonus.get_bonus_ues() if self.bonus_ues is not None: self.etud_moy_ue += self.bonus_ues # somme les dataframes - self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) + + # Clippe toutes les moyennes d'UE dans [0,20] + self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) # Moyenne générale indicative: # (note: le bonus sport a déjà été appliqué aux moyennes d'UE, et impacte # donc la moyenne indicative) - self.etud_moy_gen = moy_sem.compute_sem_moys_apc( - self.etud_moy_ue, self.modimpl_coefs_df + # self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_coefs( + # self.etud_moy_ue, self.modimpl_coefs_df + # ) + self.etud_moy_gen = moy_sem.compute_sem_moys_apc_using_ects( + self.etud_moy_ue, + [ue.ects for ue in self.ues if ue.type != UE_SPORT], + formation_id=self.formsemestre.formation_id, ) # --- UE capitalisées self.apply_capitalisation() diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 2caf515c..b36eaaf6 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -15,7 +15,7 @@ from flask import g, url_for from app import db from app import log -from app.comp import moy_mod, moy_ue, inscr_mod +from app.comp import moy_mat, moy_mod, moy_ue, inscr_mod from app.comp.res_common import NotesTableCompat from app.comp.bonus_spo import BonusSport from app.models import ScoDocSiteConfig @@ -24,6 +24,7 @@ from app.models.formsemestre import FormSemestre from app.models.ues import UniteEns from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_preferences from app.scodoc.sco_utils import ModuleType @@ -60,7 +61,7 @@ class ResultatsSemestreClassic(NotesTableCompat): ) self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) self.modimpl_coefs = np.array( - [m.module.coefficient for m in self.formsemestre.modimpls_sorted] + [m.module.coefficient or 0.0 for m in self.formsemestre.modimpls_sorted] ) self.modimpl_idx = { m.id: i for i, m in enumerate(self.formsemestre.modimpls_sorted) @@ -113,17 +114,34 @@ class ResultatsSemestreClassic(NotesTableCompat): self.etud_moy_ue += self.bonus_ues # somme les dataframes self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) bonus_mg = bonus.get_bonus_moy_gen() - if bonus_mg is not None: + if bonus_mg is None and self.bonus_ues is not None: + # pas de bonus explicite sur la moyenne générale + # on l'ajuste pour refléter les modifs d'UE, à l'aide des coefs d'UE. + bonus_mg = (self.etud_coef_ue_df * self.bonus_ues).sum( + axis=1 + ) / self.etud_coef_ue_df.sum(axis=1) self.etud_moy_gen += bonus_mg - self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True) - # compat nt, utilisé pour l'afficher sur les bulletins: - self.bonus = bonus_mg + elif bonus_mg is not None: + # Applique le bonus moyenne générale renvoyé + self.etud_moy_gen += bonus_mg + + # compat nt, utilisé pour l'afficher sur les bulletins: + self.bonus = bonus_mg + # --- UE capitalisées self.apply_capitalisation() + # Clippe toutes les moyennes dans [0,20] + self.etud_moy_ue.clip(lower=0.0, upper=20.0, inplace=True) + self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True) + # --- Classements: self.compute_rangs() + # --- En option, moyennes par matières + if sco_preferences.get_preference("bul_show_matieres", self.formsemestre.id): + self.compute_moyennes_matieres() + def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: """La moyenne de l'étudiant dans le moduleimpl Result: valeur float (peut être NaN) ou chaîne "NI" (non inscrit ou DEM) @@ -149,6 +167,16 @@ class ResultatsSemestreClassic(NotesTableCompat): ), } + def compute_moyennes_matieres(self): + """Calcul les moyennes par matière. Doit être appelée au besoin, en fin de compute.""" + self.moyennes_matieres = moy_mat.compute_mat_moys_classic( + self.formsemestre, + self.sem_matrix, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs, + ) + def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: """Détermine le coefficient de l'UE pour cet étudiant. N'est utilisé que pour l'injection des UE capitalisées dans la diff --git a/app/comp/res_common.py b/app/comp/res_common.py index b019c977..8fa106f5 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -9,18 +9,22 @@ from functools import cached_property import numpy as np import pandas as pd +from flask import g, flash, url_for + from app import log from app.comp.aux_stats import StatsMoyenne from app.comp import moy_sem from app.comp.res_cache import ResultatsCache from app.comp import res_sem from app.comp.moy_mod import ModuleImplResults -from app.models import FormSemestre, Identite, ModuleImpl -from app.models import FormSemestreUECoef +from app.models import FormSemestre, FormSemestreUECoef +from app.models import Identite +from app.models import ModuleImpl, ModuleImplInscription from app.models.ues import UniteEns from app.scodoc import sco_utils as scu from app.scodoc.sco_cache import ResultatsSemestreCache from app.scodoc.sco_codes_parcours import UE_SPORT, DEF +from app.scodoc.sco_exceptions import ScoValueError # Il faut bien distinguer # - ce qui est caché de façon persistente (via redis): @@ -39,6 +43,7 @@ class ResultatsSemestre(ResultatsCache): "modimpl_inscr_df", "modimpls_results", "etud_coef_ue_df", + "moyennes_matieres", ) def __init__(self, formsemestre: FormSemestre): @@ -57,6 +62,8 @@ class ResultatsSemestre(ResultatsCache): self.etud_coef_ue_df = None """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)""" self.validations = None + self.moyennes_matieres = {} + """Moyennes de matières, si calculées. { matiere_id : Series, index etudid }""" def compute(self): "Charge les notes et inscriptions et calcule toutes les moyennes" @@ -165,7 +172,6 @@ class ResultatsSemestre(ResultatsCache): """ # Supposant qu'il y a peu d'UE capitalisées, # on va soustraire la moyenne d'UE et ajouter celle de l'UE capitalisée. - # return # XXX XXX XXX if not self.validations: self.validations = res_sem.load_formsemestre_validations(self.formsemestre) ue_capitalisees = self.validations.ue_capitalisees @@ -184,10 +190,12 @@ class ResultatsSemestre(ResultatsCache): sum_coefs_ue = 0.0 for ue in self.formsemestre.query_ues(): ue_cap = self.get_etud_ue_status(etudid, ue.id) - if ue_cap and ue_cap["is_capitalized"]: + if ue_cap is None: + continue + if ue_cap["is_capitalized"]: recompute_mg = True coef = ue_cap["coef_ue"] - if not np.isnan(ue_cap["moy"]): + if not np.isnan(ue_cap["moy"]) and coef: sum_notes_ue += ue_cap["moy"] * coef sum_coefs_ue += coef @@ -195,13 +203,25 @@ class ResultatsSemestre(ResultatsCache): # On doit prendre en compte une ou plusieurs UE capitalisées # et donc recalculer la moyenne générale self.etud_moy_gen[etudid] = sum_notes_ue / sum_coefs_ue + # Ajoute le bonus sport + if self.bonus is not None and self.bonus[etudid]: + self.etud_moy_gen[etudid] += self.bonus[etudid] + self.etud_moy_gen[etudid] = max( + 0.0, min(self.etud_moy_gen[etudid], 20.0) + ) - def _get_etud_ue_cap(self, etudid, ue): - """""" + def _get_etud_ue_cap(self, etudid: int, ue: UniteEns) -> dict: + """Donne les informations sur la capitalisation de l'UE ue pour cet étudiant. + Résultat: + Si pas capitalisée: None + Si capitalisée: un dict, avec les colonnes de validation. + """ capitalisations = self.validations.ue_capitalisees.loc[etudid] if isinstance(capitalisations, pd.DataFrame): ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code] - if isinstance(ue_cap, pd.DataFrame) and not ue_cap.empty: + if ue_cap.empty: + return None + if isinstance(ue_cap, pd.DataFrame): # si plusieurs fois capitalisée, prend le max cap_idx = ue_cap["moy_ue"].values.argmax() ue_cap = ue_cap.iloc[cap_idx] @@ -209,8 +229,9 @@ class ResultatsSemestre(ResultatsCache): if capitalisations["ue_code"] == ue.ue_code: ue_cap = capitalisations else: - ue_cap = None - return ue_cap + return None + # converti la Series en dict, afin que les np.int64 reviennent en int + return ue_cap.to_dict() def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: """L'état de l'UE pour cet étudiant. @@ -238,22 +259,45 @@ class ResultatsSemestre(ResultatsCache): cur_moy_ue = self.etud_moy_ue[ue_id][etudid] moy_ue = cur_moy_ue is_capitalized = False # si l'UE prise en compte est une UE capitalisée - was_capitalized = ( - False # s'il y a precedemment une UE capitalisée (pas forcement meilleure) - ) + # s'il y a precedemment une UE capitalisée (pas forcement meilleure): + was_capitalized = False if etudid in self.validations.ue_capitalisees.index: ue_cap = self._get_etud_ue_cap(etudid, ue) - if ( - ue_cap is not None - and not ue_cap.empty - and not np.isnan(ue_cap["moy_ue"]) - ): + if ue_cap and not np.isnan(ue_cap["moy_ue"]): was_capitalized = True if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue): moy_ue = ue_cap["moy_ue"] is_capitalized = True - coef_ue = self.etud_coef_ue_df[ue_id][etudid] + # Coef l'UE dans le semestre courant: + if self.is_apc: + # utilise les ECTS comme coef. + coef_ue = ue.ects + else: + # formations classiques + coef_ue = self.etud_coef_ue_df[ue_id][etudid] + if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante + if self.is_apc: + # Coefs de l'UE capitalisée en formation APC: donné par ses ECTS + ue_capitalized = UniteEns.query.get(ue_cap["ue_id"]) + coef_ue = ue_capitalized.ects + if coef_ue is None: + orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"]) + raise ScoValueError( + f"""L'UE capitalisée {ue_capitalized.acronyme} + du semestre {orig_sem.titre_annee()} + n'a pas d'indication d'ECTS. + Corrigez ou faite corriger le programme + via cette page. + """ + ) + else: + # Coefs de l'UE capitalisée en formation classique: + # va chercher le coef dans le semestre d'origine + coef_ue = ModuleImplInscription.sum_coefs_modimpl_ue( + ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"] + ) return { "is_capitalized": is_capitalized, @@ -375,21 +419,31 @@ class NotesTableCompat(ResultatsSemestre): """Stats (moy/min/max) sur la moyenne générale""" return StatsMoyenne(self.etud_moy_gen) - def get_ues_stat_dict(self, filter_sport=False): # was get_ues() + def get_ues_stat_dict( + self, filter_sport=False, check_apc_ects=True + ) -> list[dict]: # was get_ues() """Liste des UEs, ordonnée par numero. Si filter_sport, retire les UE de type SPORT. Résultat: liste de dicts { champs UE U stats moyenne UE } """ - ues = [] - for ue in self.formsemestre.query_ues(with_sport=not filter_sport): + ues = self.formsemestre.query_ues(with_sport=not filter_sport) + ues_dict = [] + for ue in ues: d = ue.to_dict() if ue.type != UE_SPORT: moys = self.etud_moy_ue[ue.id] else: moys = None d.update(StatsMoyenne(moys).to_dict()) - ues.append(d) - return ues + ues_dict.append(d) + if check_apc_ects and self.is_apc and not hasattr(g, "checked_apc_ects"): + g.checked_apc_ects = True + if None in [ue.ects for ue in ues if ue.type != UE_SPORT]: + flash( + """Calcul moyenne générale impossible: ECTS des UE manquants !""", + category="danger", + ) + return ues_dict def get_modimpls_dict(self, ue_id=None) -> list[dict]: """Liste des modules pour une UE (ou toutes si ue_id==None), @@ -508,10 +562,15 @@ class NotesTableCompat(ResultatsSemestre): return "" return ins.etat - def get_etud_mat_moy(self, matiere_id, etudid): + def get_etud_mat_moy(self, matiere_id: int, etudid: int) -> str: """moyenne d'un étudiant dans une matière (ou NA si pas de notes)""" - # non supporté en 9.2 - return "na" + if not self.moyennes_matieres: + return "nd" + return ( + self.moyennes_matieres[matiere_id].get(etudid, "-") + if matiere_id in self.moyennes_matieres + else "-" + ) def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: """La moyenne de l'étudiant dans le moduleimpl diff --git a/app/comp/res_sem.py b/app/comp/res_sem.py index 607ad168..e27a157c 100644 --- a/app/comp/res_sem.py +++ b/app/comp/res_sem.py @@ -8,11 +8,13 @@ """ from flask import g +from app import db from app.comp.jury import ValidationsSemestre from app.comp.res_common import ResultatsSemestre from app.comp.res_classic import ResultatsSemestreClassic from app.comp.res_but import ResultatsSemestreBUT from app.models.formsemestre import FormSemestre +from app.scodoc import sco_cache def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre: @@ -23,6 +25,13 @@ def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre: Search in local cache (g.formsemestre_result_cache) If not in cache, build it and cache it. """ + is_apc = formsemestre.formation.is_apc() + if is_apc and formsemestre.semestre_id == -1: + formsemestre.semestre_id = 1 + db.session.add(formsemestre) + db.session.commit() + sco_cache.invalidate_formsemestre(formsemestre.id) + # --- Try local cache (within the same request context) if not hasattr(g, "formsemestre_results_cache"): g.formsemestre_results_cache = {} @@ -30,11 +39,7 @@ def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre: if formsemestre.id in g.formsemestre_results_cache: return g.formsemestre_results_cache[formsemestre.id] - klass = ( - ResultatsSemestreBUT - if formsemestre.formation.is_apc() - else ResultatsSemestreClassic - ) + klass = ResultatsSemestreBUT if is_apc else ResultatsSemestreClassic g.formsemestre_results_cache[formsemestre.id] = klass(formsemestre) return g.formsemestre_results_cache[formsemestre.id] diff --git a/app/decorators.py b/app/decorators.py index 8ebf5dea..220ece56 100644 --- a/app/decorators.py +++ b/app/decorators.py @@ -193,7 +193,7 @@ def scodoc7func(func): # necessary for db ids and boolean values try: v = int(v) - except ValueError: + except (ValueError, TypeError): pass pos_arg_values.append(v) # current_app.logger.info("pos_arg_values=%s" % pos_arg_values) diff --git a/app/forms/main/config_logos.py b/app/forms/main/config_logos.py index 2be78713..c8998327 100644 --- a/app/forms/main/config_logos.py +++ b/app/forms/main/config_logos.py @@ -30,17 +30,15 @@ Formulaires configuration logos Contrib @jmp, dec 21 """ -import re from flask import flash, url_for, redirect, render_template from flask_wtf import FlaskForm from flask_wtf.file import FileField, FileAllowed -from wtforms import SelectField, SubmitField, FormField, validators, FieldList +from wtforms import SubmitField, FormField, validators, FieldList +from wtforms import ValidationError from wtforms.fields.simple import StringField, HiddenField -from app import AccessDenied from app.models import Departement -from app.models import ScoDocSiteConfig from app.scodoc import sco_logos, html_sco_header from app.scodoc import sco_utils as scu from app.scodoc.sco_config_actions import ( @@ -49,10 +47,11 @@ from app.scodoc.sco_config_actions import ( LogoInsert, ) -from flask_login import current_user +from app.scodoc import sco_utils as scu from app.scodoc.sco_logos import find_logo + JAVASCRIPTS = html_sco_header.BOOTSTRAP_MULTISELECT_JS + [] CSSSTYLES = html_sco_header.BOOTSTRAP_MULTISELECT_CSS @@ -111,6 +110,15 @@ def dept_key_to_id(dept_key): return dept_key +def logo_name_validator(message=None): + def validate_logo_name(form, field): + name = field.data if field.data else "" + if not scu.is_valid_filename(name): + raise ValidationError(message) + + return validate_logo_name + + class AddLogoForm(FlaskForm): """Formulaire permettant l'ajout d'un logo (dans un département)""" @@ -118,11 +126,7 @@ class AddLogoForm(FlaskForm): name = StringField( label="Nom", validators=[ - validators.regexp( - r"^[a-zA-Z0-9-_]*$", - re.IGNORECASE, - "Ne doit comporter que lettres, chiffres, _ ou -", - ), + logo_name_validator("Nom de logo invalide (alphanumérique, _)"), validators.Length( max=20, message="Un nom ne doit pas dépasser 20 caractères" ), @@ -373,11 +377,11 @@ def config_logos(): if action: action.execute() flash(action.message) - return redirect( - url_for( - "scodoc.configure_logos", - ) - ) + return redirect(url_for("scodoc.configure_logos")) + else: + if not form.validate(): + scu.flash_errors(form) + return render_template( "config_logos.html", scodoc_dept=None, diff --git a/app/models/departements.py b/app/models/departements.py index ebe5cc14..44a963ca 100644 --- a/app/models/departements.py +++ b/app/models/departements.py @@ -55,6 +55,9 @@ def create_dept(acronym: str, visible=True) -> Departement: "Create new departement" from app.models import ScoPreference + existing = Departement.query.filter_by(acronym=acronym).count() + if existing: + raise ValueError(f"acronyme {acronym} déjà existant") departement = Departement(acronym=acronym, visible=visible) p1 = ScoPreference(name="DeptName", value=acronym, departement=departement) db.session.add(p1) diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 24514248..41ede854 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -104,6 +104,11 @@ class FormSemestre(db.Model): lazy=True, backref=db.backref("formsemestres", lazy=True), ) + partitions = db.relationship( + "Partition", + backref=db.backref("formsemestre", lazy=True), + lazy="dynamic", + ) # Ancien id ScoDoc7 pour les migrations de bases anciennes # ne pas utiliser après migrate_scodoc7_dept_archives scodoc7_id = db.Column(db.Text(), nullable=True) @@ -201,7 +206,11 @@ class FormSemestre(db.Model): modimpls = self.modimpls.all() if self.formation.is_apc(): modimpls.sort( - key=lambda m: (m.module.module_type, m.module.numero, m.module.code) + key=lambda m: ( + m.module.module_type or 0, + m.module.numero or 0, + m.module.code or 0, + ) ) else: modimpls.sort( diff --git a/app/models/groups.py b/app/models/groups.py index 902298cc..976d465b 100644 --- a/app/models/groups.py +++ b/app/models/groups.py @@ -31,6 +31,11 @@ class Partition(db.Model): show_in_lists = db.Column( db.Boolean(), nullable=False, default=True, server_default="true" ) + groups = db.relationship( + "GroupDescr", + backref=db.backref("partition", lazy=True), + lazy="dynamic", + ) def __init__(self, **kwargs): super(Partition, self).__init__(**kwargs) @@ -42,6 +47,9 @@ class Partition(db.Model): else: self.numero = 1 + def __repr__(self): + return f"""<{self.__class__.__name__} {self.id} "{self.partition_name or '(default)'}">""" + class GroupDescr(db.Model): """Description d'un groupe d'une partition""" @@ -55,6 +63,11 @@ class GroupDescr(db.Model): # "A", "C2", ... (NULL for 'all'): group_name = db.Column(db.String(GROUPNAME_STR_LEN)) + def __repr__(self): + return ( + f"""<{self.__class__.__name__} {self.id} "{self.group_name or '(tous)'}">""" + ) + group_membership = db.Table( "group_membership", diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 700dec26..0aa74ef4 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -2,6 +2,7 @@ """ScoDoc models: moduleimpls """ import pandas as pd +import flask_sqlalchemy from app import db from app.comp import df_cache @@ -129,14 +130,36 @@ class ModuleImplInscription(db.Model): ) @classmethod - def nb_inscriptions_dans_ue( + def etud_modimpls_in_ue( cls, formsemestre_id: int, etudid: int, ue_id: int - ) -> int: - """Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit""" + ) -> flask_sqlalchemy.BaseQuery: + """moduleimpls de l'UE auxquels l'étudiant est inscrit""" return ModuleImplInscription.query.filter( ModuleImplInscription.etudid == etudid, ModuleImplInscription.moduleimpl_id == ModuleImpl.id, ModuleImpl.formsemestre_id == formsemestre_id, ModuleImpl.module_id == Module.id, Module.ue_id == ue_id, - ).count() + ) + + @classmethod + def nb_inscriptions_dans_ue( + cls, formsemestre_id: int, etudid: int, ue_id: int + ) -> int: + """Nombre de moduleimpls de l'UE auxquels l'étudiant est inscrit""" + return cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id).count() + + @classmethod + def sum_coefs_modimpl_ue( + cls, formsemestre_id: int, etudid: int, ue_id: int + ) -> float: + """Somme des coefficients des modules auxquels l'étudiant est inscrit + dans l'UE du semestre indiqué. + N'utilise que les coefficients, donc inadapté aux formations APC. + """ + return sum( + [ + inscr.modimpl.module.coefficient + for inscr in cls.etud_modimpls_in_ue(formsemestre_id, etudid, ue_id) + ] + ) diff --git a/app/models/ues.py b/app/models/ues.py index 09469fb0..2bed88a3 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -54,13 +54,15 @@ class UniteEns(db.Model): 'EXTERNE' if self.is_external else ''})>""" def to_dict(self): - """as a dict, with the same conversions as in ScoDoc7""" + """as a dict, with the same conversions as in ScoDoc7 + (except ECTS: keep None) + """ e = dict(self.__dict__) e.pop("_sa_instance_state", None) # ScoDoc7 output_formators e["ue_id"] = self.id e["numero"] = e["numero"] if e["numero"] else 0 - e["ects"] = e["ects"] if e["ects"] else 0.0 + e["ects"] = e["ects"] e["coefficient"] = e["coefficient"] if e["coefficient"] else 0.0 e["code_apogee"] = e["code_apogee"] or "" # pas de None return e diff --git a/app/pe/pe_view.py b/app/pe/pe_view.py index 5a98d375..5af1a575 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -36,6 +36,7 @@ """ from flask import send_file, request +from app.scodoc.sco_exceptions import ScoValueError import app.scodoc.sco_utils as scu from app.scodoc import sco_formsemestre @@ -97,8 +98,12 @@ def pe_view_sem_recap( template_latex = "" # template fourni via le formulaire Web if avis_tmpl_file: - template_latex = avis_tmpl_file.read().decode('utf-8') - template_latex = template_latex + try: + template_latex = avis_tmpl_file.read().decode("utf-8") + except UnicodeDecodeError as e: + raise ScoValueError( + "Données (template) invalides (caractères non UTF8 ?)" + ) from e else: # template indiqué dans préférences ScoDoc ? template_latex = pe_avislatex.get_code_latex_from_scodoc_preference( @@ -114,7 +119,7 @@ def pe_view_sem_recap( footer_latex = "" # template fourni via le formulaire Web if footer_tmpl_file: - footer_latex = footer_tmpl_file.read().decode('utf-8') + footer_latex = footer_tmpl_file.read().decode("utf-8") footer_latex = footer_latex else: footer_latex = pe_avislatex.get_code_latex_from_scodoc_preference( diff --git a/app/scodoc/html_sco_header.py b/app/scodoc/html_sco_header.py index 23a8b834..653cdb80 100644 --- a/app/scodoc/html_sco_header.py +++ b/app/scodoc/html_sco_header.py @@ -30,7 +30,7 @@ import html -from flask import g +from flask import render_template from flask import request from flask_login import current_user @@ -280,6 +280,9 @@ def sco_header( if not no_side_bar: H.append(html_sidebar.sidebar()) H.append("""
""") + # En attendant le replacement complet de cette fonction, + # inclusion ici des messages flask + H.append(render_template("flashed_messages.html")) # # Barre menu semestre: H.append(formsemestre_page_title()) diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index e7710a91..b1482ec5 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -98,7 +98,7 @@ from chardet import detect as chardet_detect from app import log from app.comp import res_sem from app.comp.res_common import NotesTableCompat -from app.models import FormSemestre +from app.models import FormSemestre, Identite from app.models.config import ScoDocSiteConfig import app.scodoc.sco_utils as scu from app.scodoc.sco_exceptions import ScoValueError, ScoFormatError @@ -111,7 +111,6 @@ from app.scodoc.sco_codes_parcours import ( NAR, RAT, ) -from app.scodoc import sco_cache from app.scodoc import sco_formsemestre from app.scodoc import sco_parcours_dut from app.scodoc import sco_etud @@ -454,6 +453,12 @@ class ApoEtud(dict): def comp_elt_semestre(self, nt, decision, etudid): """Calcul résultat apo semestre""" + if decision is None: + etud = Identite.query.get(etudid) + nomprenom = etud.nomprenom if etud else "(inconnu)" + raise ScoValueError( + f"decision absente pour l'étudiant {nomprenom} ({etudid})" + ) # resultat du semestre decision_apo = ScoDocSiteConfig.get_code_apo(decision["code"]) note = nt.get_etud_moy_gen(etudid) diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index 93a926b6..47947bd3 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -291,15 +291,17 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): I["matieres_modules"] = {} I["matieres_modules_capitalized"] = {} for ue in ues: + u = ue.copy() + ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) if ( ModuleImplInscription.nb_inscriptions_dans_ue( formsemestre_id, etudid, ue["ue_id"] ) == 0 - ): + ) and not ue_status["is_capitalized"]: + # saute les UE où l'on est pas inscrit et n'avons pas de capitalisation continue - u = ue.copy() - ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) + u["ue_status"] = ue_status # { 'moy', 'coef_ue', ...} if ue["type"] != sco_codes_parcours.UE_SPORT: u["cur_moy_ue_txt"] = scu.fmt_note(ue_status["cur_moy_ue"]) @@ -315,13 +317,13 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): u["cur_moy_ue_txt"] = "bonus appliqué sur les UEs" else: u["cur_moy_ue_txt"] = "bonus de %.3g points" % x + 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: u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"]) else: - # C'est un bug: - log("u=" + pprint.pformat(u)) - raise Exception("invalid None coef for ue") + u["coef_ue_txt"] = "-" if ( dpv @@ -1011,11 +1013,16 @@ def mail_bulletin(formsemestre_id, I, pdfdata, filename, recipient_addr): intro_mail = sco_preferences.get_preference("bul_intro_mail", formsemestre_id) if intro_mail: - hea = intro_mail % { - "nomprenom": etud["nomprenom"], - "dept": dept, - "webmaster": webmaster, - } + try: + hea = intro_mail % { + "nomprenom": etud["nomprenom"], + "dept": dept, + "webmaster": webmaster, + } + except KeyError as e: + raise ScoValueError( + "format 'Message d'accompagnement' (bul_intro_mail) invalide, revoir les réglages dans les préférences" + ) else: hea = "" diff --git a/app/scodoc/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py index 60c6f2a0..25111f7e 100644 --- a/app/scodoc/sco_bulletins_standard.py +++ b/app/scodoc/sco_bulletins_standard.py @@ -284,7 +284,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): ) with_col_moypromo = prefs["bul_show_moypromo"] with_col_rang = prefs["bul_show_rangs"] - with_col_coef = prefs["bul_show_coef"] + with_col_coef = prefs["bul_show_coef"] or prefs["bul_show_ue_coef"] with_col_ects = prefs["bul_show_ects"] colkeys = ["titre", "module"] # noms des colonnes à afficher @@ -409,7 +409,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): # Chaque UE: for ue in I["ues"]: ue_type = None - coef_ue = ue["coef_ue_txt"] + coef_ue = ue["coef_ue_txt"] if prefs["bul_show_ue_coef"] else "" ue_descr = ue["ue_descr_txt"] rowstyle = "" plusminus = minuslink # @@ -592,7 +592,7 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): "_titre_colspan": 2, "rang": mod["mod_rang_txt"], # vide si pas option rang "note": mod["mod_moy_txt"], - "coef": mod["mod_coef_txt"], + "coef": mod["mod_coef_txt"] if prefs["bul_show_coef"] else "", "abs": mod.get( "mod_abs_txt", "" ), # absent si pas option show abs module @@ -656,7 +656,9 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): eval_style = "" t = { "module": ' ' + e["name"], - "coef": "" + e["coef_txt"] + "", + "coef": ("" + e["coef_txt"] + "") + if prefs["bul_show_coef"] + else "", "_hidden": hidden, "_module_target": e["target_html"], # '_module_help' : , diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index 5fd0cec6..6330849c 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -33,17 +33,12 @@ """ -# API ScoDoc8 pour les caches: -# sco_cache.NotesTableCache.get( formsemestre_id) -# => sco_cache.NotesTableCache.get(formsemestre_id) +# API pour les caches: +# sco_cache.MyCache.get( formsemestre_id) +# => sco_cache.MyCache.get(formsemestre_id) # -# sco_core.inval_cache(formsemestre_id=None, pdfonly=False, formsemestre_id_list=None) -# => deprecated, NotesTableCache.invalidate_formsemestre(formsemestre_id=None, pdfonly=False) -# -# -# Nouvelles fonctions: -# sco_cache.NotesTableCache.delete(formsemestre_id) -# sco_cache.NotesTableCache.delete_many(formsemestre_id_list) +# sco_cache.MyCache.delete(formsemestre_id) +# sco_cache.MyCache.delete_many(formsemestre_id_list) # # Bulletins PDF: # sco_cache.SemBulletinsPDFCache.get(formsemestre_id, version) @@ -203,49 +198,6 @@ class SemInscriptionsCache(ScoDocCache): duration = 12 * 60 * 60 # ttl 12h -class NotesTableCache(ScoDocCache): - """Cache pour les NotesTable - Clé: formsemestre_id - Valeur: NotesTable instance - """ - - prefix = "NT" - - @classmethod - def get(cls, formsemestre_id, compute=True): - """Returns NotesTable for this formsemestre - Search in local cache (g.nt_cache) or global app cache (eg REDIS) - If not in cache: - If compute is True, build it and cache it - Else return None - """ - # try local cache (same request) - if not hasattr(g, "nt_cache"): - g.nt_cache = {} - else: - if formsemestre_id in g.nt_cache: - return g.nt_cache[formsemestre_id] - # try REDIS - key = cls._get_key(formsemestre_id) - nt = CACHE.get(key) - if nt: - g.nt_cache[formsemestre_id] = nt # cache locally (same request) - return nt - if not compute: - return None - # Recompute requested table: - from app.scodoc import notes_table - - t0 = time.time() - nt = notes_table.NotesTable(formsemestre_id) - t1 = time.time() - _ = cls.set(formsemestre_id, nt) # cache in REDIS - t2 = time.time() - log(f"cached formsemestre_id={formsemestre_id} ({(t1-t0):g}s +{(t2-t1):g}s)") - g.nt_cache[formsemestre_id] = nt - return nt - - def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=False) formsemestre_id=None, pdfonly=False ): @@ -278,22 +230,24 @@ def invalidate_formsemestre( # was inval_cache(formsemestre_id=None, pdfonly=Fa if not pdfonly: # Delete cached notes and evaluations - NotesTableCache.delete_many(formsemestre_ids) if formsemestre_id: for fid in formsemestre_ids: EvaluationCache.invalidate_sem(fid) - if hasattr(g, "nt_cache") and fid in g.nt_cache: - del g.nt_cache[fid] + if ( + hasattr(g, "formsemestre_results_cache") + and fid in g.formsemestre_results_cache + ): + del g.formsemestre_results_cache[fid] + else: # optimization when we invalidate all evaluations: EvaluationCache.invalidate_all_sems() - if hasattr(g, "nt_cache"): - del g.nt_cache + if hasattr(g, "formsemestre_results_cache"): + del g.formsemestre_results_cache SemInscriptionsCache.delete_many(formsemestre_ids) - + ResultatsSemestreCache.delete_many(formsemestre_ids) + ValidationsSemestreCache.delete_many(formsemestre_ids) SemBulletinsPDFCache.invalidate_sems(formsemestre_ids) - ResultatsSemestreCache.delete_many(formsemestre_ids) - ValidationsSemestreCache.delete_many(formsemestre_ids) class DefferedSemCacheManager: diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py index 59372b8d..bcd6522b 100644 --- a/app/scodoc/sco_codes_parcours.py +++ b/app/scodoc/sco_codes_parcours.py @@ -282,7 +282,7 @@ class TypeParcours(object): return [ ue_status for ue_status in ues_status - if ue_status["coef_ue"] > 0 + if ue_status["coef_ue"] and isinstance(ue_status["moy"], float) and ue_status["moy"] < self.get_barre_ue(ue_status["ue"]["type"]) ] @@ -587,7 +587,7 @@ class ParcoursILEPS(TypeParcours): # SESSION_ABBRV = 'A' # A1, A2, ... COMPENSATION_UE = False UNUSED_CODES = set((ADC, ATT, ATB, ATJ)) - ALLOWED_UE_TYPES = [UE_STANDARD, UE_OPTIONNELLE] + ALLOWED_UE_TYPES = [UE_STANDARD, UE_OPTIONNELLE, UE_SPORT] # Barre moy gen. pour validation semestre: BARRE_MOY = 10.0 # Barre pour UE ILEPS: 8/20 pour UE standards ("fondamentales") diff --git a/app/scodoc/sco_dump_db.py b/app/scodoc/sco_dump_db.py index b11f63ca..4b55b41f 100644 --- a/app/scodoc/sco_dump_db.py +++ b/app/scodoc/sco_dump_db.py @@ -51,6 +51,7 @@ import fcntl import subprocess import requests +from flask import flash from flask_login import current_user import app.scodoc.notesdb as ndb @@ -124,6 +125,7 @@ def sco_dump_and_send_db(): fcntl.flock(x, fcntl.LOCK_UN) log("sco_dump_and_send_db: done.") + flash("Données envoyées au serveur d'assistance") return "\n".join(H) + html_sco_header.sco_footer() @@ -186,18 +188,28 @@ def _send_db(ano_db_name): log("uploading anonymized dump...") files = {"file": (ano_db_name + ".dump", dump)} - r = requests.post( - scu.SCO_DUMP_UP_URL, - files=files, - data={ - "dept_name": sco_preferences.get_preference("DeptName"), - "serial": _get_scodoc_serial(), - "sco_user": str(current_user), - "sent_by": sco_users.user_info(str(current_user))["nomcomplet"], - "sco_version": sco_version.SCOVERSION, - "sco_fullversion": scu.get_scodoc_version(), - }, - ) + try: + r = requests.post( + scu.SCO_DUMP_UP_URL, + files=files, + data={ + "dept_name": sco_preferences.get_preference("DeptName"), + "serial": _get_scodoc_serial(), + "sco_user": str(current_user), + "sent_by": sco_users.user_info(str(current_user))["nomcomplet"], + "sco_version": sco_version.SCOVERSION, + "sco_fullversion": scu.get_scodoc_version(), + }, + ) + except requests.exceptions.ConnectionError as exc: + raise ScoValueError( + """ + Impossible de joindre le serveur d'assistance (scodoc.org). + Veuillez contacter le service informatique de votre établissement pour + corriger la configuration de ScoDoc. Dans la plupart des cas, il + s'agit d'un proxy mal configuré. + """ + ) from exc return r diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index c6b0151d..cec8b7c2 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -52,6 +52,7 @@ def html_edit_formation_apc( """ parcours = formation.get_parcours() assert parcours.APC_SAE + ressources = formation.modules.filter_by(module_type=ModuleType.RESSOURCE).order_by( Module.semestre_id, Module.numero, Module.code ) @@ -68,6 +69,19 @@ def html_edit_formation_apc( ).order_by( Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code ) + + ues_by_sem = {} + ects_by_sem = {} + for semestre_idx in semestre_ids: + ues_by_sem[semestre_idx] = formation.ues.filter_by( + semestre_idx=semestre_idx + ).order_by(UniteEns.semestre_idx, UniteEns.numero, UniteEns.acronyme) + ects = [ue.ects for ue in ues_by_sem[semestre_idx]] + if None in ects: + ects_by_sem[semestre_idx] = 'manquant' + else: + ects_by_sem[semestre_idx] = sum(ects) + arrow_up, arrow_down, arrow_none = sco_groups.get_arrow_icons_tags() icons = { @@ -93,7 +107,8 @@ def html_edit_formation_apc( editable=editable, tag_editable=tag_editable, icons=icons, - UniteEns=UniteEns, + ues_by_sem=ues_by_sem, + ects_by_sem=ects_by_sem, ), ] for semestre_idx in semestre_ids: diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index 41a25d09..63bcb72a 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -500,6 +500,13 @@ def module_edit(module_id=None): matieres = matieres.filter(UniteEns.semestre_idx == a_module.ue.semestre_idx) if is_apc: + # ne conserve que la 1ere matière de chaque UE, + # et celle à laquelle ce module est rattaché + matieres = [ + mat + for mat in matieres + if a_module.matiere.id == mat.id or mat.id == mat.ue.matieres.first().id + ] mat_names = [ "S%s / %s" % (mat.ue.semestre_idx, mat.ue.acronyme) for mat in matieres ] @@ -544,14 +551,18 @@ def module_edit(module_id=None): # ne propose pas SAE et Ressources, sauf si déjà de ce type... module_types = ( set(scu.ModuleType) - {scu.ModuleType.RESSOURCE, scu.ModuleType.SAE} - ) | {a_module.module_type or scu.ModuleType.STANDARD} + ) | { + scu.ModuleType(a_module.module_type) + if a_module.module_type + else scu.ModuleType.STANDARD + } descr = [ ( "code", { "size": 10, - "explanation": "code du module (doit être unique dans la formation)", + "explanation": "code du module (issu du programme, exemple M1203 ou R2.01. Doit être unique dans la formation)", "allow_null": False, "validator": lambda val, field, formation_id=formation_id: check_module_code_unicity( val, field, formation_id, module_id=module_id @@ -690,7 +701,10 @@ def module_edit(module_id=None): { "title": "Code Apogée", "size": 25, - "explanation": "(optionnel) code élément pédagogique Apogée ou liste de codes ELP séparés par des virgules", + "explanation": """(optionnel) code élément pédagogique Apogée ou liste de codes ELP + séparés par des virgules (ce code est propre à chaque établissement, se rapprocher + du référent Apogée). + """, "validator": lambda val, _: len(val) < APO_CODE_STR_LEN, }, ), diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 17cdc8c0..14e4faec 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -29,7 +29,7 @@ """ import flask -from flask import url_for, render_template +from flask import flash, render_template, url_for from flask import g, request from flask_login import current_user @@ -89,6 +89,7 @@ _ueEditor = ndb.EditableTable( input_formators={ "type": ndb.int_null_is_zero, "is_external": ndb.bool_or_str, + "ects": ndb.float_null_is_null, }, output_formators={ "numero": ndb.int_null_is_zero, @@ -107,8 +108,6 @@ def ue_list(*args, **kw): def do_ue_create(args): "create an ue" - from app.scodoc import sco_formations - cnx = ndb.GetDBConnexion() # check duplicates ues = ue_list({"formation_id": args["formation_id"], "acronyme": args["acronyme"]}) @@ -117,6 +116,14 @@ def do_ue_create(args): f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé ! (chaque UE doit avoir un acronyme unique dans la formation)""" ) + if not "ue_code" in args: + # évite les conflits de code + while True: + cursor = db.session.execute("select notes_newid_ucod();") + code = cursor.fetchone()[0] + if UniteEns.query.filter_by(ue_code=code).count() == 0: + break + args["ue_code"] = code # create ue_id = _ueEditor.create(cnx, args) @@ -128,6 +135,8 @@ def do_ue_create(args): formation = Formation.query.get(args["formation_id"]) formation.invalidate_module_coefs() # news + ue = UniteEns.query.get(ue_id) + flash(f"UE créée (code {ue.ue_code})") formation = Formation.query.get(args["formation_id"]) sco_news.add( typ=sco_news.NEWS_FORM, @@ -296,7 +305,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No ( "numero", { - "size": 2, + "size": 4, "explanation": "numéro (1,2,3,4) de l'UE pour l'ordre d'affichage", "type": "int", }, @@ -339,6 +348,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No "type": "float", "title": "ECTS", "explanation": "nombre de crédits ECTS", + "allow_null": not is_apc, # ects requis en APC }, ), ( @@ -462,8 +472,10 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No "semestre_id": tf[2]["semestre_idx"], }, ) + flash("UE créée") else: do_ue_edit(tf[2]) + flash("UE modifiée") return flask.redirect( url_for( "notes.ue_table", @@ -601,7 +613,12 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list _add_ue_semestre_id(ues_externes, is_apc) ues.sort(key=lambda u: (u["semestre_id"], u["numero"])) ues_externes.sort(key=lambda u: (u["semestre_id"], u["numero"])) - has_duplicate_ue_codes = len(set([ue["ue_code"] for ue in ues])) != len(ues) + # Codes dupliqués (pour aider l'utilisateur) + seen = set() + duplicated_codes = { + ue["ue_code"] for ue in ues if ue["ue_code"] in seen or seen.add(ue["ue_code"]) + } + ues_with_duplicated_code = [ue for ue in ues if ue["ue_code"] in duplicated_codes] has_perm_change = current_user.has_permission(Permission.ScoChangeFormation) # editable = (not locked) and has_perm_change @@ -664,11 +681,17 @@ du programme" (menu "Semestre") si vous avez un semestre en cours); if msg: H.append('

' + msg + "

") - if has_duplicate_ue_codes: + if ues_with_duplicated_code: H.append( - """
Attention: plusieurs UE de cette - formation ont le même code. Il faut corriger cela ci-dessous, - sinon les calculs d'ECTS seront erronés !
""" + f"""
Attention: plusieurs UE de cette + formation ont le même code : { + ', '.join([ + '' + ue["acronyme"] + " (code " + ue["ue_code"] + ")" + for ue in ues_with_duplicated_code ]) + }. + Il faut corriger cela, sinon les capitalisations et ECTS seront + erronés !
""" ) # Description de la formation @@ -699,16 +722,16 @@ du programme" (menu "Semestre") si vous avez un semestre en cours); {formation.referentiel_competence.type_titre} {formation.referentiel_competence.specialite_long} - """ +  """ msg_refcomp = "changer" H.append( f""" """ ) - + H.append("
") H.append("") # formation_ue_list if ues_externes: @@ -913,10 +937,10 @@ def _ue_table_ues( cur_ue_semestre_id = None iue = 0 for ue in ues: - if ue["ects"]: - ue["ects_str"] = ", %g ECTS" % ue["ects"] - else: + if ue["ects"] is None: ue["ects_str"] = "" + else: + ue["ects_str"] = ", %g ECTS" % ue["ects"] if editable: klass = "span_apo_edit" else: @@ -930,8 +954,8 @@ def _ue_table_ues( if cur_ue_semestre_id != ue["semestre_id"]: cur_ue_semestre_id = ue["semestre_id"] - if iue > 0: - H.append("") + # if iue > 0: + # H.append("") if ue["semestre_id"] == sco_codes_parcours.UE_SEM_DEFAULT: lab = "Pas d'indication de semestre:" else: @@ -953,7 +977,6 @@ def _ue_table_ues( ) else: H.append(arrow_none) - iue += 1 ue["acro_titre"] = str(ue["acronyme"]) if ue["titre"] != ue["acronyme"]: ue["acro_titre"] += " " + str(ue["titre"]) @@ -1001,6 +1024,14 @@ def _ue_table_ues( delete_disabled_icon, ) ) + if (iue >= len(ues) - 1) or ue["semestre_id"] != ues[iue + 1]["semestre_id"]: + H.append( + f"""""" + ) + iue += 1 + return "\n".join(H) @@ -1268,7 +1299,6 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False): f"""Acronyme d'UE "{args['acronyme']}" déjà utilisé ! (chaque UE doit avoir un acronyme unique dans la formation)""" ) - # On ne peut pas supprimer le code UE: if "ue_code" in args and not args["ue_code"]: del args["ue_code"] diff --git a/app/scodoc/sco_etape_apogee_view.py b/app/scodoc/sco_etape_apogee_view.py index b784e5f0..21fc2410 100644 --- a/app/scodoc/sco_etape_apogee_view.py +++ b/app/scodoc/sco_etape_apogee_view.py @@ -53,7 +53,7 @@ from app.scodoc.sco_exceptions import ScoValueError def apo_semset_maq_status( - semset_id="", + semset_id: int, allow_missing_apo=False, allow_missing_decisions=False, allow_missing_csv=False, @@ -65,7 +65,7 @@ def apo_semset_maq_status( ): """Page statut / tableau de bord""" if not semset_id: - raise ValueError("invalid null semset_id") + raise ScoValueError("invalid null semset_id") semset = sco_semset.SemSet(semset_id=semset_id) semset.fill_formsemestres() # autorise export meme si etudiants Apo manquants: diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py index 09f2179b..d895a8a3 100644 --- a/app/scodoc/sco_evaluations.py +++ b/app/scodoc/sco_evaluations.py @@ -405,7 +405,6 @@ def formsemestre_evaluations_cal(formsemestre_id): """Page avec calendrier de toutes les evaluations de ce semestre""" formsemestre = FormSemestre.query.get_or_404(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - sem = formsemestre.to_dict() evals = nt.get_evaluations_etats() nb_evals = len(evals) @@ -416,8 +415,8 @@ def formsemestre_evaluations_cal(formsemestre_id): today = time.strftime("%Y-%m-%d") - year = int(sem["annee_debut"]) - if sem["mois_debut_ord"] < 8: + year = formsemestre.date_debut.year + if formsemestre.date_debut.month < 8: year -= 1 # calendrier septembre a septembre events = {} # (day, halfday) : event for e in evals: @@ -537,11 +536,10 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, format="html"): """Experimental: un tableau indiquant pour chaque évaluation le nombre de jours avant la publication des notes. - N'indique pas les évaluations de ratrapage ni celles des modules de bonus/malus. + N'indique pas les évaluations de rattrapage ni celles des modules de bonus/malus. """ formsemestre = FormSemestre.query.get_or_404(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - sem = formsemestre.to_dict() evals = nt.get_evaluations_etats() T = [] @@ -607,7 +605,7 @@ def formsemestre_evaluations_delai_correction(formsemestre_id, format="html"): origin="Généré par %s le " % sco_version.SCONAME + scu.timedate_human_repr() + "", - filename=scu.make_filename("evaluations_delais_" + sem["titreannee"]), + filename=scu.make_filename("evaluations_delais_" + formsemestre.titre_annee()), ) return tab.make_page(format=format) @@ -635,16 +633,13 @@ def evaluation_describe(evaluation_id="", edit_in_place=True): 'voir toutes les notes du module' % moduleimpl_id ) - mod_descr = ( - '%s %s (resp. %s) %s' - % ( - moduleimpl_id, - Mod["code"] or "", - Mod["titre"] or "?", - nomcomplet, - resp, - link, - ) + mod_descr = '%s %s (resp. %s) %s' % ( + moduleimpl_id, + Mod["code"] or "", + Mod["titre"] or "?", + nomcomplet, + resp, + link, ) etit = E["description"] or "" diff --git a/app/scodoc/sco_find_etud.py b/app/scodoc/sco_find_etud.py index 157cb6e5..4bc039ba 100644 --- a/app/scodoc/sco_find_etud.py +++ b/app/scodoc/sco_find_etud.py @@ -39,6 +39,7 @@ from app.scodoc.gen_tables import GenTable from app.scodoc import html_sco_header from app.scodoc import sco_etud from app.scodoc import sco_groups +from app.scodoc.sco_exceptions import ScoException from app.scodoc.sco_permissions import Permission from app.scodoc import sco_preferences @@ -221,7 +222,10 @@ def search_etuds_infos(expnom=None, code_nip=None): cnx = ndb.GetDBConnexion() if expnom and not may_be_nip: expnom = expnom.upper() # les noms dans la BD sont en uppercase - etuds = sco_etud.etudident_list(cnx, args={"nom": expnom}, test="~") + try: + etuds = sco_etud.etudident_list(cnx, args={"nom": expnom}, test="~") + except ScoException: + etuds = [] else: code_nip = code_nip or expnom if code_nip: diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py index caeeb707..573c95df 100644 --- a/app/scodoc/sco_formations.py +++ b/app/scodoc/sco_formations.py @@ -151,8 +151,14 @@ def formation_export( if mod["ects"] is None: del mod["ects"] + filename = f"scodoc_formation_{formation.departement.acronym}_{formation.acronyme or ''}_v{formation.version}" return scu.sendResult( - F, name="formation", format=format, force_outer_xml_tag=False, attached=True + F, + name="formation", + format=format, + force_outer_xml_tag=False, + attached=True, + filename=filename, ) diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index 81dbd565..7174cfc3 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -78,7 +78,7 @@ def formsemestre_createwithmodules(): H = [ html_sco_header.sco_header( page_title="Création d'un semestre", - javascripts=["libjs/AutoSuggest.js"], + javascripts=["libjs/AutoSuggest.js", "js/formsemestre_edit.js"], cssstyles=["css/autosuggest_inquisitor.css"], bodyOnLoad="init_tf_form('')", ), @@ -99,7 +99,7 @@ def formsemestre_editwithmodules(formsemestre_id): H = [ html_sco_header.html_sem_header( "Modification du semestre", - javascripts=["libjs/AutoSuggest.js"], + javascripts=["libjs/AutoSuggest.js", "js/formsemestre_edit.js"], cssstyles=["css/autosuggest_inquisitor.css"], bodyOnLoad="init_tf_form('')", ) @@ -213,7 +213,10 @@ def do_formsemestre_createwithmodules(edit=False): # en APC, ne permet pas de changer de semestre semestre_id_list = [formsemestre.semestre_id] else: - semestre_id_list = [-1] + list(range(1, NB_SEM + 1)) + semestre_id_list = list(range(1, NB_SEM + 1)) + if not formation.is_apc(): + # propose "pas de semestre" seulement en classique + semestre_id_list.insert(0, -1) semestre_id_labels = [] for sid in semestre_id_list: @@ -341,6 +344,9 @@ def do_formsemestre_createwithmodules(edit=False): "explanation": "en BUT, on ne peut pas modifier le semestre après création" if formation.is_apc() else "", + "attributes": ['onchange="change_semestre_id();"'] + if formation.is_apc() + else "", }, ), ) @@ -493,7 +499,8 @@ def do_formsemestre_createwithmodules(edit=False): { "input_type": "boolcheckbox", "title": "", - "explanation": "Autoriser tous les enseignants associés à un module à y créer des évaluations", + "explanation": """Autoriser tous les enseignants associés + à un module à y créer des évaluations""", }, ), ( @@ -534,11 +541,19 @@ def do_formsemestre_createwithmodules(edit=False): ] nbmod = 0 - if edit: - templ_sep = "%(label)sResponsableInscrire" - else: - templ_sep = "%(label)sResponsable" + for semestre_id in semestre_ids: + if formation.is_apc(): + # pour restreindre l'édition aux module du semestre sélectionné + tr_class = f'class="sem{semestre_id}"' + else: + tr_class = "" + if edit: + templ_sep = f"""%(label)sResponsableInscrire""" + else: + templ_sep = ( + f"""%(label)sResponsable""" + ) modform.append( ( "sep", @@ -588,12 +603,12 @@ def do_formsemestre_createwithmodules(edit=False): ) fcg += "" itemtemplate = ( - """%(label)s%(elem)s""" + f"""%(label)s%(elem)s""" + fcg + "" ) else: - itemtemplate = """%(label)s%(elem)s""" + itemtemplate = f"""%(label)s%(elem)s""" modform.append( ( "MI" + str(mod["module_id"]), diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index d8be56f2..2080e957 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -595,11 +595,12 @@ def formsemestre_description_table(formsemestre_id, with_evals=False): """Description du semestre sous forme de table exportable Liste des modules et de leurs coefficients """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id) - F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] + F = sco_formations.formation_list(args={"formation_id": formsemestre.formation_id})[ + 0 + ] parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"]) Mlist = sco_moduleimpl.moduleimpl_withmodule_list( formsemestre_id=formsemestre_id, sort_by_ue=True @@ -709,7 +710,7 @@ def formsemestre_description_table(formsemestre_id, with_evals=False): titles["coefficient"] = "Coef. éval." titles["evalcomplete_str"] = "Complète" titles["publish_incomplete_str"] = "Toujours Utilisée" - title = "%s %s" % (parcours.SESSION_NAME.capitalize(), sem["titremois"]) + title = "%s %s" % (parcours.SESSION_NAME.capitalize(), formsemestre.titre_mois()) return GenTable( columns_ids=columns_ids, @@ -986,7 +987,6 @@ Il y a des notes en attente ! Le classement des étudiants n'a qu'une valeur ind def formsemestre_status(formsemestre_id=None): """Tableau de bord semestre HTML""" # porté du DTML - cnx = ndb.GetDBConnexion() sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True) modimpls = sco_moduleimpl.moduleimpl_withmodule_list( formsemestre_id=formsemestre_id @@ -1077,7 +1077,7 @@ def formsemestre_status(formsemestre_id=None): "

", ] - if use_ue_coefs: + if use_ue_coefs and not formsemestre.formation.is_apc(): H.append( """

utilise les coefficients d'UE pour calculer la moyenne générale.

diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index f755bb11..c83e1cc4 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -585,15 +585,17 @@ def formsemestre_recap_parcours_table( else: H.append('en cours') H.append('%s' % ass) # abs - # acronymes UEs auxquelles l'étudiant est inscrit: - # XXX il est probable que l'on doive ici ajouter les - # XXX UE capitalisées + # acronymes UEs auxquelles l'étudiant est inscrit (ou capitalisé) ues = nt.get_ues_stat_dict(filter_sport=True) cnx = ndb.GetDBConnexion() + etud_ue_status = { + ue["ue_id"]: nt.get_etud_ue_status(etudid, ue["ue_id"]) for ue in ues + } ues = [ ue for ue in ues if etud_est_inscrit_ue(cnx, etudid, sem["formsemestre_id"], ue["ue_id"]) + or etud_ue_status[ue["ue_id"]]["is_capitalized"] ] for ue in ues: @@ -644,7 +646,7 @@ def formsemestre_recap_parcours_table( code = decisions_ue[ue["ue_id"]]["code"] else: code = "" - ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) + ue_status = etud_ue_status[ue["ue_id"]] moy_ue = ue_status["moy"] if ue_status else "" explanation_ue = [] # list of strings if code == ADM: @@ -1250,7 +1252,7 @@ def check_formation_ues(formation_id): for ue in ues: # formsemestres utilisant cette ue ? sems = ndb.SimpleDictFetch( - """SELECT DISTINCT sem.id AS formsemestre_id, sem.* + """SELECT DISTINCT sem.id AS formsemestre_id, sem.* FROM notes_formsemestre sem, notes_modules mod, notes_moduleimpl mi WHERE sem.formation_id = %(formation_id)s AND mod.id = mi.module_id @@ -1269,11 +1271,11 @@ def check_formation_ues(formation_id): return "", {} # Genere message HTML: H = [ - """
Attention: les UE suivantes de cette formation + """
Attention: les UE suivantes de cette formation sont utilisées dans des - semestres de rangs différents (eg S1 et S3).
Cela peut engendrer des problèmes pour - la capitalisation des UE. Il serait préférable d'essayer de rectifier cette situation: - soit modifier le programme de la formation (définir des UE dans chaque semestre), + semestres de rangs différents (eg S1 et S3).
Cela peut engendrer des problèmes pour + la capitalisation des UE. Il serait préférable d'essayer de rectifier cette situation: + soit modifier le programme de la formation (définir des UE dans chaque semestre), soit veiller à saisir le bon indice de semestre dans le menu lors de la validation d'une UE extérieure.
") diff --git a/app/scodoc/sco_groups_exports.py b/app/scodoc/sco_groups_exports.py new file mode 100644 index 00000000..b2e95545 --- /dev/null +++ b/app/scodoc/sco_groups_exports.py @@ -0,0 +1,96 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 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 +# +############################################################################## + +"""Exports groupes +""" +from flask import request + +from app.scodoc import notesdb as ndb +from app.scodoc import sco_excel +from app.scodoc import sco_groups_view +from app.scodoc import sco_preferences +from app.scodoc.gen_tables import GenTable +import app.scodoc.sco_utils as scu +import sco_version + + +def groups_list_annotation(group_ids: list[int]) -> list[dict]: + """Renvoie la liste des annotations pour les groupes d"étudiants indiqués + Arg: liste des id de groupes + Clés: etudid, ine, nip, nom, prenom, date, comment + """ + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + annotations = [] + for group_id in group_ids: + cursor.execute( + """SELECT i.id AS etudid, i.code_nip, i.code_ine, i.nom, i.prenom, ea.date, ea.comment + FROM group_membership gm, identite i, etud_annotations ea + WHERE gm.group_id=%(group_ids)s + AND gm.etudid=i.id + AND i.id=ea.etudid + """, + {"group_ids": group_id}, + ) + annotations += cursor.dictfetchall() + return annotations + + +def groups_export_annotations(group_ids, formsemestre_id=None, format="html"): + """Les annotations""" + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, formsemestre_id=formsemestre_id + ) + annotations = groups_list_annotation(groups_infos.group_ids) + for annotation in annotations: + annotation["date_str"] = annotation["date"].strftime("%d/%m/%Y à %Hh%M") + if format == "xls": + columns_ids = ("etudid", "nom", "prenom", "date", "comment") + else: + columns_ids = ("etudid", "nom", "prenom", "date_str", "comment") + table = GenTable( + rows=annotations, + columns_ids=columns_ids, + titles={ + "etudid": "etudid", + "nom": "Nom", + "prenom": "Prénom", + "date": "Date", + "date_str": "Date", + "comment": "Annotation", + }, + origin="Généré par %s le " % sco_version.SCONAME + + scu.timedate_human_repr() + + "", + page_title=f"Annotations sur les étudiants de {groups_infos.groups_titles}", + caption="Annotations", + base_url=groups_infos.base_url, + html_sortable=True, + html_class="table_leftalign", + preferences=sco_preferences.SemPreferences(formsemestre_id), + ) + return table.make_page(format=format) diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index 5b89ab44..259256bd 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -826,6 +826,8 @@ def tab_absences_html(groups_infos, etat=None): % groups_infos.groups_query_args, """
  • Liste d'appel avec photos
  • """ % groups_infos.groups_query_args, + """
  • Liste des annotations
  • """ + % groups_infos.groups_query_args, "", ] ) diff --git a/app/scodoc/sco_logos.py b/app/scodoc/sco_logos.py index c489510b..02272ce8 100644 --- a/app/scodoc/sco_logos.py +++ b/app/scodoc/sco_logos.py @@ -151,6 +151,8 @@ class Logo: Le format est renseigné au moment de la lecture (select) ou de la création (create) de l'objet """ self.logoname = secure_filename(logoname) + if not self.logoname: + self.logoname = "*** *** nom de logo invalide *** à changer ! *** ***" self.scodoc_dept_id = dept_id self.prefix = prefix or "" if self.scodoc_dept_id: @@ -276,7 +278,7 @@ class Logo: if self.mm is None: return f'' else: - return f'' + return f'' def last_modified(self): path = Path(self.filepath) diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index e3c23230..453f63f1 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -305,7 +305,10 @@ def moduleimpl_inscriptions_stats(formsemestre_id): if can_change: c_link = ( '%s' - % (mod["moduleimpl_id"], mod["descri"]) + % ( + mod["moduleimpl_id"], + mod["descri"] or "(inscrire des étudiants)", + ) ) else: c_link = mod["descri"] diff --git a/app/scodoc/sco_parcours_dut.py b/app/scodoc/sco_parcours_dut.py index d6e244e7..fbf190c5 100644 --- a/app/scodoc/sco_parcours_dut.py +++ b/app/scodoc/sco_parcours_dut.py @@ -139,9 +139,7 @@ class SituationEtudParcoursGeneric(object): # pour le DUT, le dernier est toujours S4. # Ici: terminal si semestre == NB_SEM ou bien semestre_id==-1 # (licences et autres formations en 1 seule session)) - self.semestre_non_terminal = ( - self.sem["semestre_id"] != self.parcours.NB_SEM - ) # True | False + self.semestre_non_terminal = self.sem["semestre_id"] != self.parcours.NB_SEM if self.sem["semestre_id"] == NO_SEMESTRE_ID: self.semestre_non_terminal = False # Liste des semestres du parcours de cet étudiant: diff --git a/app/scodoc/sco_pdf.py b/app/scodoc/sco_pdf.py index 0e099680..3949f50a 100755 --- a/app/scodoc/sco_pdf.py +++ b/app/scodoc/sco_pdf.py @@ -220,12 +220,16 @@ class ScolarsPageTemplate(PageTemplate): PageTemplate.__init__(self, "ScolarsPageTemplate", [content]) self.logo = None logo = find_logo( - logoname="bul_pdf_background", dept_id=g.scodoc_dept_id, prefix=None + logoname="bul_pdf_background", dept_id=g.scodoc_dept_id + ) or find_logo( + logoname="bul_pdf_background", dept_id=g.scodoc_dept_id, prefix="" ) if logo is None: # Also try to use PV background logo = find_logo( - logoname="letter_background", dept_id=g.scodoc_dept_id, prefix=None + logoname="letter_background", dept_id=g.scodoc_dept_id + ) or find_logo( + logoname="letter_background", dept_id=g.scodoc_dept_id, prefix="" ) if logo is not None: self.background_image_filename = logo.filepath diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index b639d5ee..aab148e7 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -1296,11 +1296,21 @@ class BasePreferences(object): "labels": ["non", "oui"], }, ), + ( + "bul_show_ue_coef", + { + "initvalue": 1, + "title": "Afficher coefficient des UE sur les bulletins", + "input_type": "boolcheckbox", + "category": "bul", + "labels": ["non", "oui"], + }, + ), ( "bul_show_coef", { "initvalue": 1, - "title": "Afficher coefficient des ue/modules sur les bulletins", + "title": "Afficher coefficient des modules sur les bulletins", "input_type": "boolcheckbox", "category": "bul", "labels": ["non", "oui"], diff --git a/app/scodoc/sco_pvpdf.py b/app/scodoc/sco_pvpdf.py index 1fb98e3d..2e8cdaf6 100644 --- a/app/scodoc/sco_pvpdf.py +++ b/app/scodoc/sco_pvpdf.py @@ -206,12 +206,18 @@ class CourrierIndividuelTemplate(PageTemplate): background = find_logo( logoname="pvjury_background", dept_id=g.scodoc_dept_id, + ) or find_logo( + logoname="pvjury_background", + dept_id=g.scodoc_dept_id, prefix="", ) else: background = find_logo( logoname="letter_background", dept_id=g.scodoc_dept_id, + ) or find_logo( + logoname="letter_background", + dept_id=g.scodoc_dept_id, prefix="", ) if not self.background_image_filename and background is not None: diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py index bbcf4a08..db775438 100644 --- a/app/scodoc/sco_synchro_etuds.py +++ b/app/scodoc/sco_synchro_etuds.py @@ -854,23 +854,27 @@ def formsemestre_import_etud_admission( apo_emailperso = etud.get("mailperso", "") if info["emailperso"] and not apo_emailperso: apo_emailperso = info["emailperso"] - if ( - import_email - and info["email"] != etud["mail"] - or info["emailperso"] != apo_emailperso - ): - sco_etud.adresse_edit( - cnx, - args={ - "etudid": etudid, - "adresse_id": info["adresse_id"], - "email": etud["mail"], - "emailperso": apo_emailperso, - }, - ) - # notifie seulement les changements d'adresse mail institutionnelle - if info["email"] != etud["mail"]: - changed_mails.append((info, etud["mail"])) + if import_email: + if not "mail" in etud: + raise ScoValueError( + "la réponse portail n'a pas le champs requis 'mail'" + ) + if ( + info["email"] != etud["mail"] + or info["emailperso"] != apo_emailperso + ): + sco_etud.adresse_edit( + cnx, + args={ + "etudid": etudid, + "adresse_id": info["adresse_id"], + "email": etud["mail"], + "emailperso": apo_emailperso, + }, + ) + # notifie seulement les changements d'adresse mail institutionnelle + if info["email"] != etud["mail"]: + changed_mails.append((info, etud["mail"])) else: unknowns.append(code_nip) sco_cache.invalidate_formsemestre(formsemestre_id=sem["formsemestre_id"]) diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 1cc6e89c..196c2c21 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -50,7 +50,7 @@ import pydot import requests from flask import g, request -from flask import url_for, make_response, jsonify +from flask import flash, url_for, make_response, jsonify from config import Config from app import log @@ -616,6 +616,16 @@ def bul_filename(sem, etud, format): return filename +def flash_errors(form): + """Flashes form errors (version sommaire)""" + for field, errors in form.errors.items(): + flash( + "Erreur: voir le champs %s" % (getattr(form, field).label.text,), + "warning", + ) + # see https://getbootstrap.com/docs/4.0/components/alerts/ + + def sendCSVFile(data, filename): # DEPRECATED utiliser send_file """publication fichier CSV.""" return send_file(data, filename=filename, mime=CSV_MIMETYPE, attached=True) @@ -635,21 +645,30 @@ class ScoDocJSONEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, o) -def sendJSON(data, attached=False): +def sendJSON(data, attached=False, filename=None): js = json.dumps(data, indent=1, cls=ScoDocJSONEncoder) return send_file( - js, filename="sco_data.json", mime=JSON_MIMETYPE, attached=attached + js, filename=filename or "sco_data.json", mime=JSON_MIMETYPE, attached=attached ) -def sendXML(data, tagname=None, force_outer_xml_tag=True, attached=False, quote=True): +def sendXML( + data, + tagname=None, + force_outer_xml_tag=True, + attached=False, + quote=True, + filename=None, +): if type(data) != list: data = [data] # always list-of-dicts if force_outer_xml_tag: data = [{tagname: data}] tagname += "_list" doc = sco_xml.simple_dictlist2xml(data, tagname=tagname, quote=quote) - return send_file(doc, filename="sco_data.xml", mime=XML_MIMETYPE, attached=attached) + return send_file( + doc, filename=filename or "sco_data.xml", mime=XML_MIMETYPE, attached=attached + ) def sendResult( @@ -659,6 +678,7 @@ def sendResult( force_outer_xml_tag=True, attached=False, quote_xml=True, + filename=None, ): if (format is None) or (format == "html"): return data @@ -669,9 +689,10 @@ def sendResult( force_outer_xml_tag=force_outer_xml_tag, attached=attached, quote=quote_xml, + filename=filename, ) elif format == "json": - return sendJSON(data, attached=attached) + return sendJSON(data, attached=attached, filename=filename) else: raise ValueError("invalid format: %s" % format) @@ -789,7 +810,7 @@ def abbrev_prenom(prenom): # def timedate_human_repr(): - "representation du temps courant pour utilisateur: a localiser" + "representation du temps courant pour utilisateur" return time.strftime("%d/%m/%Y à %Hh%M") diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index e8893085..d735077a 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -138,7 +138,7 @@ div.head_message { border-radius: 8px; font-family : arial, verdana, sans-serif ; font-weight: bold; - width: 40%; + width: 70%; text-align: center; } @@ -287,15 +287,15 @@ div.logo-insidebar { width: 75px; /* la marge fait 130px */ } div.logo-logo { + margin-left: -5px; text-align: center ; } div.logo-logo img { box-sizing: content-box; - margin-top: -10px; - width: 128px; + margin-top: 10px; /* -10px */ + width: 135px; /* 128px */ padding-right: 5px; - margin-left: -75px; } div.sidebar-bottom { margin-top: 10px; @@ -1297,7 +1297,7 @@ th.formsemestre_status_inscrits { text-align: center; } td.formsemestre_status_code { - width: 2em; + /* width: 2em; */ padding-right: 1em; } @@ -1671,7 +1671,10 @@ div.formation_list_modules ul.notes_module_list { padding-top: 5px; padding-bottom: 5px; } - +span.missing_ue_ects { + color: red; + font-weight: bold; +} li.module_malus span.formation_module_tit { color: red; font-weight: bold; @@ -1703,10 +1706,16 @@ ul.notes_ue_list { padding-bottom: 1em; font-weight: bold; } +.formation_classic_infos ul.notes_ue_list { + padding-top: 0px; +} -li.notes_ue_list { +.formation_classic_infos li.notes_ue_list { margin-top: 9px; list-style-type: none; + border: 1px solid maroon; + border-radius: 10px; + padding-bottom: 5px; } span.ue_type_1 { color: green; @@ -1749,6 +1758,7 @@ ul.notes_matiere_list { background-color: rgb(220,220,220); font-weight: normal; font-style: italic; + border-top: 1px solid maroon; } ul.notes_module_list { @@ -1757,6 +1767,11 @@ ul.notes_module_list { font-style: normal; } +div.ue_list_tit_sem { + font-size: 120%; + font-weight: bold; +} + .notes_ue_list a.stdlink { color: #001084; text-decoration: underline; diff --git a/app/static/icons/scologo_img.png b/app/static/icons/scologo_img.png index fb0467ae..5c710a76 100644 Binary files a/app/static/icons/scologo_img.png and b/app/static/icons/scologo_img.png differ diff --git a/app/static/js/formsemestre_edit.js b/app/static/js/formsemestre_edit.js new file mode 100644 index 00000000..3394d659 --- /dev/null +++ b/app/static/js/formsemestre_edit.js @@ -0,0 +1,14 @@ +// Formulaire formsemestre_createwithmodules + +function change_semestre_id() { + var semestre_id = $("#tf_semestre_id")[0].value; + for (var i = -1; i < 12; i++) { + $(".sem" + i).hide(); + } + $(".sem" + semestre_id).show(); +} + + +$(window).on('load', function () { + change_semestre_id(); +}); \ No newline at end of file diff --git a/app/static/js/releve-but.js b/app/static/js/releve-but.js index 5dcd9e5b..dcc4520b 100644 --- a/app/static/js/releve-but.js +++ b/app/static/js/releve-but.js @@ -91,7 +91,7 @@ class releveBUT extends HTMLElement {
    Inscrit le
    - Les moyennes servent à situer l'étudiant dans la promotion et ne correspondent pas à des validations de compétences ou d'UE. + Les moyennes ci-dessus servent à situer l'étudiant dans la promotion et ne correspondent pas à des validations de compétences ou d'UE.
    diff --git a/app/templates/base.html b/app/templates/base.html index 176e4b99..658f41eb 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -61,12 +61,10 @@ {% block content %}
    - {% with messages = get_flashed_messages() %} - {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} {% endwith %} {# application content needs to be provided in the app_content block #} diff --git a/app/templates/but/refcomp_load.html b/app/templates/but/refcomp_load.html index 290de937..50d73d80 100644 --- a/app/templates/but/refcomp_load.html +++ b/app/templates/but/refcomp_load.html @@ -18,10 +18,12 @@ Liste des référentiels de compétences chargés + {% if formation is not none %}
  • Association à la formation {{ formation.acronyme }}
  • + {% endif %}
    diff --git a/app/templates/flashed_messages.html b/app/templates/flashed_messages.html new file mode 100644 index 00000000..5ded7524 --- /dev/null +++ b/app/templates/flashed_messages.html @@ -0,0 +1,9 @@ +{# Message flask : utilisé uniquement par les anciennes pages ScoDoc #} +{# -*- mode: jinja-html -*- #} +
    + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} + {% endwith %} +
    \ No newline at end of file diff --git a/app/templates/pn/form_mods.html b/app/templates/pn/form_mods.html index 2be3cfe2..10927bc8 100644 --- a/app/templates/pn/form_mods.html +++ b/app/templates/pn/form_mods.html @@ -65,6 +65,13 @@ {% endfor %} + {% if mod.ue.type != 0 and mod.module_type != 0 %} + + type incompatible avec son UE de rattachement ! + + {% endif %} +
    diff --git a/app/templates/pn/form_ues.html b/app/templates/pn/form_ues.html index 350d12d8..a91f2942 100644 --- a/app/templates/pn/form_ues.html +++ b/app/templates/pn/form_ues.html @@ -3,11 +3,9 @@
    Unités d'Enseignement (UEs)
    {% for semestre_idx in semestre_ids %} -
    Semestre S{{semestre_idx}}
    +
    Semestre S{{semestre_idx}} (ECTS: {{ects_by_sem[semestre_idx] | safe}})
    diff --git a/app/templates/sco_page.html b/app/templates/sco_page.html index d3fbdcf8..e1aa9e3c 100644 --- a/app/templates/sco_page.html +++ b/app/templates/sco_page.html @@ -23,12 +23,10 @@
    - {% with messages = get_flashed_messages() %} - {% if messages %} - {% for message in messages %} - - {% endfor %} - {% endif %} + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} + + {% endfor %} {% endwith %}
    {% if sco.sem %} diff --git a/app/templates/sidebar.html b/app/templates/sidebar.html index c5b06a6f..5ed8ee88 100644 --- a/app/templates/sidebar.html +++ b/app/templates/sidebar.html @@ -4,7 +4,7 @@