diff --git a/README.md b/README.md index 5827703c75..209a2a0176 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,12 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes). -### État actuel (4 dec 21) +### État actuel (26 jan 22) - - 9.0 (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf: - - ancien module "Entreprises" (obsolète) + - 9.1 (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf: + - ancien module "Entreprises" (obsolète) et ajoute la gestion du BUT. - - 9.1 (branche "PNBUT") est la version de développement. + - 9.2 (branche refactor_nt) est la version de développement. ### Lignes de commandes diff --git a/app/__init__.py b/app/__init__.py index 0a58914476..76f9471bb4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -253,7 +253,7 @@ def create_app(config_class=DevConfig): host_name = socket.gethostname() mail_handler = ScoSMTPHandler( mailhost=(app.config["MAIL_SERVER"], app.config["MAIL_PORT"]), - fromaddr="no-reply@" + app.config["MAIL_SERVER"], + fromaddr=app.config["SCODOC_MAIL_FROM"], toaddrs=["exception@scodoc.org"], subject="ScoDoc Exception", # unused see ScoSMTPHandler credentials=auth, diff --git a/app/auth/email.py b/app/auth/email.py index 9ac8a0173d..617596910f 100644 --- a/app/auth/email.py +++ b/app/auth/email.py @@ -8,7 +8,7 @@ def send_password_reset_email(user): token = user.get_reset_password_token() send_email( "[ScoDoc] Réinitialisation de votre mot de passe", - sender=current_app.config["ADMINS"][0], + sender=current_app.config["SCODOC_MAIL_FROM"], recipients=[user.email], text_body=render_template("email/reset_password.txt", user=user, token=token), html_body=render_template("email/reset_password.html", user=user, token=token), diff --git a/app/auth/models.py b/app/auth/models.py index 8f187b7e94..32768df03d 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -112,6 +112,7 @@ class User(UserMixin, db.Model): self.password_hash = generate_password_hash(password) else: self.password_hash = None + self.passwd_temp = False def check_password(self, password): """Check given password vs current one. diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 2c6288bb47..cc51e2a28f 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -13,6 +13,7 @@ from flask import url_for, g from app.scodoc import sco_utils as scu from app.scodoc import sco_bulletins_json from app.scodoc import sco_preferences +from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_utils import fmt_note from app.comp.res_but import ResultatsSemestreBUT @@ -27,45 +28,71 @@ class BulletinBUT(ResultatsSemestreBUT): "dict synthèse résultats dans l'UE pour les modules indiqués" d = {} etud_idx = self.etud_index[etud.id] - ue_idx = self.modimpl_coefs_df.index.get_loc(ue.id) + if ue.type != UE_SPORT: + ue_idx = self.modimpl_coefs_df.index.get_loc(ue.id) etud_moy_module = self.sem_cube[etud_idx] # module x UE for modimpl in modimpls: - if self.modimpl_inscr_df[str(modimpl.id)][etud.id]: # si inscrit - coef = self.modimpl_coefs_df[modimpl.id][ue.id] - if coef > 0: - d[modimpl.module.code] = { - "id": modimpl.id, - "coef": coef, - "moyenne": fmt_note( - etud_moy_module[ - self.modimpl_coefs_df.columns.get_loc(modimpl.id) - ][ue_idx] - ), - } + if self.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit + if ue.type != UE_SPORT: + coef = self.modimpl_coefs_df[modimpl.id][ue.id] + if coef > 0: + d[modimpl.module.code] = { + "id": modimpl.id, + "coef": coef, + "moyenne": fmt_note( + etud_moy_module[ + self.modimpl_coefs_df.columns.get_loc(modimpl.id) + ][ue_idx] + ), + } + # else: # modules dans UE bonus sport + # d[modimpl.module.code] = { + # "id": modimpl.id, + # "coef": "", + # "moyenne": "?x?", + # } return d def etud_ue_results(self, etud, ue): "dict synthèse résultats UE" d = { "id": ue.id, + "titre": ue.titre, "numero": ue.numero, + "type": ue.type, "ECTS": { "acquis": 0, # XXX TODO voir jury "total": ue.ects, }, + "color": ue.color, "competence": None, # XXX TODO lien avec référentiel - "moyenne": { - "value": fmt_note(self.etud_moy_ue[ue.id][etud.id]), - "min": fmt_note(self.etud_moy_ue[ue.id].min()), - "max": fmt_note(self.etud_moy_ue[ue.id].max()), - "moy": fmt_note(self.etud_moy_ue[ue.id].mean()), - }, - "bonus": None, # XXX TODO + "moyenne": None, + # Le bonus sport appliqué sur cette UE + "bonus": fmt_note(self.bonus_ues[ue.id][etud.id]) + if self.bonus_ues is not None and ue.id in self.bonus_ues + else fmt_note(0.0), "malus": None, # XXX TODO voir ce qui est ici "capitalise": None, # "AAAA-MM-JJ" TODO "ressources": self.etud_ue_mod_results(etud, ue, self.ressources), "saes": self.etud_ue_mod_results(etud, ue, self.saes), } + if ue.type != UE_SPORT: + d["moyenne"] = { + "value": fmt_note(self.etud_moy_ue[ue.id][etud.id]), + "min": fmt_note(self.etud_moy_ue[ue.id].min()), + "max": fmt_note(self.etud_moy_ue[ue.id].max()), + "moy": fmt_note(self.etud_moy_ue[ue.id].mean()), + } + else: + # ceci suppose que l'on a une seule UE bonus, + # en tous cas elles auront la même description + d["bonus_description"] = self.etud_bonus_description(etud.id) + modimpls_spo = [ + modimpl + for modimpl in self.formsemestre.modimpls_sorted + if modimpl.module.ue.type == UE_SPORT + ] + d["modules"] = self.etud_mods_results(etud, modimpls_spo) return d def etud_mods_results(self, etud, modimpls) -> dict: @@ -88,7 +115,7 @@ class BulletinBUT(ResultatsSemestreBUT): # except RuntimeWarning: # all nans in np.nanmean # pass modimpl_results = self.modimpls_results[modimpl.id] - if self.modimpl_inscr_df[str(modimpl.id)][etud.id]: # si inscrit + if self.modimpl_inscr_df[modimpl.id][etud.id]: # si inscrit d[modimpl.module.code] = { "id": modimpl.id, "titre": modimpl.module.titre, @@ -144,14 +171,42 @@ class BulletinBUT(ResultatsSemestreBUT): } return d - def bulletin_etud(self, etud, formsemestre) -> dict: - """Le bulletin de l'étudiant dans ce semestre""" + def etud_bonus_description(self, etudid): + """description du bonus affichée dans la section "UE bonus".""" + if self.bonus_ues is None or self.bonus_ues.shape[1] == 0: + return "" + import random + + bonus_vect = self.bonus_ues.loc[etudid] + if bonus_vect.nunique() > 1: + # détail UE par UE + details = [ + f"{fmt_note(bonus_vect[ue.id])} sur {ue.acronyme}" + for ue in self.ues + if self.modimpls_in_ue(ue.id, etudid) + and ue.id in self.bonus_ues + and bonus_vect[ue.id] > 0.0 + ] + if details: + return "Bonus de " + ", ".join(details) + else: + return "" # aucun bonus + else: + return f"Bonus de {fmt_note(bonus_vect.iloc[0])}" + + def bulletin_etud(self, etud, formsemestre, force_publishing=False) -> dict: + """Le bulletin de l'étudiant dans ce semestre. + Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai + (bulletins non publiés). + """ etat_inscription = etud.etat_inscription(formsemestre.id) nb_inscrits = self.get_inscriptions_counts()[scu.INSCRIT] + published = (not formsemestre.bul_hide_xml) or force_publishing d = { "version": "0", "type": "BUT", "date": datetime.datetime.utcnow().isoformat() + "Z", + "publie": not formsemestre.bul_hide_xml, "etudiant": etud.to_dict_bul(), "formation": { "id": formsemestre.formation.id, @@ -163,6 +218,10 @@ class BulletinBUT(ResultatsSemestreBUT): "etat_inscription": etat_inscription, "options": sco_preferences.bulletin_option_affichage(formsemestre.id), } + if not published: + return d + + nbabs, nbabsjust = formsemestre.get_abs_count(etud.id) semestre_infos = { "etapes": [str(x.etape_apo) for x in formsemestre.etapes if x.etape_apo], "date_debut": formsemestre.date_debut.isoformat(), @@ -171,9 +230,9 @@ class BulletinBUT(ResultatsSemestreBUT): "inscription": "TODO-MM-JJ", # XXX TODO "numero": formsemestre.semestre_id, "groupes": [], # XXX TODO - "absences": { # XXX TODO - "injustifie": -1, - "total": -1, + "absences": { + "injustifie": nbabsjust, + "total": nbabs, }, } semestre_infos.update( @@ -199,7 +258,11 @@ class BulletinBUT(ResultatsSemestreBUT): "ressources": self.etud_mods_results(etud, self.ressources), "saes": self.etud_mods_results(etud, self.saes), "ues": { - ue.acronyme: self.etud_ue_results(etud, ue) for ue in self.ues + ue.acronyme: self.etud_ue_results(etud, ue) + for ue in self.ues + if self.modimpls_in_ue( + ue.id, etud.id + ) # si l'UE comporte des modules auxquels on est inscrit }, "semestre": semestre_infos, }, diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index f318f236e9..69b8f56867 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -134,8 +134,12 @@ def bulletin_but_xml_compat( moy=scu.fmt_note(results.etud_moy_gen.mean()), # moyenne des moy. gen. ) ) - rang = 0 # XXX TODO rang de l'étduiant selon la moy gen indicative - bonus = 0 # XXX TODO valeur du bonus sport + rang = 0 # XXX TODO rang de l'étudiant selon la moy gen indicative + # valeur du bonus sport + if results.bonus is not None: + bonus = results.bonus[etud.id] + else: + bonus = 0 doc.append(Element("rang", value=str(rang), ninscrits=str(nb_inscrits))) # XXX TODO: ajouter "rang_group" : rangs dans les partitions doc.append(Element("note_max", value="20")) # notes toujours sur 20 diff --git a/app/comp/aux.py b/app/comp/aux_stats.py similarity index 70% rename from app/comp/aux.py rename to app/comp/aux_stats.py index 6a758a64b0..07517f3622 100644 --- a/app/comp/aux.py +++ b/app/comp/aux_stats.py @@ -19,12 +19,16 @@ class StatsMoyenne: def __init__(self, vals): """Calcul les statistiques. Les valeurs NAN ou non numériques sont toujours enlevées. + Si vals is None, renvoie des zéros (utilisé pour UE bonus) """ - self.moy = np.nanmean(vals) - self.min = np.nanmin(vals) - self.max = np.nanmax(vals) - self.size = len(vals) - self.nb_vals = self.size - np.count_nonzero(np.isnan(vals)) + if vals is None: + self.moy = self.min = self.max = self.size = self.nb_vals = 0 + else: + self.moy = np.nanmean(vals) + self.min = np.nanmin(vals) + self.max = np.nanmax(vals) + self.size = len(vals) + self.nb_vals = self.size - np.count_nonzero(np.isnan(vals)) def to_dict(self): "Tous les attributs dans un dict" diff --git a/app/comp/bonus_spo.py b/app/comp/bonus_spo.py new file mode 100644 index 0000000000..e7f97c13d8 --- /dev/null +++ b/app/comp/bonus_spo.py @@ -0,0 +1,664 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Classes spécifiques de calcul du bonus sport, culture ou autres activités + +Les classes de Bonus fournissent deux méthodes: + - get_bonus_ues() + - get_bonus_moy_gen() + + +""" +import datetime +import numpy as np +import pandas as pd + +from flask import g + +from app.models.formsemestre import FormSemestre +from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_utils import ModuleType + + +def get_bonus_sport_class_from_name(dept_id): + """La classe de bonus sport pour le département indiqué. + Note: en ScoDoc 9, le bonus sport est défini gloabelement et + ne dépend donc pas du département. + Résultat: une sous-classe de BonusSport + """ + raise NotImplementedError() + + +class BonusSport: + """Calcul du bonus sport. + + Arguments: + - sem_modimpl_moys : + notes moyennes aux modules (tous les étuds x tous les modimpls) + floats avec des NaN. + En classique: sem_matrix, ndarray (etuds x modimpls) + En APC: sem_cube, ndarray (etuds x modimpls x UEs non bonus) + - ues: les ues du semestre (incluant le bonus sport) + - modimpl_inscr_df: matrice d'inscription aux modules du semestre (etud x modimpl) + - modimpl_coefs: les coefs des modules + En classique: 1d ndarray de float (modimpl) + En APC: 2d ndarray de float, (modimpl x UE) <= attention à transposer + - etud_moy_gen: Series, index etudid, valeur float (moyenne générale avant bonus) + - etud_moy_ue: DataFrame columns UE (sans sport), rows etudid (moyennes avant bonus) + + 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) + classic_use_bonus_ues = False + + # Attributs virtuels: + seuil_moy_gen = None + proportion_point = None + bonus_max = None + + name = "virtual" + + def __init__( + self, + formsemestre: FormSemestre, + sem_modimpl_moys: np.array, + ues: list, + modimpl_inscr_df: pd.DataFrame, + modimpl_coefs: np.array, + etud_moy_gen, + etud_moy_ue, + ): + self.formsemestre = formsemestre + self.ues = ues + self.etud_moy_gen = etud_moy_gen + self.etud_moy_ue = etud_moy_ue + self.etuds_idx = modimpl_inscr_df.index # les étudiants inscrits au semestre + self.bonus_ues: pd.DataFrame = None # virtual + self.bonus_moy_gen: pd.Series = None # virtual (pour formations non apc slt) + # Restreint aux modules standards des UE de type "sport": + modimpl_mask = np.array( + [ + (m.module.module_type == ModuleType.STANDARD) + and (m.module.ue.type == UE_SPORT) + for m in formsemestre.modimpls_sorted + ] + ) + self.modimpls_spo = [ + modimpl + for i, modimpl in enumerate(formsemestre.modimpls_sorted) + if modimpl_mask[i] + ] + "liste des modimpls sport" + + # Les moyennes des modules "sport": (une par UE en APC) + # donc (nb_etuds, nb_mod_sport, nb_ues_non_bonus) + sem_modimpl_moys_spo = sem_modimpl_moys[:, modimpl_mask] + # Les inscriptions aux modules sport: + modimpl_inscr_spo = modimpl_inscr_df.values[:, modimpl_mask] + # Les coefficients des modules sport (en apc: nb_mod_sport x nb_ue) (toutes ues) + modimpl_coefs_spo = modimpl_coefs[modimpl_mask] + # sem_modimpl_moys_spo est (nb_etuds, nb_mod_sport) + # ou (nb_etuds, nb_mod_sport, nb_ues_non_bonus) + nb_etuds, nb_mod_sport = sem_modimpl_moys_spo.shape[:2] + nb_ues = len(ues) + # Enlève les NaN du numérateur: + sem_modimpl_moys_no_nan = np.nan_to_num(sem_modimpl_moys_spo, nan=0.0) + + # Annule les coefs des modules où l'étudiant n'est pas inscrit: + if formsemestre.formation.is_apc(): + # BUT + nb_ues_no_bonus = sem_modimpl_moys.shape[2] + # Duplique les inscriptions sur les UEs non bonus: + modimpl_inscr_spo_stacked = np.stack( + [modimpl_inscr_spo] * nb_ues_no_bonus, axis=2 + ) + # Ne prend pas en compte les notes des étudiants non inscrits au module: + # Annule les notes: (nb_etuds, nb_mod_bonus, nb_ues_non_bonus) + sem_modimpl_moys_inscrits = np.where( + modimpl_inscr_spo_stacked, sem_modimpl_moys_no_nan, 0.0 + ) + # Annule les coefs des modules où l'étudiant n'est pas inscrit: + modimpl_coefs_etuds = np.where( + modimpl_inscr_spo_stacked, + np.stack([modimpl_coefs_spo.T] * nb_etuds), + 0.0, + ) + else: + # Formations classiques + # Ne prend pas en compte les notes des étudiants non inscrits au module: + # Annule les notes: + sem_modimpl_moys_inscrits = np.where( + modimpl_inscr_spo, sem_modimpl_moys_no_nan, 0.0 + ) + modimpl_coefs_spo = modimpl_coefs_spo.T + modimpl_coefs_etuds = np.where( + modimpl_inscr_spo, np.stack([modimpl_coefs_spo] * nb_etuds), 0.0 + ) + # Annule les coefs des modules NaN (nb_etuds x nb_mod_sport) + modimpl_coefs_etuds_no_nan = np.where( + np.isnan(sem_modimpl_moys_spo), 0.0, modimpl_coefs_etuds + ) + # + self.compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + + def compute_bonus( + self, + sem_modimpl_moys_inscrits: np.ndarray, + modimpl_coefs_etuds_no_nan: np.ndarray, + ): + """Calcul des bonus: méthode virtuelle à écraser. + Arguments: + - sem_modimpl_moys_inscrits: + ndarray (nb_etuds, mod_sport) ou en APC (nb_etuds, mods_sport, nb_ue_non_bonus) + les notes aux modules sports auxquel l'étudiant est inscrit, 0 sinon. Pas de nans. + - modimpl_coefs_etuds_no_nan: + les coefficients: float ndarray + + Résultat: None + """ + raise NotImplementedError("méthode virtuelle") + + def get_bonus_ues(self) -> pd.Series: + """Les bonus à appliquer aux UE + Résultat: DataFrame de float, index etudid, columns: ue.id + """ + if self.classic_use_bonus_ues or self.formsemestre.formation.is_apc(): + return self.bonus_ues + return None + + def get_bonus_moy_gen(self): + """Le bonus à appliquer à la moyenne générale. + Résultat: Series de float, index etudid + """ + if self.formsemestre.formation.is_apc(): + return None # garde-fou + return self.bonus_moy_gen + + +class BonusSportAdditif(BonusSport): + """Bonus sport simples calcule un bonus à partir des notes moyennes de modules + de l'UE sport, et ce bonus est soit ajouté à la moyenne générale (formations classiques), + soit ajouté à chaque UE (formations APC). + + Le bonus est par défaut calculé comme: + Les points au-dessus du seuil (par défaut) 10 sur 20 obtenus dans chacun des + modules optionnels sont cumulés et une fraction de ces points cumulés s'ajoute + à 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 + 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: + """ + 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, + 0.0, + ), + axis=1, + ) + if self.bonus_max is not None: + # Seuil: bonus limité à bonus_max points (et >= 0) + bonus_moy_arr = np.clip( + bonus_moy_arr, 0.0, self.bonus_max, out=bonus_moy_arr + ) + else: # necessaire pour éviter bonus négatifs ! + bonus_moy_arr = np.clip(bonus_moy_arr, 0.0, 20.0, out=bonus_moy_arr) + + # en APC, bonus_moy_arr est (nb_etuds, nb_ues_non_bonus) + if self.formsemestre.formation.is_apc() or self.classic_use_bonus_ues: + # 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 + ) + else: + # Bonus sur la moyenne générale seulement + self.bonus_moy_gen = pd.Series( + bonus_moy_arr, index=self.etuds_idx, dtype=float + ) + + # if len(bonus_moy_arr.shape) > 1: + # bonus_moy_arr = bonus_moy_arr.sum(axis=1) + # Laisse bonus_moy_gen à None, en APC le bonus moy. gen. sera réparti sur les UEs. + + +class BonusSportMultiplicatif(BonusSport): + """Bonus sport qui multiplie les moyennes d'UE par un facteur""" + + seuil_moy_gen = 10.0 # seuls les points au dessus du seuil sont comptés + amplitude = 0.005 # multiplie les points au dessus du seuil + # En classique, les bonus multiplicatifs agissent par défaut sur les UE: + classic_use_bonus_ues = True + + # C'est un bonus "multiplicatif": on l'exprime en additif, + # sur chaque moyenne d'UE m_0 + # Augmenter de 5% correspond à multiplier par a=1.05 + # m_1 = a . m_0 + # m_1 = m_0 + bonus + # bonus = m_0 (a - 1) + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + # Calcule moyenne pondérée des notes de sport: + notes = np.sum( + sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + notes = np.nan_to_num(notes, copy=False) + factor = (notes - self.seuil_moy_gen) * self.amplitude # 5% si note=20 + factor[factor <= 0] = 0.0 # note < seuil_moy_gen, pas de bonus + + # Ne s'applique qu'aux moyennes d'UE + if len(factor.shape) == 1: # classic + factor = factor[:, np.newaxis] + bonus = self.etud_moy_ue * factor + if self.bonus_max is not None: + # Seuil: bonus limité à bonus_max points + bonus.clip(upper=self.bonus_max, inplace=True) + + self.bonus_ues = bonus # DataFrame + + # Les bonus multiplicatifs ne s'appliquent pas à la moyenne générale + self.bonus_moy_gen = None + + +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). + """ + + name = "bonus_direct" + displayed_name = 'Bonus "direct"' + seuil_moy_gen = 0.0 # tous les points sont comptés + proportion_point = 1.0 + + +class BonusBethune(BonusSportMultiplicatif): + """Calcul bonus modules optionels (sport), règle IUT de Béthune. + + Les points au dessus de la moyenne de 10 apportent un bonus pour le semestre. + Ce bonus est égal au nombre de points divisé par 200 et multiplié par la + moyenne générale du semestre de l'étudiant. + """ + + name = "bonus_iutbethune" + displayed_name = "IUT de Béthune" + seuil_moy_gen = 10.0 + amplitude = 0.005 + + +class BonusBezier(BonusSportAdditif): + """Calcul bonus modules optionels (sport, culture), règle IUT de Bézier. + + Les étudiants de l'IUT peuvent suivre des enseignements optionnels + sport , 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 3% de ces points cumulés s'ajoutent à + la moyenne générale du semestre déjà obtenue par l'étudiant, dans + la limite de 0,3 points. + """ + + # note: cela revient à dire que l'on ajoute 5% des points au dessus de 10, + # et qu'on limite à 5% de 10, soit 0.5 points + # ce bonus est donc strictement identique à celui de St Denis (BonusIUTStDenis) + name = "bonus_iutbeziers" + displayed_name = "IUT de Bézier" + bonus_max = 0.3 + seuil_moy_gen = 10.0 # tous les points sont comptés + proportion_point = 0.03 + + +class BonusBordeaux1(BonusSportMultiplicatif): + """Calcul bonus modules optionels (sport, culture), règle IUT Bordeaux 1, sur moyenne générale + et UE. + + 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 + 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" + classic_use_bonus_ues = True # s'applique aux UEs en DUT et LP + seuil_moy_gen = 10.0 + amplitude = 0.005 + + +class BonusColmar(BonusSportAdditif): + """Calcul bonus modules optionels (sport, culture), règle IUT Colmar. + + Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'U.H.A. (sports, musique, deuxième langue, culture, 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 + dans la limite de 10 points. 5% de ces points cumulés s'ajoutent à + la moyenne générale du semestre déjà obtenue par l'étudiant. + """ + + # note: cela revient à dire que l'on ajoute 5% des points au dessus de 10, + # et qu'on limite à 5% de 10, soit 0.5 points + # ce bonus est donc strictement identique à celui de St Denis (BonusIUTStDenis) + name = "bonus_colmar" + displayed_name = "IUT de Colmar" + bonus_max = 0.5 + seuil_moy_gen = 10.0 # tous les points sont comptés + proportion_point = 0.05 + + +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) + + 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" + displayed_name = "IUT de Grenoble 1" + # C'est un bonus "multiplicatif": on l'exprime en additif, + # sur chaque moyenne d'UE m_0 + # Augmenter de 5% correspond à multiplier par a=1.05 + # m_1 = a . m_0 + # m_1 = m_0 + bonus + # bonus = m_0 (a - 1) + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus, avec réglage différent suivant la date""" + + if self.formsemestre.date_debut > datetime.date(2021, 7, 15): + self.seuil_moy_gen = 10.0 + self.amplitude = 0.005 + else: # anciens semestres + self.seuil_moy_gen = 0.0 + self.amplitude = 1 / 400.0 + + super().compute_bonus(sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan) + + +class BonusLaRochelle(BonusSportAdditif): + """Calcul bonus modules optionels (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 + + +class BonusLeHavre(BonusSportMultiplicatif): + """Bonus sport IUT du Havre sur moyenne générale et UE + + Les points des modules bonus au dessus de 10/20 sont ajoutés, + et les moyennes d'UE augmentées de 5% de ces points. + """ + + name = "bonus_iutlh" + displayed_name = "IUT du Havre" + seuil_moy_gen = 10.0 # seuls les points au dessus du seuil sont comptés + amplitude = 0.005 # multiplie les points au dessus du seuil + + +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 + optionnelles sont cumulés. + + + En BUT: la moyenne de chacune des UE du semestre est augmentée de + 2% du cumul des points de bonus, + + En DUT/LP: la moyenne générale est augmentée de 5% du cumul des points bonus. + + Dans tous les cas, le bonus est dans la limite de 0,5 point. + """ + + name = "bonus_iutlemans" + displayed_name = "IUT du Mans" + seuil_moy_gen = 10.0 # points comptés au dessus de 10. + bonus_max = 0.5 # + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + # La date du semestre ? + if self.formsemestre.formation.is_apc(): + self.proportion_point = 0.02 + else: + self.proportion_point = 0.05 + return super().compute_bonus( + sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan + ) + + +# Bonus simple, mais avec changement de paramètres en 2010 ! +class BonusLille(BonusSportAdditif): + """Calcul bonus modules optionels (sport, culture), règle IUT Villeneuve d'Ascq + + 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" + displayed_name = "IUT de Lille" + seuil_moy_gen = 10.0 # points comptés au dessus de 10. + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + # La date du semestre ? + if self.formsemestre.date_debut > datetime.date(2010, 8, 1): + self.proportion_point = 0.04 + else: + self.proportion_point = 0.02 + return super().compute_bonus( + sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan + ) + + +class BonusLyonProvisoire(BonusSportAdditif): + """Calcul bonus modules optionnels (sport, culture), règle IUT de Lyon (provisoire) + + Les points au-dessus de 10 sur 20 obtenus dans chacune des matières + optionnelles sont cumulés et 1,8% de ces points cumulés + s'ajoutent aux moyennes, dans la limite d'1/2 point. + """ + + name = "bonus_lyon_provisoire" + displayed_name = "IUT de Lyon (provisoire)" + seuil_moy_gen = 10.0 # points comptés au dessus de 10. + proportion_point = 0.018 + bonus_max = 0.5 + + +class BonusMulhouse(BonusSportAdditif): + """Calcul bonus modules optionnels (sport, culture), règle IUT de Mulhouse + + La moyenne de chacune des UE du semestre sera majorée à hauteur de + 5% du cumul des points supérieurs à 10 obtenus en matières optionnelles, + dans la limite de 0,5 point. + """ + + name = "bonus_iutmulhouse" + displayed_name = "IUT de Mulhouse" + seuil_moy_gen = 10.0 # points comptés au dessus de 10. + proportion_point = 0.05 + bonus_max = 0.5 # + + +class BonusNantes(BonusSportAdditif): + """IUT de Nantes (Septembre 2018) + + 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) + """ + + name = "bonus_nantes" + displayed_name = "IUT de Nantes" + seuil_moy_gen = 0.0 # seuls les points au dessus du seuil sont comptés + proportion_point = 1 # multiplie les points au dessus du seuil + bonus_max = 0.5 # plafonnement à 0.5 points + + +class BonusRoanne(BonusSportAdditif): + """IUT de Roanne. + + Le bonus est compris entre 0 et 0.6 points + et est toujours appliqué aux UEs. + """ + + name = "bonus_iutr" + 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 + + +class BonusStDenis(BonusSportAdditif): + """Calcul bonus modules optionels (sport, culture), règle IUT Saint-Denis + + Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université Paris 13 (sports, musique, deuxième langue, + culture, 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 5% de ces points cumulés s'ajoutent à + la moyenne générale du semestre déjà obtenue par l'étudiant, dans la limite + d'1/2 point. + """ + + name = "bonus_iut_stdenis" + displayed_name = "IUT de Saint-Denis" + bonus_max = 0.5 + + +class BonusTours(BonusDirect): + """Calcul bonus sport & culture IUT Tours. + + 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. + + Le bonus total est limité à 1 point. + """ + + name = "bonus_tours" + displayed_name = "IUT de Tours" + bonus_max = 1.0 # + seuil_moy_gen = 0.0 # seuls les points au dessus du seuil sont comptés + proportion_point = 1.0 / 40.0 + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul différencié selon le département !""" + if g.scodoc_dept == "GEII": + self.proportion_point = 1.0 / 40.0 + else: + self.proportion_point = 1.0 + return super().compute_bonus( + sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan + ) + + +class BonusVilleAvray(BonusSport): + """Bonus modules optionels (sport, culture), règle IUT Ville d'Avray. + + 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. + """ + + name = "bonus_iutva" + displayed_name = "IUT de Ville d'Avray" + + def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): + """calcul du bonus""" + # 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 + bonus_moy_arr[bonus_moy_arr >= 16.0] = 0.3 + + # 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. + + +class BonusIUTV(BonusSportAdditif): + """Calcul bonus modules optionels (sport, culture), règle IUT Villetaneuse + + Les étudiants de l'IUT peuvent suivre des enseignements optionnels + de l'Université Paris 13 (sports, musique, deuxième langue, + culture, 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 5% de ces points cumulés s'ajoutent à + la moyenne générale du semestre déjà obtenue par l'étudiant. + """ + + name = "bonus_iutv" + displayed_name = "IUT de Villetaneuse" + pass # oui, c'ets le bonus par défaut + + +def get_bonus_class_dict(start=BonusSport, d=None): + """Dictionnaire des classes de bonus + (liste les sous-classes de BonusSport ayant un nom) + Resultat: { name : class } + """ + if d is None: + d = {} + if start.name != "virtual": + d[start.name] = start + for subclass in start.__subclasses__(): + get_bonus_class_dict(subclass, d=d) + return d diff --git a/app/comp/inscr_mod.py b/app/comp/inscr_mod.py index 8a5f4bc8f1..667eacbd04 100644 --- a/app/comp/inscr_mod.py +++ b/app/comp/inscr_mod.py @@ -21,7 +21,7 @@ def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame: value: bool (0/1 inscrit ou pas) """ # méthode la moins lente: une requete par module, merge les dataframes - moduleimpl_ids = [m.id for m in formsemestre.modimpls] + moduleimpl_ids = [m.id for m in formsemestre.modimpls_sorted] etudids = [inscr.etudid for inscr in formsemestre.inscriptions] df = pd.DataFrame(index=etudids, dtype=int) for moduleimpl_id in moduleimpl_ids: @@ -35,6 +35,8 @@ def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame: dtype=int, ) df = df.merge(ins_df, how="left", left_index=True, right_index=True) + # Force columns names to integers (moduleimpl ids) + df.columns = pd.Int64Index([int(x) for x in df.columns], dtype="int") # les colonnes de df sont en float (Nan) quand il n'y a # aucun inscrit au module. df.fillna(0, inplace=True) # les non-inscrits @@ -47,10 +49,10 @@ def df_load_modimpl_inscr(formsemestre) -> pd.DataFrame: def df_load_modimpl_inscr_v0(formsemestre): # methode 0, pur SQL Alchemy, 1.5 à 2 fois plus lente - moduleimpl_ids = [m.id for m in formsemestre.modimpls] + moduleimpl_ids = [m.id for m in formsemestre.modimpls_sorted] etudids = [i.etudid for i in formsemestre.inscriptions] df = pd.DataFrame(False, columns=moduleimpl_ids, index=etudids, dtype=bool) - for modimpl in formsemestre.modimpls: + for modimpl in formsemestre.modimpls_sorted: ins_mod = df[modimpl.id] for inscr in modimpl.inscriptions: ins_mod[inscr.etudid] = True @@ -58,7 +60,7 @@ def df_load_modimpl_inscr_v0(formsemestre): def df_load_modimpl_inscr_v2(formsemestre): - moduleimpl_ids = [m.id for m in formsemestre.modimpls] + moduleimpl_ids = [m.id for m in formsemestre.modimpls_sorted] etudids = [i.etudid for i in formsemestre.inscriptions] df = pd.DataFrame(False, columns=moduleimpl_ids, index=etudids, dtype=bool) cursor = db.engine.execute( diff --git a/app/comp/moy_mod.py b/app/comp/moy_mod.py index 2fee521d30..bf4afe7a01 100644 --- a/app/comp/moy_mod.py +++ b/app/comp/moy_mod.py @@ -40,6 +40,8 @@ import pandas as pd from app import db from app.models import ModuleImpl, Evaluation, EvaluationUEPoids from app.scodoc import sco_utils as scu +from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_exceptions import ScoValueError @dataclass @@ -64,9 +66,10 @@ class ModuleImplResults: self.moduleimpl_id = moduleimpl.id self.module_id = moduleimpl.module.id self.etudids = None - "liste des étudiants inscrits au SEMESTRE" + "liste des étudiants inscrits au SEMESTRE (incluant dem et def)" + self.nb_inscrits_module = None - "nombre d'inscrits (non DEM) au module" + "nombre d'inscrits (non DEM) à ce module" self.evaluations_completes = [] "séquence de booléens, indiquant les évals à prendre en compte." self.evaluations_completes_dict = {} @@ -117,7 +120,7 @@ class ModuleImplResults: # --- Calcul nombre d'inscrits pour déterminer les évaluations "completes": # on prend les inscrits au module ET au semestre (donc sans démissionnaires) inscrits_module = {ins.etud.id for ins in moduleimpl.inscriptions}.intersection( - self.etudids + moduleimpl.formsemestre.etudids_actifs ) self.nb_inscrits_module = len(inscrits_module) @@ -125,14 +128,14 @@ class ModuleImplResults: evals_notes = pd.DataFrame(index=self.etudids, dtype=float) self.evaluations_completes = [] self.evaluations_completes_dict = {} + for evaluation in moduleimpl.evaluations: eval_df = self._load_evaluation_notes(evaluation) # is_complete ssi tous les inscrits (non dem) au semestre ont une note - # ou évaluaton déclarée "à prise en compte immédiate" - is_complete = ( - len(set(eval_df.index).intersection(self.etudids)) - == self.nb_inscrits_module - ) or evaluation.publish_incomplete # immédiate + # ou évaluation déclarée "à prise en compte immédiate" + is_complete = evaluation.publish_incomplete or ( + not (inscrits_module - set(eval_df.index)) + ) self.evaluations_completes.append(is_complete) self.evaluations_completes_dict[evaluation.id] = is_complete @@ -263,14 +266,13 @@ class ModuleImplResultsAPC(ModuleImplResults): return self.etuds_moy_module -def load_evaluations_poids( - moduleimpl_id: int, default_poids=1.0 -) -> tuple[pd.DataFrame, list]: +def load_evaluations_poids(moduleimpl_id: int) -> tuple[pd.DataFrame, list]: """Charge poids des évaluations d'un module et retourne un dataframe rows = evaluations, columns = UE, value = poids (float). Les valeurs manquantes (évaluations sans coef vers des UE) sont - remplies par default_poids. - Résultat: (evals_poids, liste de UE du semestre) + remplies: 1 si le coef de ce module dans l'UE est non nul, zéro sinon + (sauf pour module bonus, defaut à 1) + Résultat: (evals_poids, liste de UEs du semestre sauf le sport) """ modimpl: ModuleImpl = ModuleImpl.query.get(moduleimpl_id) evaluations = Evaluation.query.filter_by(moduleimpl_id=moduleimpl_id).all() @@ -281,9 +283,21 @@ def load_evaluations_poids( for ue_poids in EvaluationUEPoids.query.join( EvaluationUEPoids.evaluation ).filter_by(moduleimpl_id=moduleimpl_id): - evals_poids[ue_poids.ue_id][ue_poids.evaluation_id] = ue_poids.poids - if default_poids is not None: - evals_poids.fillna(value=default_poids, inplace=True) + 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 = 1.0 if modimpl.module.ue.type == UE_SPORT else 0.0 + + if np.isnan(evals_poids.values.flat).any(): + ue_coefs = modimpl.module.get_ue_coef_dict() + for ue in ues: + evals_poids[ue.id][evals_poids[ue.id].isna()] = ( + 1 if ue_coefs.get(ue.id, default_poids) > 0 else 0 + ) + return evals_poids, ues @@ -296,6 +310,7 @@ def moduleimpl_is_conforme( évaluations vers une UE de coefficient non nul est non nulle. Argument: evals_poids: DataFrame, colonnes: UEs, Lignes: EVALs + NB: les UEs dans evals_poids sont sans le bonus sport """ nb_evals, nb_ues = evals_poids.shape if nb_evals == 0: diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index 3c658988b0..ae167d4e77 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -38,7 +38,7 @@ def compute_sem_moys_apc( = moyenne des moyennes d'UE, pondérée par la somme de leurs coefs etud_moy_ue_df: DataFrame, colonnes ue_id, lignes etudid - modimpl_coefs_df: DataFrame, colonnes moduleimpl_id, lignes UE + modimpl_coefs_df: DataFrame, colonnes moduleimpl_id, lignes UE (sans ue bonus) Result: panda Series, index etudid, valeur float (moyenne générale) """ diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index ae8b98cca7..d7ed476671 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -36,6 +36,8 @@ from app.models import UniteEns, Module, ModuleImpl, ModuleUECoef from app.comp import moy_mod from app.models.formsemestre import FormSemestre from app.scodoc import sco_codes_parcours +from app.scodoc import sco_preferences +from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_utils import ModuleType @@ -44,10 +46,10 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data En APC, ces coefs lient les modules à chaque UE. - Résultat: (module_coefs_df, ues, modules) + Résultat: (module_coefs_df, ues_no_bonus, modules) DataFrame rows = UEs, columns = modules, value = coef. - Considère toutes les UE (sauf sport) et modules du semestre. + Considère toutes les UE sauf bonus et tous les modules du semestre. Les coefs non définis (pas en base) sont mis à zéro. Si semestre_idx None, prend toutes les UE de la formation. @@ -62,6 +64,10 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data .filter( (Module.module_type == ModuleType.RESSOURCE) | (Module.module_type == ModuleType.SAE) + | ( + (Module.ue_id == UniteEns.id) + & (UniteEns.type == sco_codes_parcours.UE_SPORT) + ) ) .order_by( Module.semestre_id, Module.module_type.desc(), Module.numero, Module.code @@ -88,7 +94,17 @@ def df_load_module_coefs(formation_id: int, semestre_idx: int = None) -> pd.Data module_coefs_df[mod_coef.module_id][mod_coef.ue_id] = mod_coef.coef # silently ignore coefs associated to other modules (ie when module_type is changed) - module_coefs_df.fillna(value=0, inplace=True) + # Initialisation des poids non fixés: + # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse + # sur toutes les UE) + default_poids = { + mod.id: 1.0 + if (mod.module_type == ModuleType.STANDARD) and (mod.ue.type == UE_SPORT) + else 0.0 + for mod in modules + } + + module_coefs_df.fillna(value=default_poids, inplace=True) return module_coefs_df, ues, modules @@ -100,15 +116,15 @@ def df_load_modimpl_coefs( Comme df_load_module_coefs mais prend seulement les UE et modules du formsemestre. - Si ues et modimpls sont None, prend tous ceux du formsemestre. + Si ues et modimpls sont None, prend tous ceux du formsemestre (sauf ue bonus). Résultat: (module_coefs_df, ues, modules) - DataFrame rows = UEs, columns = modimpl, value = coef. + DataFrame rows = UEs (sans bonus), columns = modimpl, value = coef. """ if ues is None: ues = formsemestre.query_ues().all() ue_ids = [x.id for x in ues] if modimpls is None: - modimpls = formsemestre.modimpls.all() + modimpls = formsemestre.modimpls_sorted modimpl_ids = [x.id for x in modimpls] mod2impl = {m.module.id: m.id for m in modimpls} modimpl_coefs_df = pd.DataFrame(columns=modimpl_ids, index=ue_ids, dtype=float) @@ -120,7 +136,19 @@ def df_load_modimpl_coefs( for mod_coef in mod_coefs: modimpl_coefs_df[mod2impl[mod_coef.module_id]][mod_coef.ue_id] = mod_coef.coef - modimpl_coefs_df.fillna(value=0, inplace=True) + + # Initialisation des poids non fixés: + # 0 pour modules normaux, 1. pour bonus (car par défaut, on veut qu'un bonus agisse + # sur toutes les UE) + default_poids = { + modimpl.id: 1.0 + if (modimpl.module.module_type == ModuleType.STANDARD) + and (modimpl.module.ue.type == UE_SPORT) + else 0.0 + for modimpl in formsemestre.modimpls_sorted + } + + modimpl_coefs_df.fillna(value=default_poids, inplace=True) return modimpl_coefs_df, ues, modimpls @@ -134,7 +162,7 @@ def notes_sem_assemble_cube(modimpls_notes: list[pd.DataFrame]) -> np.ndarray: assert len(modimpls_notes) modimpls_notes_arr = [df.values for df in modimpls_notes] modimpls_notes = np.stack(modimpls_notes_arr) - # passe de (mod x etud x ue) à (etud x mod x UE) + # passe de (mod x etud x ue) à (etud x mod x ue) return modimpls_notes.swapaxes(0, 1) @@ -144,10 +172,14 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: et assemble le cube. etuds: tous les inscrits au semestre (avec dem. et def.) - modimpls: _tous_ les modimpls de ce semestre - UEs: X?X voir quelles sont les UE considérées ici + modimpls: _tous_ les modimpls de ce semestre (y compris bonus sport) + UEs: toutes les UE du semestre (même si pas d'inscrits) SAUF le sport. - Resultat: + Attention: la liste des modimpls inclut les modules des UE sport, mais + elles ne sont pas dans la troisième dimension car elles n'ont pas de + "moyenne d'UE". + + Résultat: sem_cube : ndarray (etuds x modimpls x UEs) modimpls_evals_poids dict { modimpl.id : evals_poids } modimpls_results dict { modimpl.id : ModuleImplResultsAPC } @@ -155,7 +187,7 @@ def notes_sem_load_cube(formsemestre: FormSemestre) -> tuple: modimpls_results = {} modimpls_evals_poids = {} modimpls_notes = [] - for modimpl in formsemestre.modimpls: + for modimpl in formsemestre.modimpls_sorted: mod_results = moy_mod.ModuleImplResultsAPC(modimpl) evals_poids, _ = moy_mod.load_evaluations_poids(modimpl.id) etuds_moy_module = mod_results.compute_module_moy(evals_poids) @@ -194,26 +226,27 @@ def compute_ue_moys_apc( modimpls : liste des modules à considérer (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) + modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport - Resultat: DataFrame columns UE, rows etudid + Résultat: DataFrame columns UE (sans sport), rows etudid """ - nb_etuds, nb_modules, nb_ues = sem_cube.shape + nb_etuds, nb_modules, nb_ues_no_bonus = sem_cube.shape + nb_ues_tot = len(ues) assert len(modimpls) == nb_modules if nb_modules == 0 or nb_etuds == 0: return pd.DataFrame( index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index ) assert len(etuds) == nb_etuds - assert len(ues) == nb_ues assert modimpl_inscr_df.shape[0] == nb_etuds assert modimpl_inscr_df.shape[1] == nb_modules - assert modimpl_coefs_df.shape[0] == nb_ues + 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 - # Duplique les inscriptions sur les UEs: - modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues, axis=2) + + # Duplique les inscriptions sur les UEs non bonus: + modimpl_inscr_stacked = np.stack([modimpl_inscr] * nb_ues_no_bonus, axis=2) # Enlève les NaN du numérateur: # si on veut prendre en compte les modules avec notes neutralisées ? sem_cube_no_nan = np.nan_to_num(sem_cube, nan=0.0) @@ -234,7 +267,9 @@ def compute_ue_moys_apc( modimpl_coefs_etuds_no_nan * sem_cube_inscrits, axis=1 ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) return pd.DataFrame( - etud_moy_ue, index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index + etud_moy_ue, + index=modimpl_inscr_df.index, # les etudids + columns=modimpl_coefs_df.index, # les UE sans les UE bonus sport ) @@ -244,6 +279,7 @@ def compute_ue_moys_classic( ues: list, modimpl_inscr_df: pd.DataFrame, modimpl_coefs: np.array, + modimpl_mask: np.array, ) -> tuple[pd.Series, pd.DataFrame, pd.DataFrame]: """Calcul de la moyenne d'UE en mode classique. La moyenne d'UE est un nombre (note/20), ou NI ou NA ou ERR @@ -251,13 +287,19 @@ def compute_ue_moys_classic( NA pas de notes disponibles ERR erreur dans une formule utilisateur. [XXX pas encore gérées ici] - sem_matrix: notes moyennes aux modules + L'éventuel bonus sport n'est PAS appliqué ici. + + Le masque modimpl_mask est un tableau de booléens (un par modimpl) qui + permet de sélectionner un sous-ensemble de modules (SAEs, tout sauf sport, ...). + + 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) - ues : liste des UE + ues : liste des UE du semestre 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 générales: pd.Series, index etudid @@ -266,10 +308,15 @@ def compute_ue_moys_classic( les coefficients effectifs de chaque UE pour chaque étudiant (sommes de coefs de modules pris en compte) """ + # 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 - nb_ues = len(ues) - modimpl_inscr = modimpl_inscr_df.values + nb_ues = len(ues) # en comptant bonus + # 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: @@ -283,16 +330,11 @@ def compute_ue_moys_classic( modimpl_coefs_etuds_no_nan = np.where( np.isnan(sem_matrix), 0.0, modimpl_coefs_etuds ) - # Calcul des moyennes générales: - with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) - etud_moy_gen = np.sum( - modimpl_coefs_etuds_no_nan * sem_matrix_inscrits, axis=1 - ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) - etud_moy_gen_s = pd.Series(etud_moy_gen, index=modimpl_inscr_df.index) - # Calcul des moyennes d'UE + + # --------------------- Calcul des moyennes d'UE ue_modules = np.array( - [[m.module.ue == ue for m in formsemestre.modimpls] for ue in ues] - )[..., np.newaxis] + [[m.module.ue == ue for m in formsemestre.modimpls_sorted] for ue in ues] + )[..., np.newaxis][:, modimpl_mask, :] modimpl_coefs_etuds_no_nan_stacked = np.stack( [modimpl_coefs_etuds_no_nan.T] * nb_ues ) @@ -305,9 +347,35 @@ def compute_ue_moys_classic( etud_moy_ue_df = pd.DataFrame( etud_moy_ue, index=modimpl_inscr_df.index, columns=[ue.id for ue in ues] ) - etud_coef_ue_df = pd.DataFrame( - coefs.sum(axis=2).T, - index=modimpl_inscr_df.index, # etudids - columns=[ue.id for ue in ues], - ) + + # --------------------- Calcul des moyennes générales + if sco_preferences.get_preference("use_ue_coefs", formsemestre.id): + # Cas avec coefficients d'UE forcés: (on met à zéro l'UE bonus) + etud_coef_ue_df = pd.DataFrame( + {ue.id: ue.coefficient if ue.type != UE_SPORT else 0.0 for ue in ues}, + index=modimpl_inscr_df.index, + columns=[ue.id for ue in ues], + ) + # remplace NaN par zéros dans les moyennes d'UE + etud_moy_ue_df_no_nan = etud_moy_ue_df.fillna(0.0, inplace=False) + # Si on voulait annuler les coef d'UE dont la moyenne d'UE est NaN + # etud_coef_ue_df_no_nan = etud_coef_ue_df.where(etud_moy_ue_df.notna(), 0.0) + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etud_moy_gen_s = (etud_coef_ue_df * etud_moy_ue_df_no_nan).sum( + axis=1 + ) / etud_coef_ue_df.sum(axis=1) + else: + # Cas normal: pondère directement les modules + etud_coef_ue_df = pd.DataFrame( + coefs.sum(axis=2).T, + index=modimpl_inscr_df.index, # etudids + columns=[ue.id for ue in ues], + ) + with np.errstate(invalid="ignore"): # ignore les 0/0 (-> NaN) + etud_moy_gen = np.sum( + modimpl_coefs_etuds_no_nan * sem_matrix_inscrits, axis=1 + ) / np.sum(modimpl_coefs_etuds_no_nan, axis=1) + + etud_moy_gen_s = pd.Series(etud_moy_gen, index=modimpl_inscr_df.index) + return etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df diff --git a/app/comp/res_but.py b/app/comp/res_but.py index 669380a31d..4423f3fa16 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -10,6 +10,9 @@ import pandas as pd 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.scodoc.sco_codes_parcours import UE_SPORT class ResultatsSemestreBUT(NotesTableCompat): @@ -37,15 +40,25 @@ class ResultatsSemestreBUT(NotesTableCompat): ) = moy_ue.notes_sem_load_cube(self.formsemestre) self.modimpl_inscr_df = inscr_mod.df_load_modimpl_inscr(self.formsemestre) self.modimpl_coefs_df, _, _ = moy_ue.df_load_modimpl_coefs( - self.formsemestre, ues=self.ues, modimpls=self.modimpls + self.formsemestre, modimpls=self.formsemestre.modimpls_sorted ) # l'idx de la colonne du mod modimpl.id est # modimpl_coefs_df.columns.get_loc(modimpl.id) # idx de l'UE: modimpl_coefs_df.index.get_loc(ue.id) + + # Elimine les coefs des modimpl bonus sports: + modimpls_sport = [ + modimpl + 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, self.etuds, - self.modimpls, + self.formsemestre.modimpls_sorted, self.ues, self.modimpl_inscr_df, self.modimpl_coefs_df, @@ -54,6 +67,28 @@ class ResultatsSemestreBUT(NotesTableCompat): self.etud_coef_ue_df = pd.DataFrame( 1.0, index=self.etud_moy_ue.index, columns=self.etud_moy_ue.columns ) + + # --- Bonus Sport & Culture + if len(modimpls_sport) > 0: + bonus_class = ScoDocSiteConfig.get_bonus_sport_class() + if bonus_class is not None: + bonus: BonusSport = bonus_class( + self.formsemestre, + self.sem_cube, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs_df.transpose(), + self.etud_moy_gen, + self.etud_moy_ue, + ) + 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) + + # Moyenne générale indicative: + # (note: le bonus sport a déjà été appliqué aux moyenens 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 ) diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 68972ced63..8d52d0c329 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -11,7 +11,11 @@ import pandas as pd from app.comp import moy_mod, 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.formsemestre import FormSemestre +from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.sco_utils import ModuleType class ResultatsSemestreClassic(NotesTableCompat): @@ -41,11 +45,20 @@ 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] + [m.module.coefficient for m in self.formsemestre.modimpls_sorted] ) - self.modimpl_idx = {m.id: i for i, m in enumerate(self.formsemestre.modimpls)} + self.modimpl_idx = { + m.id: i for i, m in enumerate(self.formsemestre.modimpls_sorted) + } "l'idx de la colonne du mod modimpl.id est modimpl_idx[modimpl.id]" + modimpl_standards_mask = np.array( + [ + (m.module.module_type == ModuleType.STANDARD) + and (m.module.ue.type != UE_SPORT) + for m in self.formsemestre.modimpls_sorted + ] + ) ( self.etud_moy_gen, self.etud_moy_ue, @@ -56,7 +69,32 @@ class ResultatsSemestreClassic(NotesTableCompat): self.ues, self.modimpl_inscr_df, self.modimpl_coefs, + modimpl_standards_mask, ) + # --- Bonus Sport & Culture + bonus_class = ScoDocSiteConfig.get_bonus_sport_class() + if bonus_class is not None: + bonus: BonusSport = bonus_class( + self.formsemestre, + self.sem_matrix, + self.ues, + self.modimpl_inscr_df, + self.modimpl_coefs, + self.etud_moy_gen, + self.etud_moy_ue, + ) + 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) + bonus_mg = bonus.get_bonus_moy_gen() + if bonus_mg is not None: + self.etud_moy_gen += bonus_mg + self.etud_moy_gen.clip(lower=0.0, upper=20.0, inplace=True) + self.bonus = ( + bonus_mg # compat nt, utilisé pour l'afficher sur les bulletins + ) + # --- Classements: self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: @@ -85,9 +123,9 @@ class ResultatsSemestreClassic(NotesTableCompat): } -def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple: +def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple[np.ndarray, dict]: """Calcule la matrice des notes du semestre - (charge toutes les notes, calcule les moyenne des modules + (charge toutes les notes, calcule les moyennes des modules et assemble la matrice) Resultat: sem_matrix : 2d-array (etuds x modimpls) @@ -95,7 +133,7 @@ def notes_sem_load_matrix(formsemestre: FormSemestre) -> tuple: """ modimpls_results = {} modimpls_notes = [] - for modimpl in formsemestre.modimpls: + for modimpl in formsemestre.modimpls_sorted: mod_results = moy_mod.ModuleImplResultsClassic(modimpl) etuds_moy_module = mod_results.compute_module_moy() modimpls_results[modimpl.id] = mod_results diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 1ff654686c..7bc2bff2c4 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -8,7 +8,7 @@ from collections import defaultdict, Counter from functools import cached_property import numpy as np import pandas as pd -from app.comp.aux import StatsMoyenne +from app.comp.aux_stats import StatsMoyenne from app.comp.moy_mod import ModuleImplResults from app.models import FormSemestre, Identite, ModuleImpl from app.models.ues import UniteEns @@ -100,42 +100,28 @@ class ResultatsSemestre: @cached_property def ues(self) -> list[UniteEns]: - """Liste des UEs du semestre + """Liste des UEs du semestre (avec les UE bonus sport) (indices des DataFrames) """ return self.formsemestre.query_ues(with_sport=True).all() - @cached_property - def modimpls(self): - """Liste des modimpls du semestre - - triée par numéro de module en APC - - triée par numéros d'UE/matières/modules pour les formations standard. - """ - modimpls = self.formsemestre.modimpls.all() - if self.is_apc: - modimpls.sort(key=lambda m: (m.module.numero, m.module.code)) - else: - modimpls.sort( - key=lambda m: ( - m.module.ue.numero, - m.module.matiere.numero, - m.module.numero, - m.module.code, - ) - ) - return modimpls - @cached_property def ressources(self): "Liste des ressources du semestre, triées par numéro de module" return [ - m for m in self.modimpls if m.module.module_type == scu.ModuleType.RESSOURCE + m + for m in self.formsemestre.modimpls_sorted + if m.module.module_type == scu.ModuleType.RESSOURCE ] @cached_property def saes(self): "Liste des SAÉs du semestre, triées par numéro de module" - return [m for m in self.modimpls if m.module.module_type == scu.ModuleType.SAE] + return [ + m + for m in self.formsemestre.modimpls_sorted + if m.module.module_type == scu.ModuleType.SAE + ] @cached_property def ue_validables(self) -> list: @@ -144,6 +130,16 @@ class ResultatsSemestre: """ return self.formsemestre.query_ues().filter(UniteEns.type != UE_SPORT).all() + def modimpls_in_ue(self, ue_id, etudid): + """Liste des modimpl de cet ue auxquels l'étudiant est inscrit""" + # sert pour l'affichage ou non de l'UE sur le bulletin + return [ + modimpl + for modimpl in self.formsemestre.modimpls_sorted + if modimpl.module.ue.id == ue_id + and self.modimpl_inscr_df[modimpl.id][etudid] + ] + @cached_property def ue_au_dessus(self, seuil=10.0) -> pd.DataFrame: """DataFrame columns UE, rows etudid, valeurs: bool @@ -163,16 +159,20 @@ class NotesTableCompat(ResultatsSemestre): développements (API malcommode et peu efficace). """ - _cached_attrs = ResultatsSemestre._cached_attrs + () + _cached_attrs = ResultatsSemestre._cached_attrs + ( + "bonus", + "bonus_ues", + ) def __init__(self, formsemestre: FormSemestre): super().__init__(formsemestre) nb_etuds = len(self.etuds) - self.bonus = defaultdict(lambda: 0.0) # XXX TODO - self.ue_rangs = {u.id: (defaultdict(lambda: 0.0), nb_etuds) for u in self.ues} + self.bonus = None # virtuel + self.bonus_ues = None # virtuel + self.ue_rangs = {u.id: (None, nb_etuds) for u in self.ues} self.mod_rangs = { - m.id: (defaultdict(lambda: 0), nb_etuds) for m in self.modimpls + m.id: (None, nb_etuds) for m in self.formsemestre.modimpls_sorted } self.moy_min = "NA" self.moy_max = "NA" @@ -221,18 +221,26 @@ class NotesTableCompat(ResultatsSemestre): ues = [] for ue in self.formsemestre.query_ues(with_sport=not filter_sport): d = ue.to_dict() - d.update(StatsMoyenne(self.etud_moy_ue[ue.id]).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 - def get_modimpls_dict(self, ue_id=None): + def get_modimpls_dict(self, ue_id=None) -> list[dict]: """Liste des modules pour une UE (ou toutes si ue_id==None), triés par numéros (selon le type de formation) """ - if ue_id is None: - return [m.to_dict() for m in self.modimpls] - else: - return [m.to_dict() for m in self.modimpls if m.module.ue.id == ue_id] + modimpls_dict = [] + for modimpl in self.formsemestre.modimpls_sorted: + if ue_id == None or modimpl.module.ue.id == ue_id: + d = modimpl.to_dict() + # compat ScoDoc < 9.2: ajoute matières + d["mat"] = modimpl.module.matiere.to_dict() + modimpls_dict.append(d) + return modimpls_dict def get_etud_decision_sem(self, etudid: int) -> dict: """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu. @@ -261,13 +269,10 @@ class NotesTableCompat(ResultatsSemestre): return "" return ins.etat - def get_etud_moy_gen(self, etudid): # -> float | str - """Moyenne générale de cet etudiant dans ce semestre. - Prend(ra) en compte les UE capitalisées. (TODO) XXX - Si apc, moyenne indicative. - Si pas de notes: 'NA' - """ - return self.etud_moy_gen[etudid] + def get_etud_mat_moy(self, matiere_id, etudid): + """moyenne d'un étudiant dans une matière (ou NA si pas de notes)""" + # non supporté en 9.2 + return "na" def get_etud_mod_moy(self, moduleimpl_id: int, etudid: int) -> float: """La moyenne de l'étudiant dans le moduleimpl @@ -276,6 +281,14 @@ class NotesTableCompat(ResultatsSemestre): """ raise NotImplementedError() # virtual method + def get_etud_moy_gen(self, etudid): # -> float | str + """Moyenne générale de cet etudiant dans ce semestre. + Prend(ra) en compte les UE capitalisées. (TODO) XXX + Si apc, moyenne indicative. + Si pas de notes: 'NA' + """ + return self.etud_moy_gen[etudid] + def get_etud_ue_status(self, etudid: int, ue_id: int): coef_ue = self.etud_coef_ue_df[ue_id][etudid] return { @@ -359,12 +372,16 @@ class NotesTableCompat(ResultatsSemestre): moy_gen = self.etud_moy_gen.get(etudid, False) if moy_gen is False: # pas de moyenne: démissionnaire ou def - t = ["-"] + ["0.00"] * len(self.ues) + ["NI"] * len(self.modimpls) + t = ( + ["-"] + + ["0.00"] * len(self.ues) + + ["NI"] * len(self.formsemestre.modimpls_sorted) + ) else: moy_ues = self.etud_moy_ue.loc[etudid] t = [moy_gen] + list(moy_ues) # TODO UE capitalisées: ne pas afficher moyennes modules - for modimpl in self.modimpls: + for modimpl in self.formsemestre.modimpls_sorted: val = self.get_etud_mod_moy(modimpl.id, etudid) t.append(val) t.append(etudid) diff --git a/app/forms/main/config_apo.py b/app/forms/main/config_apo.py new file mode 100644 index 0000000000..6151f2f311 --- /dev/null +++ b/app/forms/main/config_apo.py @@ -0,0 +1,77 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# 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 +# +############################################################################## + +""" +Formulaires configuration Exports Apogée (codes) +""" + +from flask import flash, url_for, redirect, render_template +from flask_wtf import FlaskForm +from wtforms import SubmitField, validators +from wtforms.fields.simple import StringField + +from app import models +from app.models import ScoDocSiteConfig +from app.models import SHORT_STR_LEN + +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_utils as scu + + +def _build_code_field(code): + return StringField( + label=code, + description=sco_codes_parcours.CODES_EXPL[code], + validators=[ + validators.regexp( + r"^[A-Z0-9_]*$", + message="Ne doit comporter que majuscules et des chiffres", + ), + validators.Length( + max=SHORT_STR_LEN, + message=f"L'acronyme ne doit pas dépasser {SHORT_STR_LEN} caractères", + ), + validators.DataRequired("code requis"), + ], + ) + + +class CodesDecisionsForm(FlaskForm): + ADC = _build_code_field("ADC") + ADJ = _build_code_field("ADJ") + ADM = _build_code_field("ADM") + AJ = _build_code_field("AJ") + ATB = _build_code_field("ATB") + ATJ = _build_code_field("ATJ") + ATT = _build_code_field("ATT") + CMP = _build_code_field("CMP") + DEF = _build_code_field("DEF") + DEM = _build_code_field("DEF") + NAR = _build_code_field("NAR") + RAT = _build_code_field("RAT") + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) diff --git a/app/forms/main/config_forms.py b/app/forms/main/config_logos.py similarity index 92% rename from app/forms/main/config_forms.py rename to app/forms/main/config_logos.py index 16be845184..91a73747ae 100644 --- a/app/forms/main/config_forms.py +++ b/app/forms/main/config_logos.py @@ -47,7 +47,6 @@ from app.scodoc.sco_config_actions import ( LogoDelete, LogoUpdate, LogoInsert, - BonusSportUpdate, ) from flask_login import current_user @@ -296,23 +295,15 @@ def _make_depts_data(modele): return data -def _make_data(bonus_sport, modele): +def _make_data(modele): data = { - "bonus_sport_func_name": bonus_sport, "depts": _make_depts_data(modele=modele), } return data -class ScoDocConfigurationForm(FlaskForm): - "Panneau de configuration général" - bonus_sport_func_name = SelectField( - label="Fonction de calcul des bonus sport&culture", - choices=[ - (x, x if x else "Aucune") - for x in ScoDocSiteConfig.get_bonus_sport_func_names() - ], - ) +class LogosConfigurationForm(FlaskForm): + "Panneau de configuration des logos" depts = FieldList(FormField(DeptForm)) def __init__(self, *args, **kwargs): @@ -361,11 +352,6 @@ class ScoDocConfigurationForm(FlaskForm): return dept_form.get_form(logoname) def select_action(self): - if ( - self.data["bonus_sport_func_name"] - != ScoDocSiteConfig.get_bonus_sport_func_name() - ): - return BonusSportUpdate(self.data) for dept_entry in self.depts: dept_form = dept_entry.form action = dept_form.select_action() @@ -374,14 +360,11 @@ class ScoDocConfigurationForm(FlaskForm): return None -def configuration(): - """Panneau de configuration général""" - auth_name = str(current_user) - if not current_user.is_administrator(): - raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) - form = ScoDocConfigurationForm( +def config_logos(): + "Page de configuration des logos" + # nb: le contrôle d'accès (SuperAdmin) doit être fait dans la vue + form = LogosConfigurationForm( data=_make_data( - bonus_sport=ScoDocSiteConfig.get_bonus_sport_func_name(), modele=sco_logos.list_logos(), ) ) @@ -392,11 +375,11 @@ def configuration(): flash(action.message) return redirect( url_for( - "scodoc.configuration", + "scodoc.configure_logos", ) ) return render_template( - "configuration.html", + "config_logos.html", scodoc_dept=None, title="Configuration ScoDoc", form=form, diff --git a/app/forms/main/config_main.py b/app/forms/main/config_main.py new file mode 100644 index 0000000000..d6536a8de7 --- /dev/null +++ b/app/forms/main/config_main.py @@ -0,0 +1,76 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# ScoDoc +# +# 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 +# +############################################################################## + +""" +Formulaires configuration Exports Apogée (codes) +""" + +from flask import flash, url_for, redirect, request, render_template +from flask_wtf import FlaskForm +from wtforms import SelectField, SubmitField + +import app +from app.models import ScoDocSiteConfig + + +class ScoDocConfigurationForm(FlaskForm): + "Panneau de configuration des logos" + bonus_sport_func_name = SelectField( + label="Fonction de calcul des bonus sport&culture", + choices=[ + (name, displayed_name if name else "Aucune") + for (name, displayed_name) in ScoDocSiteConfig.get_bonus_sport_class_list() + ], + ) + submit = SubmitField("Valider") + cancel = SubmitField("Annuler", render_kw={"formnovalidate": True}) + + +def configuration(): + "Page de configuration principale" + # nb: le contrôle d'accès (SuperAdmin) doit être fait dans la vue + form = ScoDocConfigurationForm( + data={ + "bonus_sport_func_name": ScoDocSiteConfig.get_bonus_sport_class_name(), + } + ) + if request.method == "POST" and form.cancel.data: # cancel button + return redirect(url_for("scodoc.index")) + if form.validate_on_submit(): + if ( + form.data["bonus_sport_func_name"] + != ScoDocSiteConfig.get_bonus_sport_class_name() + ): + ScoDocSiteConfig.set_bonus_sport_class(form.data["bonus_sport_func_name"]) + app.clear_scodoc_cache() + flash(f"Fonction bonus sport&culture configurée.") + return redirect(url_for("scodoc.index")) + + return render_template( + "configuration.html", + form=form, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py index 642e31873e..f110849348 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -6,13 +6,12 @@ XXX version préliminaire ScoDoc8 #sco8 sans département CODE_STR_LEN = 16 # chaine pour les codes SHORT_STR_LEN = 32 # courtes chaine, eg acronymes -APO_CODE_STR_LEN = 24 # nb de car max d'un code Apogée +APO_CODE_STR_LEN = 512 # nb de car max d'un code Apogée (il peut y en avoir plusieurs) GROUPNAME_STR_LEN = 64 from app.models.raw_sql_init import create_database_functions from app.models.absences import Absence, AbsenceNotification, BilletAbsence - from app.models.departements import Departement from app.models.etudiants import ( Identite, @@ -57,7 +56,7 @@ from app.models.notes import ( NotesNotes, NotesNotesLog, ) -from app.models.preferences import ScoPreference, ScoDocSiteConfig +from app.models.preferences import ScoPreference from app.models.but_refcomp import ( ApcReferentielCompetences, @@ -65,3 +64,4 @@ from app.models.but_refcomp import ( ApcSituationPro, ApcAppCritique, ) +from app.models.config import ScoDocSiteConfig diff --git a/app/models/config.py b/app/models/config.py new file mode 100644 index 0000000000..9c9c563850 --- /dev/null +++ b/app/models/config.py @@ -0,0 +1,209 @@ +# -*- coding: UTF-8 -* + +"""Model : site config WORK IN PROGRESS #WIP +""" + +from flask import flash +from app import db, log +from app.comp import bonus_spo +from app.scodoc.sco_exceptions import ScoValueError + +from app.scodoc.sco_codes_parcours import ( + ADC, + ADJ, + ADM, + AJ, + ATB, + ATJ, + ATT, + CMP, + DEF, + DEM, + NAR, + RAT, +) + +CODES_SCODOC_TO_APO = { + ADC: "ADMC", + ADJ: "ADM", + ADM: "ADM", + AJ: "AJ", + ATB: "AJAC", + ATJ: "AJAC", + ATT: "AJAC", + CMP: "COMP", + DEF: "NAR", + DEM: "NAR", + NAR: "NAR", + RAT: "ATT", +} + + +def code_scodoc_to_apo_default(code): + """Conversion code jury ScoDoc en code Apogée + (codes par défaut, c'est configurable via ScoDocSiteConfig.get_code_apo) + """ + return CODES_SCODOC_TO_APO.get(code, "DEF") + + +class ScoDocSiteConfig(db.Model): + """Config. d'un site + Nouveau en ScoDoc 9: va regrouper les paramètres qui dans les versions + antérieures étaient dans scodoc_config.py + """ + + __tablename__ = "scodoc_site_config" + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(128), nullable=False, index=True) + value = db.Column(db.Text()) + + BONUS_SPORT = "bonus_sport_func_name" + NAMES = { + BONUS_SPORT: str, + "always_require_ine": bool, + "SCOLAR_FONT": str, + "SCOLAR_FONT_SIZE": str, + "SCOLAR_FONT_SIZE_FOOT": str, + "INSTITUTION_NAME": str, + "INSTITUTION_ADDRESS": str, + "INSTITUTION_CITY": str, + "DEFAULT_PDF_FOOTER_TEMPLATE": str, + } + + def __init__(self, name, value): + self.name = name + self.value = value + + def __repr__(self): + return f"<{self.__class__.__name__}('{self.name}', '{self.value}')>" + + @classmethod + def get_dict(cls) -> dict: + "Returns all data as a dict name = value" + return { + c.name: cls.NAMES.get(c.name, lambda x: x)(c.value) + for c in ScoDocSiteConfig.query.all() + } + + @classmethod + def set_bonus_sport_class(cls, class_name): + """Record bonus_sport config. + If class_name not defined, raise NameError + """ + if class_name not in cls.get_bonus_sport_class_names(): + raise NameError("invalid class name for bonus_sport") + c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() + if c: + log("setting to " + class_name) + c.value = class_name + else: + c = ScoDocSiteConfig(cls.BONUS_SPORT, class_name) + db.session.add(c) + db.session.commit() + + @classmethod + def get_bonus_sport_class_name(cls): + """Get configured bonus function name, or None if None.""" + klass = cls.get_bonus_sport_class_from_name() + if klass is None: + return "" + else: + return klass.name + + @classmethod + def get_bonus_sport_class(cls): + """Get configured bonus function, or None if None.""" + return cls.get_bonus_sport_class_from_name() + + @classmethod + def get_bonus_sport_class_from_name(cls, class_name=None): + """returns bonus class with specified name. + If name not specified, return the configured function. + None if no bonus function configured. + If class_name not found in module bonus_sport, returns None + and flash a warning. + """ + if not class_name: # None or "" + c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() + if c is None: + return None + class_name = c.value + if class_name == "": # pas de bonus défini + return None + klass = bonus_spo.get_bonus_class_dict().get(class_name) + if klass is None: + flash( + f"""Fonction de calcul bonus sport inexistante: {class_name}. + Changez là ou contactez votre administrateur local.""" + ) + return klass + + @classmethod + def get_bonus_sport_class_names(cls) -> list: + """List available bonus class names + (starting with empty string to represent "no bonus function"). + """ + return [""] + sorted(bonus_spo.get_bonus_class_dict().keys()) + + @classmethod + def get_bonus_sport_class_list(cls) -> list[tuple]: + """List available bonus class names + (starting with empty string to represent "no bonus function"). + """ + d = bonus_spo.get_bonus_class_dict() + class_list = [(name, d[name].displayed_name) for name in d.keys()] + class_list.sort(key=lambda x: x[1].replace(" du ", " de ")) + return [("", "")] + class_list + + @classmethod + def get_bonus_sport_func(cls): + """Fonction bonus_sport ScoDoc 7 XXX + Transitoire pour les tests durant la transition #sco92 + """ + """returns bonus func with specified name. + If name not specified, return the configured function. + None if no bonus function configured. + Raises ScoValueError if func_name not found in module bonus_sport. + """ + from app.scodoc import bonus_sport + + c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() + if c is None: + return None + func_name = c.value + if func_name == "": # pas de bonus défini + return None + try: + return getattr(bonus_sport, func_name) + except AttributeError: + raise ScoValueError( + f"""Fonction de calcul maison inexistante: {func_name}. + (contacter votre administrateur local).""" + ) + + @classmethod + def get_code_apo(cls, code: str) -> str: + """La représentation d'un code pour les exports Apogée. + Par exemple, à l'iUT du H., le code ADM est réprésenté par VAL + Les codes par défaut sont donnés dans sco_apogee_csv. + + """ + cfg = ScoDocSiteConfig.query.filter_by(name=code).first() + if not cfg: + code_apo = code_scodoc_to_apo_default(code) + else: + code_apo = cfg.value + return code_apo + + @classmethod + def set_code_apo(cls, code: str, code_apo: str): + """Enregistre nouvelle représentation du code""" + if code_apo != cls.get_code_apo(code): + cfg = ScoDocSiteConfig.query.filter_by(name=code).first() + if cfg is None: + cfg = ScoDocSiteConfig(code, code_apo) + else: + cfg.value = code_apo + db.session.add(cfg) + db.session.commit() diff --git a/app/models/evaluations.py b/app/models/evaluations.py index 4f06fb75f4..b4e5f4e2fc 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -103,7 +103,16 @@ class Evaluation(db.Model): Note: si les poids ne sont pas initialisés (poids par défaut), ils ne sont pas affichés. """ - return ", ".join([f"{p.ue.acronyme}: {p.poids}" for p in self.ue_poids]) + # restreint aux UE du semestre dans lequel est cette évaluation + # au cas où le module ait changé de semestre et qu'il reste des poids + evaluation_semestre_idx = self.moduleimpl.module.semestre_id + return ", ".join( + [ + f"{p.ue.acronyme}: {p.poids}" + for p in self.ue_poids + if evaluation_semestre_idx == p.ue.semestre_idx + ] + ) class EvaluationUEPoids(db.Model): diff --git a/app/models/formations.py b/app/models/formations.py index e2273c3b65..edd57097d0 100644 --- a/app/models/formations.py +++ b/app/models/formations.py @@ -1,6 +1,7 @@ """ScoDoc 9 models : Formations """ +import app from app import db from app.comp import df_cache from app.models import SHORT_STR_LEN @@ -141,8 +142,7 @@ class Formation(db.Model): db.session.add(ue) db.session.commit() - if change: - self.invalidate_module_coefs() + app.clear_scodoc_cache() class Matiere(db.Model): @@ -161,3 +161,16 @@ class Matiere(db.Model): numero = db.Column(db.Integer) # ordre de présentation modules = db.relationship("Module", lazy="dynamic", backref="matiere") + + def __repr__(self): + return f"""<{self.__class__.__name__}(id={self.id}, ue_id={ + self.ue_id}, titre='{self.titre}')>""" + + def to_dict(self): + """as a dict, with the same conversions as in ScoDoc7""" + 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 + return e diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index a74f1671d1..d059701aed 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -49,7 +49,7 @@ class FormSemestre(db.Model): gestion_compensation = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) - # ne publie pas le bulletin XML: + # ne publie pas le bulletin XML ou JSON: bul_hide_xml = db.Column( db.Boolean(), nullable=False, default=False, server_default="false" ) @@ -112,6 +112,9 @@ class FormSemestre(db.Model): if self.modalite is None: self.modalite = FormationModalite.DEFAULT_MODALITE + def __repr__(self): + return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>" + def to_dict(self): d = dict(self.__dict__) d.pop("_sa_instance_state", None) @@ -152,6 +155,28 @@ class FormSemestre(db.Model): sem_ues = sem_ues.filter(UniteEns.type != sco_codes_parcours.UE_SPORT) return sem_ues.order_by(UniteEns.numero) + @cached_property + def modimpls_sorted(self) -> list[ModuleImpl]: + """Liste des modimpls du semestre + - triée par type/numéro/code en APC + - triée par numéros d'UE/matières/modules pour les formations standard. + """ + modimpls = self.modimpls.all() + if self.formation.is_apc(): + modimpls.sort( + key=lambda m: (m.module.module_type, m.module.numero, m.module.code) + ) + else: + modimpls.sort( + key=lambda m: ( + m.module.ue.numero, + m.module.matiere.numero, + m.module.numero, + m.module.code, + ) + ) + return modimpls + def est_courant(self) -> bool: """Vrai si la date actuelle (now) est dans le semestre (les dates de début et fin sont incluses) @@ -262,7 +287,7 @@ class FormSemestre(db.Model): self.date_fin.year})""" def titre_num(self) -> str: - """Le titre est le semestre, ex ""DUT Informatique semestre 2"" """ + """Le titre et le semestre, ex ""DUT Informatique semestre 2"" """ if self.semestre_id == sco_codes_parcours.NO_SEMESTRE_ID: return self.titre return f"{self.titre} {self.formation.get_parcours().SESSION_NAME} {self.semestre_id}" @@ -288,6 +313,11 @@ class FormSemestre(db.Model): else: return [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] + @cached_property + def etudids_actifs(self) -> set: + "Set des etudids inscrits non démissionnaires" + return {ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT} + @cached_property def etuds_inscriptions(self) -> dict: """Map { etudid : inscription } (incluant DEM et DEF)""" diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index 2aa36da9cb..d51a620b08 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -5,7 +5,7 @@ import pandas as pd from app import db from app.comp import df_cache -from app.models import UniteEns, Identite +from app.models import Identite, Module import app.scodoc.notesdb as ndb from app.scodoc import sco_utils as scu @@ -127,3 +127,16 @@ class ModuleImplInscription(db.Model): ModuleImpl, backref=db.backref("inscriptions", cascade="all, delete-orphan"), ) + + @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 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() diff --git a/app/models/modules.py b/app/models/modules.py index 24e552461d..ac82b127e5 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -4,6 +4,7 @@ from app import db from app.models import APO_CODE_STR_LEN from app.scodoc import sco_utils as scu +from app.scodoc.sco_codes_parcours import UE_SPORT from app.scodoc.sco_utils import ModuleType @@ -131,7 +132,8 @@ class Module(db.Model): def ue_coefs_list(self, include_zeros=True): """Liste des coefs vers les UE (pour les modules APC). - Si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre. + Si include_zeros, liste aussi les UE sans coef (donc nul) de ce semestre, + sauf UE bonus sport. Result: List of tuples [ (ue, coef) ] """ if not self.is_apc(): @@ -140,6 +142,7 @@ class Module(db.Model): # Toutes les UE du même semestre: ues_semestre = ( self.formation.ues.filter_by(semestre_idx=self.ue.semestre_idx) + .filter(UniteEns.type != UE_SPORT) .order_by(UniteEns.numero) .all() ) diff --git a/app/models/preferences.py b/app/models/preferences.py index 59c82ec805..924f6e604f 100644 --- a/app/models/preferences.py +++ b/app/models/preferences.py @@ -2,9 +2,8 @@ """Model : preferences """ -from app import db, log -from app.scodoc import bonus_sport -from app.scodoc.sco_exceptions import ScoValueError + +from app import db class ScoPreference(db.Model): @@ -19,108 +18,3 @@ class ScoPreference(db.Model): name = db.Column(db.String(128), nullable=False, index=True) value = db.Column(db.Text()) formsemestre_id = db.Column(db.Integer, db.ForeignKey("notes_formsemestre.id")) - - -class ScoDocSiteConfig(db.Model): - """Config. d'un site - Nouveau en ScoDoc 9: va regrouper les paramètres qui dans les versions - antérieures étaient dans scodoc_config.py - """ - - __tablename__ = "scodoc_site_config" - - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(128), nullable=False, index=True) - value = db.Column(db.Text()) - - BONUS_SPORT = "bonus_sport_func_name" - NAMES = { - BONUS_SPORT: str, - "always_require_ine": bool, - "SCOLAR_FONT": str, - "SCOLAR_FONT_SIZE": str, - "SCOLAR_FONT_SIZE_FOOT": str, - "INSTITUTION_NAME": str, - "INSTITUTION_ADDRESS": str, - "INSTITUTION_CITY": str, - "DEFAULT_PDF_FOOTER_TEMPLATE": str, - } - - def __init__(self, name, value): - self.name = name - self.value = value - - def __repr__(self): - return f"<{self.__class__.__name__}('{self.name}', '{self.value}')>" - - def get_dict(self) -> dict: - "Returns all data as a dict name = value" - return { - c.name: self.NAMES.get(c.name, lambda x: x)(c.value) - for c in ScoDocSiteConfig.query.all() - } - - @classmethod - def set_bonus_sport_func(cls, func_name): - """Record bonus_sport config. - If func_name not defined, raise NameError - """ - if func_name not in cls.get_bonus_sport_func_names(): - raise NameError("invalid function name for bonus_sport") - c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() - if c: - log("setting to " + func_name) - c.value = func_name - else: - c = ScoDocSiteConfig(cls.BONUS_SPORT, func_name) - db.session.add(c) - db.session.commit() - - @classmethod - def get_bonus_sport_func_name(cls): - """Get configured bonus function name, or None if None.""" - f = cls.get_bonus_sport_func_from_name() - if f is None: - return "" - else: - return f.__name__ - - @classmethod - def get_bonus_sport_func(cls): - """Get configured bonus function, or None if None.""" - return cls.get_bonus_sport_func_from_name() - - @classmethod - def get_bonus_sport_func_from_name(cls, func_name=None): - """returns bonus func with specified name. - If name not specified, return the configured function. - None if no bonus function configured. - Raises ScoValueError if func_name not found in module bonus_sport. - """ - if func_name is None: - c = ScoDocSiteConfig.query.filter_by(name=cls.BONUS_SPORT).first() - if c is None: - return None - func_name = c.value - if func_name == "": # pas de bonus défini - return None - try: - return getattr(bonus_sport, func_name) - except AttributeError: - raise ScoValueError( - f"""Fonction de calcul maison inexistante: {func_name}. - (contacter votre administrateur local).""" - ) - - @classmethod - def get_bonus_sport_func_names(cls): - """List available functions names - (starting with empty string to represent "no bonus function"). - """ - return [""] + sorted( - [ - getattr(bonus_sport, name).__name__ - for name in dir(bonus_sport) - if name.startswith("bonus_") - ] - ) diff --git a/app/models/ues.py b/app/models/ues.py index 26223eefc7..3497414c0e 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -41,6 +41,8 @@ class UniteEns(db.Model): # coef UE, utilise seulement si l'option use_ue_coefs est activée: coefficient = db.Column(db.Float) + color = db.Column(db.Text()) + # relations matieres = db.relationship("Matiere", lazy="dynamic", backref="ue") modules = db.relationship("Module", lazy="dynamic", backref="ue") diff --git a/app/pe/pe_tools.py b/app/pe/pe_tools.py index a495f96509..5f58428bb9 100644 --- a/app/pe/pe_tools.py +++ b/app/pe/pe_tools.py @@ -35,13 +35,15 @@ Created on Thu Sep 8 09:36:33 2016 @author: barasc """ -from __future__ import print_function import os import datetime import re import unicodedata + +from flask import g + import app.scodoc.sco_utils as scu from app import log from app.scodoc.sco_logos import find_logo @@ -54,7 +56,6 @@ if not PE_DEBUG: # kw is ignored. log always add a newline log(" ".join(a)) - else: pe_print = print # print function @@ -206,7 +207,9 @@ def add_pe_stuff_to_zip(zipfile, ziproot): for name in logos_names: logo = find_logo(logoname=name, dept_id=g.scodoc_dept_id) if logo is not None: - add_local_file_to_zip(zipfile, ziproot, logo, "avis/logos/" + logo.filename) + add_local_file_to_zip( + zipfile, ziproot, logo.filepath, "avis/logos/" + logo.filename + ) # ---------------------------------------------------------------------------------------- diff --git a/app/pe/pe_view.py b/app/pe/pe_view.py index 43a00ebe56..5a98d375fb 100644 --- a/app/pe/pe_view.py +++ b/app/pe/pe_view.py @@ -97,7 +97,7 @@ def pe_view_sem_recap( template_latex = "" # template fourni via le formulaire Web if avis_tmpl_file: - template_latex = avis_tmpl_file.read() + template_latex = avis_tmpl_file.read().decode('utf-8') template_latex = template_latex else: # template indiqué dans préférences ScoDoc ? @@ -114,7 +114,7 @@ def pe_view_sem_recap( footer_latex = "" # template fourni via le formulaire Web if footer_tmpl_file: - footer_latex = footer_tmpl_file.read() + 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/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index 722b7ec9a6..23b500393f 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -73,7 +73,8 @@ def TrivialFormulator( input_type : 'text', 'textarea', 'password', 'radio', 'menu', 'checkbox', 'hidden', 'separator', 'file', 'date', 'datedmy' (avec validation), - 'boolcheckbox', 'text_suggest' + 'boolcheckbox', 'text_suggest', + 'color' (default text) size : text field width rows, cols: textarea geometry @@ -594,6 +595,11 @@ class TF(object): var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); """ ) + elif input_type == "color": + lem.append( + '') % values) else: raise ValueError("unkown input_type for form (%s)!" % input_type) explanation = descr.get("explanation", "") @@ -712,7 +718,11 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts); R.append("%s" % title) R.append('
Formation %(titre)s (%(acronyme)s), version %(version)s, code %(formation_code)s
' - % F - + msg - + str(tf[1]) - ) + return f"""Formation {formation.titre} ({formation.acronyme}), version {formation.version}, code {formation.formation_code} +
+ {msg} + {tf[1]} + """ elif tf[0] == -1: return "