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('' % klass) - if input_type == "text" or input_type == "text_suggest": + if ( + input_type == "text" + or input_type == "text_suggest" + or input_type == "color" + ): R.append(("%(" + field + ")s") % self.values) elif input_type in ("radio", "menu", "checkbox", "boolcheckbox"): if input_type == "boolcheckbox": diff --git a/app/scodoc/bonus_sport.py b/app/scodoc/bonus_sport.py index 75b08b5010..5fc3b8b4d1 100644 --- a/app/scodoc/bonus_sport.py +++ b/app/scodoc/bonus_sport.py @@ -28,10 +28,12 @@ from operator import mul import pprint -""" +""" ANCIENS BONUS SPORT pour ScoDoc < 9.2 NON UTILISES A PARTIR DE 9.2 (voir comp/bonus_spo.py) + La fonction bonus_sport reçoit: - - notes_sport: la liste des notes des modules de sport et culture (une note par module de l'UE de type sport/culture); + - notes_sport: la liste des notes des modules de sport et culture (une note par module + de l'UE de type sport/culture, toujours dans remise sur 20); - coefs: un coef (float) pondérant chaque note (la plupart des bonus les ignorent); - infos: dictionnaire avec des données pouvant être utilisées pour les calculs. Ces données dépendent du type de formation. @@ -77,7 +79,6 @@ def bonus_iutv(notes_sport, coefs, infos=None): 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. """ - # breakpoint() bonus = sum([(x - 10) / 20.0 for x in notes_sport if x > 10]) return bonus @@ -91,7 +92,7 @@ def bonus_direct(notes_sport, coefs, infos=None): def bonus_iut_stdenis(notes_sport, coefs, infos=None): - """Semblable à bonus_iutv mais sans coefficients et total limité à 0.5 points.""" + """Semblable à bonus_iutv mais total limité à 0.5 points.""" points = sum([x - 10 for x in notes_sport if x > 10]) # points au dessus de 10 bonus = points * 0.05 # ou / 20 return min(bonus, 0.5) # bonus limité à 1/2 point @@ -374,7 +375,7 @@ def bonus_iutBordeaux1(notes_sport, coefs, infos=None): return bonus -def bonus_iuto(notes_sport, coefs, infos=None): +def bonus_iuto(notes_sport, coefs, infos=None): # OBSOLETE => EN ATTENTE (27/01/2022) """Calcul bonus modules optionels (sport, culture), règle IUT Orleans * Avant aout 2013 Un bonus de 2,5% de la note de sport est accordé à chaque UE sauf diff --git a/app/scodoc/htmlutils.py b/app/scodoc/htmlutils.py index 3306b80d10..65101f1f4a 100644 --- a/app/scodoc/htmlutils.py +++ b/app/scodoc/htmlutils.py @@ -29,6 +29,7 @@ """ from html.parser import HTMLParser from html.entities import name2codepoint +from multiprocessing.sharedctypes import Value import re from flask import g, url_for @@ -36,17 +37,23 @@ from flask import g, url_for from . import listhistogram -def horizontal_bargraph(value, mark): +def horizontal_bargraph(value, mark) -> str: """html drawing an horizontal bar and a mark used to vizualize the relative level of a student """ - tmpl = """ + try: + vals = {"value": int(value), "mark": int(mark)} + except ValueError: + return "" + return ( + """ """ - return tmpl % {"value": int(value), "mark": int(mark)} + % vals + ) def histogram_notes(notes): diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index eec178f143..07cbd13365 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -170,7 +170,7 @@ class NotesTable: """ def __init__(self, formsemestre_id): - log(f"NotesTable( formsemestre_id={formsemestre_id} )") + # log(f"NotesTable( formsemestre_id={formsemestre_id} )") # raise NotImplementedError() # XXX if not formsemestre_id: raise ValueError("invalid formsemestre_id (%s)" % formsemestre_id) @@ -788,7 +788,12 @@ class NotesTable: moy_ue_cap = ue_cap["moy"] mu["was_capitalized"] = True event_date = event_date or ue_cap["event_date"] - if (moy_ue_cap != "NA") and (moy_ue_cap > max_moy_ue): + if ( + (moy_ue_cap != "NA") + and isinstance(moy_ue_cap, float) + and isinstance(max_moy_ue, float) + and (moy_ue_cap > max_moy_ue) + ): # meilleure UE capitalisée event_date = ue_cap["event_date"] max_moy_ue = moy_ue_cap @@ -909,6 +914,7 @@ class NotesTable: if len(coefs_bonus_gen) == 1: coefs_bonus_gen = [1.0] # irrelevant, may be zero + # XXX attention: utilise anciens bonus_sport, évidemment bonus_func = ScoDocSiteConfig.get_bonus_sport_func() if bonus_func: bonus = bonus_func( @@ -1328,7 +1334,11 @@ class NotesTable: t[0] = results.etud_moy_gen[etudid] for i, ue in enumerate(ues, start=1): if ue["type"] != UE_SPORT: - t[i] = results.etud_moy_ue[ue["id"]][etudid] + # temporaire pour 9.1.29 ! + if ue["id"] in results.etud_moy_ue: + t[i] = results.etud_moy_ue[ue["id"]][etudid] + else: + t[i] = "" # re-trie selon la nouvelle moyenne générale: self.T.sort(key=self._row_key) # Remplace aussi le rang: diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index 8fad07dd41..565d1168b2 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -95,30 +95,21 @@ from flask import send_file # Pour la détection auto de l'encodage des fichiers Apogée: from chardet import detect as chardet_detect +from app.models.config import ScoDocSiteConfig import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb from app import log from app.scodoc.sco_exceptions import ScoValueError, ScoFormatError from app.scodoc.gen_tables import GenTable from app.scodoc.sco_vdi import ApoEtapeVDI from app.scodoc.sco_codes_parcours import code_semestre_validant from app.scodoc.sco_codes_parcours import ( - ADC, - ADJ, - ADM, - AJ, - ATB, - ATJ, - ATT, - CMP, DEF, + DEM, NAR, RAT, ) from app.scodoc import sco_cache -from app.scodoc import sco_codes_parcours from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_status from app.scodoc import sco_parcours_dut from app.scodoc import sco_etud @@ -132,24 +123,6 @@ APO_SEP = "\t" APO_NEWLINE = "\r\n" -def code_scodoc_to_apo(code): - """Conversion code jury ScoDoc en code Apogée""" - return { - ATT: "AJAC", - ATB: "AJAC", - ATJ: "AJAC", - ADM: "ADM", - ADJ: "ADM", - ADC: "ADMC", - AJ: "AJ", - CMP: "COMP", - "DEM": "NAR", - DEF: "NAR", - NAR: "NAR", - RAT: "ATT", - }.get(code, "DEF") - - def _apo_fmt_note(note): "Formatte une note pour Apogée (séparateur décimal: ',')" if not note and isinstance(note, float): @@ -449,7 +422,7 @@ class ApoEtud(dict): N=_apo_fmt_note(ue_status["moy"]), B=20, J="", - R=code_scodoc_to_apo(code_decision_ue), + R=ScoDocSiteConfig.get_code_apo(code_decision_ue), M="", ) else: @@ -475,13 +448,9 @@ class ApoEtud(dict): def comp_elt_semestre(self, nt, decision, etudid): """Calcul résultat apo semestre""" # resultat du semestre - decision_apo = code_scodoc_to_apo(decision["code"]) + decision_apo = ScoDocSiteConfig.get_code_apo(decision["code"]) note = nt.get_etud_moy_gen(etudid) - if ( - decision_apo == "DEF" - or decision["code"] == "DEM" - or decision["code"] == DEF - ): + if decision_apo == "DEF" or decision["code"] == DEM or decision["code"] == DEF: note_str = "0,01" # note non nulle pour les démissionnaires else: note_str = _apo_fmt_note(note) @@ -520,21 +489,21 @@ class ApoEtud(dict): # ou jury intermediaire et etudiant non redoublant... return self.comp_elt_semestre(cur_nt, cur_decision, etudid) - decision_apo = code_scodoc_to_apo(cur_decision["code"]) + decision_apo = ScoDocSiteConfig.get_code_apo(cur_decision["code"]) autre_nt = sco_cache.NotesTableCache.get(autre_sem["formsemestre_id"]) autre_decision = autre_nt.get_etud_decision_sem(etudid) if not autre_decision: # pas de decision dans l'autre => pas de résultat annuel return VOID_APO_RES - autre_decision_apo = code_scodoc_to_apo(autre_decision["code"]) + autre_decision_apo = ScoDocSiteConfig.get_code_apo(autre_decision["code"]) if ( autre_decision_apo == "DEF" - or autre_decision["code"] == "DEM" + or autre_decision["code"] == DEM or autre_decision["code"] == DEF ) or ( decision_apo == "DEF" - or cur_decision["code"] == "DEM" + or cur_decision["code"] == DEM or cur_decision["code"] == DEF ): note_str = "0,01" # note non nulle pour les démissionnaires diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index 1941d051fe..23833f88ca 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -43,6 +43,7 @@ from flask import g, request from flask import url_for from flask_login import current_user from flask_mail import Message +from app.models.moduleimpls import ModuleImplInscription import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType @@ -285,21 +286,34 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): else: I["rang_nt"], I["rang_txt"] = "", "" I["note_max"] = 20.0 # notes toujours sur 20 - I["bonus_sport_culture"] = nt.bonus[etudid] + I["bonus_sport_culture"] = nt.bonus[etudid] if nt.bonus is not None else 0.0 # Liste les UE / modules /evals I["ues"] = [] I["matieres_modules"] = {} I["matieres_modules_capitalized"] = {} for ue in ues: + if ( + ModuleImplInscription.nb_inscriptions_dans_ue( + formsemestre_id, etudid, ue["ue_id"] + ) + == 0 + ): + 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"]) else: - x = scu.fmt_note(nt.bonus[etudid], keep_numeric=True) + if nt.bonus is not None: + x = scu.fmt_note(nt.bonus[etudid], keep_numeric=True) + else: + x = "" if isinstance(x, str): - u["cur_moy_ue_txt"] = "pas de bonus" + if nt.bonus_ues is None: + u["cur_moy_ue_txt"] = "pas de bonus" + else: + u["cur_moy_ue_txt"] = "bonus appliqué sur les UEs" else: u["cur_moy_ue_txt"] = "bonus de %.3g points" % x u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"]) @@ -369,7 +383,7 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): ) else: if prefs["bul_show_ue_rangs"] and ue["type"] != sco_codes_parcours.UE_SPORT: - if ue_attente: # nt.get_moduleimpls_attente(): + if ue_attente or nt.ue_rangs[ue["ue_id"]][0] is None: u["ue_descr_txt"] = "%s/%s" % ( scu.RANG_ATTENTE_STR, nt.ue_rangs[ue["ue_id"]][1], @@ -387,8 +401,8 @@ def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): I["ues"].append(u) # ne montre pas les UE si non inscrit # Accès par matieres - # voir si on supporte encore cela en #sco92 XXX - # I["matieres_modules"].update(_sort_mod_by_matiere(modules, nt, etudid)) + # En #sco92, pas d'information + I["matieres_modules"].update(_sort_mod_by_matiere(modules, nt, etudid)) # C = make_context_dict(I["sem"], I["etud"]) @@ -605,12 +619,15 @@ def _ue_mod_bulletin(etudid, formsemestre_id, ue_id, modimpls, nt, version): # Classement if bul_show_mod_rangs and mod["mod_moy_txt"] != "-" and not is_malus: rg = nt.mod_rangs[modimpl["moduleimpl_id"]] - if mod_attente: # nt.get_moduleimpls_attente(): - mod["mod_rang"] = scu.RANG_ATTENTE_STR + if rg[0] is None: + mod["mod_rang_txt"] = "" else: - mod["mod_rang"] = rg[0][etudid] - mod["mod_eff"] = rg[1] # effectif dans ce module - mod["mod_rang_txt"] = "%s/%s" % (mod["mod_rang"], mod["mod_eff"]) + if mod_attente: # nt.get_moduleimpls_attente(): + mod["mod_rang"] = scu.RANG_ATTENTE_STR + else: + mod["mod_rang"] = rg[0][etudid] + mod["mod_eff"] = rg[1] # effectif dans ce module + mod["mod_rang_txt"] = "%s/%s" % (mod["mod_rang"], mod["mod_eff"]) else: mod["mod_rang_txt"] = "" if mod_attente: diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py index 5a6d542d04..31271822de 100644 --- a/app/scodoc/sco_bulletins_json.py +++ b/app/scodoc/sco_bulletins_json.py @@ -98,9 +98,9 @@ def formsemestre_bulletinetud_published_dict( d = {} if (not sem["bul_hide_xml"]) or force_publishing: - published = 1 + published = True else: - published = 0 + published = False if xml_nodate: docdate = "" else: @@ -192,7 +192,9 @@ def formsemestre_bulletinetud_published_dict( ) d["note_max"] = dict(value=20) # notes toujours sur 20 - d["bonus_sport_culture"] = dict(value=nt.bonus[etudid]) + d["bonus_sport_culture"] = dict( + value=nt.bonus[etudid] if nt.bonus is not None else 0.0 + ) # Liste les UE / modules /evals d["ue"] = [] diff --git a/app/scodoc/sco_bulletins_xml.py b/app/scodoc/sco_bulletins_xml.py index aa2b95775b..5d6ba7def8 100644 --- a/app/scodoc/sco_bulletins_xml.py +++ b/app/scodoc/sco_bulletins_xml.py @@ -195,7 +195,12 @@ def make_xml_formsemestre_bulletinetud( ) ) doc.append(Element("note_max", value="20")) # notes toujours sur 20 - doc.append(Element("bonus_sport_culture", value=str(nt.bonus[etudid]))) + doc.append( + Element( + "bonus_sport_culture", + value=str(nt.bonus[etudid] if nt.bonus is not None else 0.0), + ) + ) # Liste les UE / modules /evals for ue in ues: ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) @@ -211,7 +216,7 @@ def make_xml_formsemestre_bulletinetud( if ue["type"] != sco_codes_parcours.UE_SPORT: v = ue_status["cur_moy_ue"] else: - v = nt.bonus[etudid] + v = nt.bonus[etudid] if nt.bonus is not None else 0.0 x_ue.append( Element( "note", diff --git a/app/scodoc/sco_cache.py b/app/scodoc/sco_cache.py index 17ddd09b62..59ebab2d5b 100644 --- a/app/scodoc/sco_cache.py +++ b/app/scodoc/sco_cache.py @@ -98,8 +98,9 @@ class ScoDocCache: status = CACHE.set(key, value, timeout=cls.timeout) if not status: log("Error: cache set failed !") - except: + except Exception as exc: log("XXX CACHE Warning: error in set !!!") + log(exc) status = None return status diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py index 4ff29bb79c..6bcb8cc32c 100644 --- a/app/scodoc/sco_codes_parcours.py +++ b/app/scodoc/sco_codes_parcours.py @@ -125,6 +125,7 @@ CMP = "CMP" # utile pour UE seulement (indique UE acquise car semestre acquis) NAR = "NAR" RAT = "RAT" # en attente rattrapage, sera ATT dans Apogée DEF = "DEF" # défaillance (n'est pas un code jury dans scodoc mais un état, comme inscrit ou demission) +DEM = "DEM" # codes actions REDOANNEE = "REDOANNEE" # redouble annee (va en Sn-1) @@ -140,22 +141,26 @@ BUG = "BUG" ALL = "ALL" +# Explication des codes (de demestre ou d'UE) CODES_EXPL = { - ADM: "Validé", ADC: "Validé par compensation", ADJ: "Validé par le Jury", - ATT: "Décision en attente d'un autre semestre (faute d'atteindre la moyenne)", + ADM: "Validé", + AJ: "Ajourné", ATB: "Décision en attente d'un autre semestre (au moins une UE sous la barre)", ATJ: "Décision en attente d'un autre semestre (assiduité insuffisante)", - AJ: "Ajourné", - NAR: "Echec, non autorisé à redoubler", - RAT: "En attente d'un rattrapage", + ATT: "Décision en attente d'un autre semestre (faute d'atteindre la moyenne)", + CMP: "Code UE acquise car semestre acquis", DEF: "Défaillant", + NAR: "Échec, non autorisé à redoubler", + RAT: "En attente d'un rattrapage", } # Nota: ces explications sont personnalisables via le fichier # de config locale /opt/scodoc/var/scodoc/config/scodoc_local.py # variable: CONFIG.CODES_EXP +# Les codes de semestres: +CODES_JURY_SEM = {ADC, ADJ, ADM, AJ, ATB, ATJ, ATT, DEF, NAR, RAT} CODES_SEM_VALIDES = {ADM: True, ADC: True, ADJ: True} # semestre validé CODES_SEM_ATTENTES = {ATT: True, ATB: True, ATJ: True} # semestre en attente diff --git a/app/scodoc/sco_config_actions.py b/app/scodoc/sco_config_actions.py index c3ccc9cb62..f6ea96371c 100644 --- a/app/scodoc/sco_config_actions.py +++ b/app/scodoc/sco_config_actions.py @@ -152,28 +152,3 @@ class LogoInsert(Action): name=self.parameters["name"], dept_id=dept_id, ) - - -class BonusSportUpdate(Action): - """Action: Change bonus_sport_function_name. - bonus_sport_function_name: the new value""" - - def __init__(self, parameters): - super().__init__( - f"Changement du calcul de bonus sport pour ({parameters['bonus_sport_func_name']}).", - parameters, - ) - - @staticmethod - def build_action(parameters): - if ( - parameters["bonus_sport_func_name"] - != ScoDocSiteConfig.get_bonus_sport_func_name() - ): - return [BonusSportUpdate(parameters)] - return [] - - def execute(self): - current_app.logger.info(self.message) - ScoDocSiteConfig.set_bonus_sport_func(self.parameters["bonus_sport_func_name"]) - app.clear_scodoc_cache() diff --git a/app/scodoc/sco_edit_apc.py b/app/scodoc/sco_edit_apc.py index a9eff6de4f..c1c75319bd 100644 --- a/app/scodoc/sco_edit_apc.py +++ b/app/scodoc/sco_edit_apc.py @@ -33,6 +33,7 @@ from flask_login import current_user from app import db from app.models import Formation, UniteEns, Matiere, Module, FormSemestre, ModuleImpl from app.models.notes import ScolarFormSemestreValidation +from app.scodoc.sco_codes_parcours import UE_SPORT import app.scodoc.sco_utils as scu from app.scodoc import sco_groups from app.scodoc.sco_utils import ModuleType @@ -99,12 +100,19 @@ def html_edit_formation_apc( ressources_in_sem = ressources.filter_by(semestre_id=semestre_idx) saes_in_sem = saes.filter_by(semestre_id=semestre_idx) other_modules_in_sem = other_modules.filter_by(semestre_id=semestre_idx) + matiere_parent = Matiere.query.filter( + Matiere.ue_id == UniteEns.id, + UniteEns.formation_id == formation.id, + UniteEns.semestre_idx == semestre_idx, + UniteEns.type != UE_SPORT, + ).first() H += [ render_template( "pn/form_mods.html", formation=formation, titre=f"Ressources du S{semestre_idx}", create_element_msg="créer une nouvelle ressource", + matiere_parent=matiere_parent, modules=ressources_in_sem, module_type=ModuleType.RESSOURCE, editable=editable, @@ -117,6 +125,7 @@ def html_edit_formation_apc( formation=formation, titre=f"Situations d'Apprentissage et d'Évaluation (SAÉs) S{semestre_idx}", create_element_msg="créer une nouvelle SAÉ", + matiere_parent=matiere_parent, modules=saes_in_sem, module_type=ModuleType.SAE, editable=editable, diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index fbc28db679..ae143cbc07 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -32,15 +32,16 @@ import flask from flask import url_for, render_template from flask import g, request from flask_login import current_user + +from app import log +from app import models from app.models import APO_CODE_STR_LEN -from app.models import Matiere, Module, UniteEns +from app.models import Formation, Matiere, Module, UniteEns +from app.models import FormSemestre, ModuleImpl import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType -from app import log -from app import models -from app.models import Formation from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( @@ -144,7 +145,7 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): ] if is_apc: H += [ - f"""

Création {object_name} dans la formation {ue.formation.acronyme}

""" + f"""

Création {object_name} dans la formation {ue.formation.acronyme}, Semestre {ue.semestre_idx}, {ue.acronyme}

""" ] else: H += [ @@ -190,35 +191,7 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): ), ] semestres_indices = list(range(1, parcours.NB_SEM + 1)) - if is_apc: # BUT: choix de l'UE de rattachement (qui donnera le semestre) - descr += [ - ( - "ue_id", - { - "input_type": "menu", - "type": "int", - "title": "UE de rattachement", - "explanation": "utilisée pour la présentation dans certains documents", - "labels": [f"{u.acronyme} {u.titre}" for u in ues], - "allowed_values": [u.id for u in ues], - }, - ), - ] - else: - # Formations classiques: choix du semestre - descr += [ - ( - "semestre_id", - { - "input_type": "menu", - "type": "int", - "title": parcours.SESSION_NAME.capitalize(), - "explanation": "%s du module" % parcours.SESSION_NAME, - "labels": [str(x) for x in semestres_indices], - "allowed_values": semestres_indices, - }, - ), - ] + descr += [ ( "module_type", @@ -294,6 +267,7 @@ def module_create(matiere_id=None, module_type=None, semestre_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", + "validator": lambda val, _: len(val) < APO_CODE_STR_LEN, }, ), ( @@ -316,12 +290,7 @@ def module_create(matiere_id=None, module_type=None, semestre_id=None): if tf[0] == 0: return "\n".join(H) + tf[1] + html_sco_header.sco_footer() else: - if is_apc: - # BUT: l'UE indique le semestre - selected_ue = UniteEns.query.get(tf[2]["ue_id"]) - if selected_ue is None: - raise ValueError("UE invalide") - tf[2]["semestre_id"] = selected_ue.semestre_idx + tf[2]["semestre_id"] = ue.semestre_idx _ = do_module_create(tf[2]) @@ -472,18 +441,23 @@ def module_edit(module_id=None): formation_id = module["formation_id"] formation = sco_formations.formation_list(args={"formation_id": formation_id})[0] parcours = sco_codes_parcours.get_parcours_from_code(formation["type_parcours"]) - is_apc = parcours.APC_SAE - ues_matieres = ndb.SimpleDictFetch( - """SELECT ue.acronyme, mat.*, mat.id AS matiere_id - FROM notes_matieres mat, notes_ue ue - WHERE mat.ue_id = ue.id - AND ue.formation_id = %(formation_id)s - ORDER BY ue.numero, mat.numero - """, - {"formation_id": formation_id}, - ) - mat_names = ["%s / %s" % (x["acronyme"], x["titre"]) for x in ues_matieres] - ue_mat_ids = ["%s!%s" % (x["ue_id"], x["matiere_id"]) for x in ues_matieres] + is_apc = parcours.APC_SAE # BUT + in_use = len(a_module.modimpls.all()) > 0 # il y a des modimpls + matieres = Matiere.query.filter( + Matiere.ue_id == UniteEns.id, UniteEns.formation_id == formation_id + ).order_by(UniteEns.semestre_idx, UniteEns.numero, Matiere.numero) + if in_use: + # restreint aux matières du même semestre + matieres = matieres.filter(UniteEns.semestre_idx == a_module.ue.semestre_idx) + + if is_apc: + mat_names = [ + "S%s / %s" % (mat.ue.semestre_idx, mat.ue.acronyme) for mat in matieres + ] + else: + mat_names = ["%s / %s" % (mat.ue.acronyme, mat.titre or "") for mat in matieres] + ue_mat_ids = ["%s!%s" % (mat.ue.id, mat.id) for mat in matieres] + module["ue_matiere_id"] = "%s!%s" % (module["ue_id"], module["matiere_id"]) semestres_indices = list(range(1, parcours.NB_SEM + 1)) @@ -500,12 +474,25 @@ def module_edit(module_id=None): ), """

Modification du module %(titre)s""" % module, """ (formation %(acronyme)s, version %(version)s)

""" % formation, - render_template("scodoc/help/modules.html", is_apc=is_apc), + render_template( + "scodoc/help/modules.html", + is_apc=is_apc, + formsemestres=FormSemestre.query.filter( + ModuleImpl.formsemestre_id == FormSemestre.id, + ModuleImpl.module_id == module_id, + ).all(), + ), ] if not unlocked: H.append( """
Formation verrouillée, seuls certains éléments peuvent être modifiés
""" ) + if in_use: + H.append( + """
Module déjà utilisé dans des semestres, + soyez prudents ! +
""" + ) descr = [ ( @@ -534,11 +521,17 @@ def module_edit(module_id=None): ), ( "heures_cours", - {"size": 4, "type": "float", "explanation": "nombre d'heures de cours"}, + { + "title": "Heures CM :", + "size": 4, + "type": "float", + "explanation": "nombre d'heures de cours", + }, ), ( "heures_td", { + "title": "Heures TD :", "size": 4, "type": "float", "explanation": "nombre d'heures de Travaux Dirigés", @@ -547,6 +540,7 @@ def module_edit(module_id=None): ( "heures_tp", { + "title": "Heures TP :", "size": 4, "type": "float", "explanation": "nombre d'heures de Travaux Pratiques", @@ -566,9 +560,9 @@ def module_edit(module_id=None): "ue_coefs", { "readonly": True, - "title": "Coefficients vers les UE", + "title": "Coefficients vers les UE ", "default": coefs_descr_txt, - "explanation": "passer par la page d'édition de la formation pour modifier les coefficients", + "explanation": "
(passer par la page d'édition de la formation pour modifier les coefficients)", }, ) ] @@ -594,7 +588,14 @@ def module_edit(module_id=None): { "input_type": "menu", "title": "Rattachement :" if is_apc else "Matière :", - "explanation": "UE de rattachement, utilisée pour la présentation" + "explanation": ( + "UE de rattachement" + + ( + " module utilisé, ne peut pas être changé de semestre" + if in_use + else "" + ) + ) if is_apc else "un module appartient à une seule matière.", "labels": mat_names, @@ -666,8 +667,30 @@ def module_edit(module_id=None): initvalues=module, submitlabel="Modifier ce module", ) + # Affiche liste des formseemstre utilisant ce module + if in_use: + formsemestre_ids = {modimpl.formsemestre_id for modimpl in a_module.modimpls} + formsemestres = [FormSemestre.query.get(fid) for fid in formsemestre_ids] + formsemestres.sort(key=lambda f: f.date_debut) + items = [ + f"""{f.titre}""" + for f in formsemestres + ] + sem_descr = f""" +
+
Ce module est utilisé dans les formsemestres suivants:
+ +
+ """ + else: + sem_descr = "" + # if tf[0] == 0: - return "\n".join(H) + tf[1] + html_sco_header.sco_footer() + return "\n".join(H) + tf[1] + sem_descr + html_sco_header.sco_footer() elif tf[0] == -1: return flask.redirect( url_for( @@ -678,8 +701,17 @@ def module_edit(module_id=None): ) ) else: - # l'UE peut changer + # l'UE de rattachement peut changer tf[2]["ue_id"], tf[2]["matiere_id"] = tf[2]["ue_matiere_id"].split("!") + old_ue_id = a_module.ue.id + new_ue_id = int(tf[2]["ue_id"]) + if (old_ue_id != new_ue_id) and in_use: + new_ue = UniteEns.query.get_or_404(new_ue_id) + if new_ue.semestre_idx != a_module.ue.semestre_idx: + # pas changer de semestre un module utilisé ! + raise ScoValueError( + "Module utilisé: il ne peut pas être changé de semestre !" + ) # En APC, force le semestre égal à celui de l'UE if is_apc: selected_ue = UniteEns.query.get(tf[2]["ue_id"]) diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 6b7884591a..5b333a9fd6 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -33,13 +33,15 @@ from flask import url_for, render_template from flask import g, request from flask_login import current_user +from app import db +from app import log from app.models import APO_CODE_STR_LEN from app.models import Formation, UniteEns, ModuleImpl, Module +from app.models.formations import Matiere import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType -from app import log -from app.scodoc.TrivialFormulator import TrivialFormulator, TF +from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc.gen_tables import GenTable from app.scodoc.sco_permissions import Permission from app.scodoc.sco_exceptions import ( @@ -81,6 +83,7 @@ _ueEditor = ndb.EditableTable( "is_external", "code_apogee", "coefficient", + "color", ), sortkey="numero", input_formators={ @@ -330,7 +333,9 @@ def ue_edit(ue_id=None, create=False, formation_id=None): la moyenne générale est activée. Par défaut, le coefficient d'une UE est simplement la somme des coefficients des modules dans lesquels l'étudiant a des notes. + Jamais utilisé en BUT. """, + "enabled": not is_apc, }, ), ( @@ -358,6 +363,14 @@ def ue_edit(ue_id=None, create=False, formation_id=None): "explanation": "réservé pour les capitalisations d'UE effectuées à l'extérieur de l'établissement", }, ), + ( + "color", + { + "input_type": "color", + "title": "Couleur", + "explanation": "pour affichages", + }, + ), ] if create and not parcours.UE_IS_MODULE and not is_apc: fw.append( @@ -379,9 +392,9 @@ def ue_edit(ue_id=None, create=False, formation_id=None): submitlabel=submitlabel, ) if tf[0] == 0: - X = """
- """ - return "\n".join(H) + tf[1] + X + html_sco_header.sco_footer() + ue_div = """
""" + bonus_div = """
""" + return "\n".join(H) + tf[1] + bonus_div + ue_div + html_sco_header.sco_footer() else: if create: if not tf[2]["ue_code"]: @@ -533,6 +546,12 @@ def ue_table(formation_id=None, semestre_idx=1, msg=""): # was ue_list # pour faciliter la transition des anciens programmes non APC for ue in ues_obj: ue.guess_semestre_idx() + # vérifie qu'on a bien au moins une matière dans chaque UE + for ue in ues_obj: + if ue.matieres.count() < 1: + mat = Matiere(ue_id=ue.id) + db.session.add(mat) + db.session.commit() ues = [ue.to_dict() for ue in ues_obj] ues_externes = [ue.to_dict() for ue in ues_externes_obj] @@ -1220,7 +1239,8 @@ def do_ue_edit(args, bypass_lock=False, dont_invalidate_cache=False): def edit_ue_set_code_apogee(id=None, value=None): "set UE code apogee" ue_id = id - value = value.strip("-_ \t") + value = value.strip("-_ \t")[:APO_CODE_STR_LEN] # tronque + log("edit_ue_set_code_apogee: ue_id=%s code_apogee=%s" % (ue_id, value)) ues = ue_list(args={"ue_id": ue_id}) diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py index 17d47915ae..6586ddd099 100644 --- a/app/scodoc/sco_evaluation_db.py +++ b/app/scodoc/sco_evaluation_db.py @@ -185,7 +185,8 @@ def _check_evaluation_args(args): if (jour > date_fin) or (jour < date_debut): raise ScoValueError( "La date de l'évaluation (%s/%s/%s) n'est pas dans le semestre !" - % (d, m, y) + % (d, m, y), + dest_url="javascript:history.back();", ) heure_debut = args.get("heure_debut", None) args["heure_debut"] = heure_debut diff --git a/app/scodoc/sco_evaluation_edit.py b/app/scodoc/sco_evaluation_edit.py index c324a89be2..ab178c1503 100644 --- a/app/scodoc/sco_evaluation_edit.py +++ b/app/scodoc/sco_evaluation_edit.py @@ -143,6 +143,7 @@ def evaluation_create_form( if vals.get("tf_submitted", False) and "visibulletinlist" not in vals: vals["visibulletinlist"] = [] # + ue_coef_dict = {} if is_apc: # BUT: poids vers les UE ue_coef_dict = ModuleImpl.query.get(moduleimpl_id).module.get_ue_coef_dict() for ue in sem_ues: @@ -290,7 +291,10 @@ def evaluation_create_form( "title": f"Poids {ue.acronyme}", "size": 2, "type": "float", - "explanation": f"{ue.titre}", + "explanation": f""" + {ue_coef_dict.get(ue.id, 0.)} + {ue.titre} + """, "allow_null": False, }, ), diff --git a/app/scodoc/sco_exceptions.py b/app/scodoc/sco_exceptions.py index d975766efe..112658e658 100644 --- a/app/scodoc/sco_exceptions.py +++ b/app/scodoc/sco_exceptions.py @@ -36,8 +36,19 @@ class ScoException(Exception): pass -class NoteProcessError(ScoException): - "misc errors in process" +class InvalidNoteValue(ScoException): + pass + + +# Exception qui stoque dest_url +class ScoValueError(ScoException): + def __init__(self, msg, dest_url=None): + super().__init__(msg) + self.dest_url = dest_url + + +class NoteProcessError(ScoValueError): + "Valeurs notes invalides" pass @@ -45,17 +56,6 @@ class InvalidEtudId(NoteProcessError): pass -class InvalidNoteValue(ScoException): - pass - - -# Exception qui stoque dest_url, utilisee dans Zope standard_error_message -class ScoValueError(ScoException): - def __init__(self, msg, dest_url=None): - super().__init__(msg) - self.dest_url = dest_url - - class ScoFormatError(ScoValueError): pass diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py index e6ac86e3a8..f1cf22644a 100644 --- a/app/scodoc/sco_formsemestre_edit.py +++ b/app/scodoc/sco_formsemestre_edit.py @@ -28,13 +28,16 @@ """Form choix modules / responsables et creation formsemestre """ import flask -from flask import url_for, g, request +from flask import url_for, flash +from flask import g, request from flask_login import current_user from app import db from app.auth.models import User from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN -from app.models import ModuleImpl, Evaluation, EvaluationUEPoids +from app.models import Module, ModuleImpl, Evaluation, EvaluationUEPoids, UniteEns +from app.models.formations import Formation +from app.models.formsemestre import FormSemestre import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app.scodoc import sco_cache @@ -65,9 +68,9 @@ from app.scodoc import sco_preferences from app.scodoc import sco_users -def _default_sem_title(F): - """Default title for a semestre in formation F""" - return F["titre"] +def _default_sem_title(formation): + """Default title for a semestre in formation""" + return formation.titre def formsemestre_createwithmodules(): @@ -140,6 +143,7 @@ def do_formsemestre_createwithmodules(edit=False): if edit: formsemestre_id = int(vals["formsemestre_id"]) sem = sco_formsemestre.get_formsemestre(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) if not current_user.has_permission(Permission.ScoImplement): if not edit: # il faut ScoImplement pour creer un semestre @@ -161,26 +165,25 @@ def do_formsemestre_createwithmodules(edit=False): allowed_user_names = list(uid2display.values()) + [""] # formation_id = int(vals["formation_id"]) - F = sco_formations.formation_list(args={"formation_id": formation_id}) - if not F: + formation = Formation.query.get(formation_id) + if formation is None: raise ScoValueError("Formation inexistante !") - F = F[0] if not edit: - initvalues = {"titre": _default_sem_title(F)} + initvalues = {"titre": _default_sem_title(formation)} semestre_id = int(vals["semestre_id"]) - sem_module_ids = set() + module_ids_set = set() else: # setup form init values initvalues = sem semestre_id = initvalues["semestre_id"] # add associated modules to tf-checked: - ams = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) - sem_module_ids = set([x["module_id"] for x in ams]) - initvalues["tf-checked"] = ["MI" + str(x["module_id"]) for x in ams] - for x in ams: - initvalues["MI" + str(x["module_id"])] = uid2display.get( - x["responsable_id"], - f"inconnu numéro {x['responsable_id']} resp. de {x['moduleimpl_id']} !", + module_ids_existing = [modimpl.module.id for modimpl in formsemestre.modimpls] + module_ids_set = set(module_ids_existing) + initvalues["tf-checked"] = ["MI" + str(x) for x in module_ids_existing] + for modimpl in formsemestre.modimpls: + initvalues[f"MI{modimpl.module.id}"] = uid2display.get( + modimpl.responsable_id, + f"inconnu numéro {modimpl.responsable_id} resp. de {modimpl.id} !", ) initvalues["responsable_id"] = uid2display.get( @@ -192,49 +195,38 @@ def do_formsemestre_createwithmodules(edit=False): ) # Liste des ID de semestres - if F["type_parcours"] is not None: - parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"]) + if formation.type_parcours is not None: + parcours = sco_codes_parcours.get_parcours_from_code(formation.type_parcours) NB_SEM = parcours.NB_SEM else: NB_SEM = 10 # fallback, max 10 semestres if NB_SEM == 1: semestre_id_list = [-1] else: - semestre_id_list = [-1] + list(range(1, NB_SEM + 1)) + if edit and formation.is_apc(): + # 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_labels = [] for sid in semestre_id_list: if sid == -1: semestre_id_labels.append("pas de semestres") else: semestre_id_labels.append(f"S{sid}") - # Liste des modules dans ce semestre de cette formation - # on pourrait faire un simple module_list( ) - # mais si on veut l'ordre du PPN (groupe par UE et matieres) il faut: - mods = [] # liste de dicts - uelist = sco_edit_ue.ue_list({"formation_id": formation_id}) - for ue in uelist: - matlist = sco_edit_matiere.matiere_list({"ue_id": ue["ue_id"]}) - for mat in matlist: - modsmat = sco_edit_module.module_list({"matiere_id": mat["matiere_id"]}) - # XXX debug checks - for m in modsmat: - if m["ue_id"] != ue["ue_id"]: - log( - "XXX createwithmodules: m.ue_id=%s != u.ue_id=%s !" - % (m["ue_id"], ue["ue_id"]) - ) - if m["formation_id"] != formation_id: - log( - "XXX createwithmodules: formation_id=%s\n\tm=%s" - % (formation_id, str(m)) - ) - if m["formation_id"] != ue["formation_id"]: - log( - "XXX createwithmodules: formation_id=%s\n\tue=%s\tm=%s" - % (formation_id, str(ue), str(m)) - ) - # /debug - mods = mods + modsmat + # Liste des modules dans cette formation + if formation.is_apc(): + modules = formation.modules.order_by(Module.module_type, Module.numero) + else: + modules = ( + Module.query.filter( + Module.formation_id == formation_id, UniteEns.id == Module.ue_id + ) + .order_by(Module.module_type, UniteEns.numero, Module.numero) + .all() + ) + mods = [mod.to_dict() for mod in modules] # Pour regroupement des modules par semestres: semestre_ids = {} for mod in mods: @@ -319,7 +311,7 @@ def do_formsemestre_createwithmodules(edit=False): "explanation": """n'indiquez pas les dates, ni le semestre, ni la modalité dans le titre: ils seront automatiquement ajoutés """ - % _default_sem_title(F), + % _default_sem_title(formation), }, ), ( @@ -340,6 +332,9 @@ def do_formsemestre_createwithmodules(edit=False): "title": "Semestre dans la formation", "allowed_values": semestre_id_list, "labels": semestre_id_labels, + "explanation": "en BUT, on ne peut pas modifier le semestre après création" + if formation.is_apc() + else "", }, ), ) @@ -549,7 +544,12 @@ def do_formsemestre_createwithmodules(edit=False): ) ) for mod in mods: - if mod["semestre_id"] == semestre_id: + if mod["semestre_id"] == semestre_id and ( + (not edit) # creation => tous modules + or (not formation.is_apc()) # pas BUT, on peux mixer les semestres + or (semestre_id == formsemestre.semestre_id) # module du semestre + or (mod["module_id"] in module_ids_set) # module déjà présent + ): nbmod += 1 if edit: select_name = "%s!group_id" % mod["module_id"] @@ -560,7 +560,7 @@ def do_formsemestre_createwithmodules(edit=False): else: return "" - if mod["module_id"] in sem_module_ids: + if mod["module_id"] in module_ids_set: disabled = "disabled" else: disabled = "" @@ -684,12 +684,13 @@ def do_formsemestre_createwithmodules(edit=False): msg = '' if tf[0] == 0 or msg: - return ( - '

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 "

annulation

" else: @@ -735,42 +736,58 @@ def do_formsemestre_createwithmodules(edit=False): etape=tf[2]["etape_apo" + str(n)], vdi=tf[2]["vdi_apo" + str(n)] ) ) + # Modules sélectionnés: + # (retire le "MI" du début du nom de champs) + module_ids_checked = [int(x[2:]) for x in tf[2]["tf-checked"]] if not edit: - # creation du semestre + if formation.is_apc(): + _formsemestre_check_module_list( + module_ids_checked, tf[2]["semestre_id"] + ) + # création du semestre formsemestre_id = sco_formsemestre.do_formsemestre_create(tf[2]) - # creation des modules - for module_id in tf[2]["tf-checked"]: - assert module_id[:2] == "MI" + # création des modules + for module_id in module_ids_checked: modargs = { - "module_id": int(module_id[2:]), + "module_id": module_id, "formsemestre_id": formsemestre_id, - "responsable_id": tf[2][module_id], + "responsable_id": tf[2][f"MI{module_id}"], } _ = sco_moduleimpl.do_moduleimpl_create(modargs) + flash("Nouveau semestre créé") return flask.redirect( - "formsemestre_status?formsemestre_id=%s&head_message=Nouveau%%20semestre%%20créé" - % formsemestre_id + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) ) else: - # modification du semestre: + # Modification du semestre: # on doit creer les modules nouvellement selectionnés - # modifier ceux a modifier, et DETRUIRE ceux qui ne sont plus selectionnés. - # Note: la destruction echouera s'il y a des objets dependants - # (eg des evaluations définies) - # nouveaux modules - # (retire le "MI" du début du nom de champs) - checkedmods = [int(x[2:]) for x in tf[2]["tf-checked"]] + # modifier ceux à modifier, et DETRUIRE ceux qui ne sont plus selectionnés. + # Note: la destruction échouera s'il y a des objets dépendants + # (eg des évaluations définies) + module_ids_tocreate = [ + x for x in module_ids_checked if not x in module_ids_existing + ] + if formation.is_apc(): + _formsemestre_check_module_list( + module_ids_tocreate, tf[2]["semestre_id"] + ) + # modules existants à modifier + module_ids_toedit = [ + x for x in module_ids_checked if x in module_ids_existing + ] + # modules à détruire + module_ids_todelete = [ + x for x in module_ids_existing if not x in module_ids_checked + ] + # sco_formsemestre.do_formsemestre_edit(tf[2]) - ams = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) - existingmods = [x["module_id"] for x in ams] - mods_tocreate = [x for x in checkedmods if not x in existingmods] - # modules a existants a modifier - mods_toedit = [x for x in checkedmods if x in existingmods] - # modules a detruire - mods_todelete = [x for x in existingmods if not x in checkedmods] # msg = [] - for module_id in mods_tocreate: + for module_id in module_ids_tocreate: modargs = { "module_id": module_id, "formsemestre_id": formsemestre_id, @@ -808,9 +825,11 @@ def do_formsemestre_createwithmodules(edit=False): % (module_id, moduleimpl_id) ) # - ok, diag = formsemestre_delete_moduleimpls(formsemestre_id, mods_todelete) + ok, diag = formsemestre_delete_moduleimpls( + formsemestre_id, module_ids_todelete + ) msg += diag - for module_id in mods_toedit: + for module_id in module_ids_toedit: moduleimpl_id = sco_moduleimpl.moduleimpl_list( formsemestre_id=formsemestre_id, module_id=module_id )[0]["moduleimpl_id"] @@ -847,6 +866,22 @@ def do_formsemestre_createwithmodules(edit=False): ) +def _formsemestre_check_module_list(module_ids, semestre_idx): + """En APC: Vérifie que tous les modules de la liste + sont dans le semestre indiqué. + Sinon, raise ScoValueError. + """ + # vérification de la cohérence / modules / semestre + mod_sems_idx = { + Module.query.get_or_404(module_id).ue.semestre_idx for module_id in module_ids + } + if mod_sems_idx and mod_sems_idx != {semestre_idx}: + raise ScoValueError( + "Les modules sélectionnés ne sont pas tous dans le semestre choisi !", + dest_url="javascript:history.back();", + ) + + def formsemestre_delete_moduleimpls(formsemestre_id, module_ids_to_del): """Delete moduleimpls module_ids_to_del: list of module_id (warning: not moduleimpl) diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index d0ee4b1a70..97ac05dd6c 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -108,10 +108,10 @@ def _build_menu_stats(formsemestre_id): "enabled": True, }, { - "title": "Documents Avis Poursuite Etudes", + "title": "Documents Avis Poursuite Etudes (xp)", "endpoint": "notes.pe_view_sem_recap", "args": {"formsemestre_id": formsemestre_id}, - "enabled": current_app.config["TESTING"] or current_app.config["DEBUG"], + "enabled": True, # current_app.config["TESTING"] or current_app.config["DEBUG"], }, { "title": 'Table "débouchés"', @@ -1107,6 +1107,7 @@ _TABLEAU_MODULES_HEAD = """ Module Inscrits Responsable +Coefs. Évaluations """ @@ -1213,7 +1214,21 @@ def formsemestre_tableau_modules( sco_users.user_info(modimpl["responsable_id"])["prenomnom"], ) ) - + H.append("") + if mod.module_type in (ModuleType.RESSOURCE, ModuleType.SAE): + coefs = mod.ue_coefs_list() + for coef in coefs: + if coef[1] > 0: + H.append( + f"""""" + ) + else: + H.append(f"""""") + H.append("") if mod.module_type in ( None, # ne devrait pas être nécessaire car la migration a remplacé les NULLs ModuleType.STANDARD, diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 36549bb36b..baee42f3cf 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -748,7 +748,7 @@ def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None ) # Choix code semestre: - codes = list(sco_codes_parcours.CODES_EXPL.keys()) + codes = list(sco_codes_parcours.CODES_JURY_SEM) codes.sort() # fortuitement, cet ordre convient bien ! H.append( diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py index 2036a64d25..d1cfb47d82 100644 --- a/app/scodoc/sco_groups.py +++ b/app/scodoc/sco_groups.py @@ -87,7 +87,7 @@ groupEditor = ndb.EditableTable( group_list = groupEditor.list -def get_group(group_id): +def get_group(group_id: int): """Returns group object, with partition""" r = ndb.SimpleDictFetch( """SELECT gd.id AS group_id, gd.*, p.id AS partition_id, p.* @@ -687,6 +687,11 @@ def setGroups( group_id = fs[0].strip() if not group_id: continue + try: + group_id = int(group_id) + except ValueError as exc: + log("setGroups: ignoring invalid group_id={group_id}") + continue group = get_group(group_id) # Anciens membres du groupe: old_members = get_group_members(group_id) diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index 3875bbca77..831cc87a94 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -49,9 +49,11 @@ from app.scodoc import sco_etud from app.scodoc.sco_exceptions import ScoValueError -def list_authorized_etuds_by_sem(sem, delai=274): +def list_authorized_etuds_by_sem(sem, delai=274, ignore_jury=False): """Liste des etudiants autorisés à s'inscrire dans sem. delai = nb de jours max entre la date de l'autorisation et celle de debut du semestre cible. + ignore_jury: si vrai, considère tous les étudiants comem autorisés, même + s'ils n'ont pas de décision de jury. """ src_sems = list_source_sems(sem, delai=delai) inscrits = list_inscrits(sem["formsemestre_id"]) @@ -59,7 +61,12 @@ def list_authorized_etuds_by_sem(sem, delai=274): candidats = {} # etudid : etud (tous les etudiants candidats) nb = 0 # debug for src in src_sems: - liste = list_etuds_from_sem(src, sem) + if ignore_jury: + # liste de tous les inscrits au semestre (sans dems) + liste = list_inscrits(src["formsemestre_id"]).values() + else: + # liste des étudiants autorisés par le jury à s'inscrire ici + liste = list_etuds_from_sem(src, sem) liste_filtree = [] for e in liste: # Filtre ceux qui se sont déjà inscrit dans un semestre APRES le semestre src @@ -125,7 +132,7 @@ def list_inscrits(formsemestre_id, with_dems=False): return inscr -def list_etuds_from_sem(src, dst): +def list_etuds_from_sem(src, dst) -> list[dict]: """Liste des etudiants du semestre src qui sont autorisés à passer dans le semestre dst.""" target = dst["semestre_id"] dpv = sco_pvjury.dict_pvjury(src["formsemestre_id"]) @@ -224,7 +231,7 @@ def do_desinscrit(sem, etudids): ) -def list_source_sems(sem, delai=None): +def list_source_sems(sem, delai=None) -> list[dict]: """Liste des semestres sources sem est le semestre destination """ @@ -265,6 +272,7 @@ def formsemestre_inscr_passage( inscrit_groupes=False, submitted=False, dialog_confirmed=False, + ignore_jury=False, ): """Form. pour inscription des etudiants d'un semestre dans un autre (donné par formsemestre_id). @@ -280,6 +288,7 @@ def formsemestre_inscr_passage( """ inscrit_groupes = int(inscrit_groupes) + ignore_jury = int(ignore_jury) sem = sco_formsemestre.get_formsemestre(formsemestre_id) # -- check lock if not sem["etat"]: @@ -295,7 +304,9 @@ def formsemestre_inscr_passage( elif etuds and isinstance(etuds[0], str): etuds = [int(x) for x in etuds] - auth_etuds_by_sem, inscrits, candidats = list_authorized_etuds_by_sem(sem) + auth_etuds_by_sem, inscrits, candidats = list_authorized_etuds_by_sem( + sem, ignore_jury=ignore_jury + ) etuds_set = set(etuds) candidats_set = set(candidats) inscrits_set = set(inscrits) @@ -323,6 +334,7 @@ def formsemestre_inscr_passage( candidats_non_inscrits, inscrits_ailleurs, inscrit_groupes=inscrit_groupes, + ignore_jury=ignore_jury, ) else: if not dialog_confirmed: @@ -363,6 +375,7 @@ def formsemestre_inscr_passage( "formsemestre_id": formsemestre_id, "etuds": ",".join([str(x) for x in etuds]), "inscrit_groupes": inscrit_groupes, + "ignore_jury": ignore_jury, "submitted": 1, }, ) @@ -411,18 +424,23 @@ def build_page( candidats_non_inscrits, inscrits_ailleurs, inscrit_groupes=False, + ignore_jury=False, ): inscrit_groupes = int(inscrit_groupes) + ignore_jury = int(ignore_jury) if inscrit_groupes: inscrit_groupes_checked = " checked" else: inscrit_groupes_checked = "" - + if ignore_jury: + ignore_jury_checked = " checked" + else: + ignore_jury_checked = "" H = [ html_sco_header.html_sem_header( "Passages dans le semestre", with_page_header=False ), - """
""" % request.base_url, + """""" % request.base_url, """  aide @@ -430,6 +448,8 @@ def build_page( % sem, # " """inscrire aux mêmes groupes""" % inscrit_groupes_checked, + """inclure tous les étudiants (même sans décision de jury)""" + % ignore_jury_checked, """
Actuellement %s inscrits et %d candidats supplémentaires
""" diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 4cd768886a..a342e3126a 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -37,7 +37,10 @@ from app.models.moduleimpls import ModuleImpl import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log +from app.comp import res_sem from app.comp import moy_mod +from app.comp.moy_mod import ModuleImplResults +from app.comp.res_common import NotesTableCompat from app.scodoc.TrivialFormulator import TrivialFormulator from app.scodoc import sco_cache from app.scodoc import sco_edit_module @@ -432,7 +435,7 @@ def _make_table_notes( if is_apc: # Ajoute une colonne par UE _add_apc_columns( - moduleimpl_id, + modimpl, evals_poids, ues, rows, @@ -815,7 +818,7 @@ def _add_moymod_column( def _add_apc_columns( - moduleimpl_id, + modimpl, evals_poids, ues, rows, @@ -834,18 +837,23 @@ def _add_apc_columns( # => On recharge tout dans les nouveaux modèles # rows est une liste de dict avec une clé "etudid" # on va y ajouter une clé par UE du semestre - modimpl = ModuleImpl.query.get(moduleimpl_id) - evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( - moduleimpl_id - ) - etuds_moy_module = moy_mod.compute_module_moy( - evals_notes, evals_poids, evaluations, evaluations_completes - ) + nt: NotesTableCompat = res_sem.load_formsemestre_result(modimpl.formsemestre) + modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl.id] + + # XXX A ENLEVER TODO + # modimpl = ModuleImpl.query.get(moduleimpl_id) + + # evals_notes, evaluations, evaluations_completes = moy_mod.df_load_modimpl_notes( + # moduleimpl_id + # ) + # etuds_moy_module = moy_mod.compute_module_moy( + # evals_notes, evals_poids, evaluations, evaluations_completes + # ) if is_conforme: # valeur des moyennes vers les UEs: for row in rows: for ue in ues: - moy_ue = etuds_moy_module[ue.id].get(row["etudid"], "?") + moy_ue = modimpl_results.etuds_moy_module[ue.id].get(row["etudid"], "?") row[f"moy_ue_{ue.id}"] = scu.fmt_note(moy_ue, keep_numeric=keep_numeric) row[f"_moy_ue_{ue.id}_class"] = "moy_ue" # Nom et coefs des UE (lignes titres): diff --git a/app/scodoc/sco_moduleimpl_inscriptions.py b/app/scodoc/sco_moduleimpl_inscriptions.py index bba273d897..0778a0069c 100644 --- a/app/scodoc/sco_moduleimpl_inscriptions.py +++ b/app/scodoc/sco_moduleimpl_inscriptions.py @@ -567,17 +567,17 @@ def do_etud_inscrit_ue(etudid, formsemestre_id, ue_id): cnx = ndb.GetDBConnexion() cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) cursor.execute( - """SELECT mi.moduleimpl_id + """SELECT mi.id FROM notes_moduleimpl mi, notes_modules mod, notes_formsemestre sem - WHERE sem.formsemestre_id = %(formsemestre_id)s - AND mi.formsemestre_id = sem.formsemestre_id - AND mod.module_id = mi.module_id + WHERE sem.id = %(formsemestre_id)s + AND mi.formsemestre_id = sem.id + AND mod.id = mi.module_id AND mod.ue_id = %(ue_id)s """, {"formsemestre_id": formsemestre_id, "ue_id": ue_id}, ) res = cursor.dictfetchall() - for moduleimpl_id in [x["moduleimpl_id"] for x in res]: + for moduleimpl_id in [x["id"] for x in res]: sco_moduleimpl.do_moduleimpl_inscription_create( {"moduleimpl_id": moduleimpl_id, "etudid": etudid}, formsemestre_id=formsemestre_id, diff --git a/app/scodoc/sco_moduleimpl_status.py b/app/scodoc/sco_moduleimpl_status.py index 904c74c9ae..3cf49e3b5f 100644 --- a/app/scodoc/sco_moduleimpl_status.py +++ b/app/scodoc/sco_moduleimpl_status.py @@ -171,7 +171,9 @@ def _ue_coefs_html(coefs_lst) -> str: """ + "\n".join( [ - f"""
{coef}
{ue.acronyme}
""" + f"""
{coef}
{ue.acronyme}
""" for ue, coef in coefs_lst ] ) @@ -286,21 +288,16 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None): 'Règle de calcul: moyenne=%s' % M["computation_expr"] ) - if sco_moduleimpl.can_change_ens(moduleimpl_id, raise_exc=False): - H.append( - 'modifier' - % moduleimpl_id - ) + H.append('inutilisée dans cette version de ScoDoc') H.append("") else: H.append( - 'règle de calcul standard' + '' # règle de calcul standard' ) - if sco_moduleimpl.can_change_ens(moduleimpl_id, raise_exc=False): - H.append( - ' (changer)' - % moduleimpl_id - ) + # if sco_moduleimpl.can_change_ens(moduleimpl_id, raise_exc=False): + # H.append( + # f' (changer)' + # ) H.append("") H.append( 'Absences dans ce module' @@ -399,7 +396,9 @@ def moduleimpl_status(moduleimpl_id=None, partition_id=None): eval_index = len(mod_evals) - 1 first_eval = True for eval in mod_evals: - evaluation = Evaluation.query.get(eval["evaluation_id"]) # TODO unifier + evaluation: Evaluation = Evaluation.query.get( + eval["evaluation_id"] + ) # TODO unifier etat = sco_evaluations.do_evaluation_etat( eval["evaluation_id"], partition_id=partition_id, diff --git a/app/scodoc/sco_permissions.py b/app/scodoc/sco_permissions.py index 71cfede19a..c72b3ff50b 100644 --- a/app/scodoc/sco_permissions.py +++ b/app/scodoc/sco_permissions.py @@ -37,7 +37,7 @@ _SCO_PERMISSIONS = ( (1 << 21, "ScoEditPVJury", "Éditer les PV de jury"), # ajouter maquettes Apogee (=> chef dept et secr): (1 << 22, "ScoEditApo", "Ajouter des maquettes Apogées"), - # application relations entreprises + # Application relations entreprises (1 << 23, "RelationsEntreprisesView", "Voir l'application relations entreprises"), (1 << 24, "RelationsEntreprisesChange", "Modifier les entreprises"), ( diff --git a/app/scodoc/sco_portal_apogee.py b/app/scodoc/sco_portal_apogee.py index f78e90034f..836be2ed9b 100644 --- a/app/scodoc/sco_portal_apogee.py +++ b/app/scodoc/sco_portal_apogee.py @@ -169,7 +169,9 @@ def get_inscrits_etape(code_etape, anneeapogee=None, ntrials=2): if doc: break if not doc: - raise ScoValueError("pas de réponse du portail ! (timeout=%s)" % portal_timeout) + raise ScoValueError( + f"pas de réponse du portail !
(timeout={portal_timeout}, requête: {req})" + ) etuds = _normalize_apo_fields(xml_to_list_of_dicts(doc, req=req)) # Filtre sur annee inscription Apogee: diff --git a/app/scodoc/sco_preferences.py b/app/scodoc/sco_preferences.py index a0d9b010b1..dbbdd0ac5e 100644 --- a/app/scodoc/sco_preferences.py +++ b/app/scodoc/sco_preferences.py @@ -111,8 +111,9 @@ get_base_preferences(formsemestre_id) """ import flask -from flask import g, url_for, request -from flask_login import current_user +from flask import g, request, current_app + +# from flask_login import current_user from app.models import Departement from app.scodoc import sco_cache @@ -1537,7 +1538,7 @@ class BasePreferences(object): ( "email_from_addr", { - "initvalue": "noreply@scodoc.example.com", + "initvalue": current_app.config["SCODOC_MAIL_FROM"], "title": "adresse mail origine", "size": 40, "explanation": "adresse expéditeur pour les envois par mails (bulletins)", @@ -2018,7 +2019,7 @@ class BasePreferences(object): H = [ html_sco_header.sco_header(page_title="Préférences"), "

Préférences globales pour %s

" % scu.ScoURL(), - # f"""

modification des logos du département (pour documents pdf)

""" # if current_user.is_administrator() # else "", diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pvjury.py index 084b1ca0e1..ea43cb2d2a 100644 --- a/app/scodoc/sco_pvjury.py +++ b/app/scodoc/sco_pvjury.py @@ -566,7 +566,7 @@ def formsemestre_pvjury(formsemestre_id, format="html", publish=True): if "prev_decision" in row and row["prev_decision"]: counts[row["prev_decision"]] += 0 # Légende des codes - codes = list(counts.keys()) # sco_codes_parcours.CODES_EXPL.keys() + codes = list(counts.keys()) codes.sort() H.append("

Explication des codes

") lines = [] diff --git a/app/scodoc/sco_roles_default.py b/app/scodoc/sco_roles_default.py index 6f8d79eeea..9529b031c1 100644 --- a/app/scodoc/sco_roles_default.py +++ b/app/scodoc/sco_roles_default.py @@ -58,8 +58,31 @@ SCO_ROLES_DEFAULTS = { # il peut ajouter des tags sur les formations: # (doit avoir un rôle Ens en plus !) "RespPe": (p.ScoEditFormationTags,), + # Rôles pour l'application relations entreprises + # ObservateurEntreprise est un observateur de l'application entreprise + "ObservateurEntreprise": (p.RelationsEntreprisesView,), + # UtilisateurEntreprise est un utilisateur de l'application entreprise (droit de modification) + "UtilisateurEntreprise": (p.RelationsEntreprisesView, p.RelationsEntreprisesChange), + # AdminEntreprise est un admin de l'application entreprise (toutes les actions possibles de l'application) + "AdminEntreprise": ( + p.RelationsEntreprisesView, + p.RelationsEntreprisesChange, + p.RelationsEntreprisesExport, + p.RelationsEntreprisesSend, + p.RelationsEntreprisesValidate, + ), # Super Admin est un root: création/suppression de départements # _tous_ les droits # Afin d'avoir tous les droits, il ne doit pas être asscoié à un département "SuperAdmin": p.ALL_PERMISSIONS, } + +# Les rôles accessibles via la page d'admin utilisateurs +# - associés à un département: +ROLES_ATTRIBUABLES_DEPT = ("Ens", "Secr", "Admin", "RespPe") +# - globaux: (ne peuvent être attribués que par un SuperAdmin) +ROLES_ATTRIBUABLES_SCODOC = ( + "ObservateurEntreprise", + "UtilisateurEntreprise", + "AdminEntreprise", +) diff --git a/app/scodoc/sco_saisie_notes.py b/app/scodoc/sco_saisie_notes.py index cbcc012d79..cc15907728 100644 --- a/app/scodoc/sco_saisie_notes.py +++ b/app/scodoc/sco_saisie_notes.py @@ -153,7 +153,10 @@ def _check_notes(notes, evaluation, mod): for (etudid, note) in notes: note = str(note).strip().upper() - etudid = int(etudid) # + try: + etudid = int(etudid) # + except ValueError as exc: + raise ScoValueError(f"Code étudiant ({etudid}) invalide") if note[:3] == "DEM": continue # skip ! if note: @@ -487,10 +490,10 @@ def notes_add( } for (etudid, value) in notes: if check_inscription and (etudid not in inscrits): - raise NoteProcessError("etudiant non inscrit dans ce module") - if not ((value is None) or (type(value) == type(1.0))): + raise NoteProcessError(f"etudiant {etudid} non inscrit dans ce module") + if (value is not None) and not isinstance(value, float): raise NoteProcessError( - "etudiant %s: valeur de note invalide (%s)" % (etudid, value) + f"etudiant {etudid}: valeur de note invalide ({value})" ) # Recherche notes existantes notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) diff --git a/app/scodoc/sco_undo_notes.py b/app/scodoc/sco_undo_notes.py index 56684e3990..5d169b8bc4 100644 --- a/app/scodoc/sco_undo_notes.py +++ b/app/scodoc/sco_undo_notes.py @@ -181,7 +181,7 @@ def formsemestre_list_saisies_notes(formsemestre_id, format="html"): """ sem = sco_formsemestre.get_formsemestre(formsemestre_id, raise_soft_exc=True) r = ndb.SimpleDictFetch( - """SELECT i.nom, i.prenom, code_nip, n.*, mod.titre, e.description, e.jour, u.user_name + """SELECT i.nom, i.prenom, code_nip, n.*, mod.titre, e.description, e.jour, u.user_name, e.id as evaluation_id FROM notes_notes n, notes_evaluation e, notes_moduleimpl mi, notes_modules mod, identite i, "user" u WHERE mi.id = e.moduleimpl_id @@ -202,6 +202,7 @@ def formsemestre_list_saisies_notes(formsemestre_id, format="html"): "value", "user_name", "titre", + "evaluation_id", "description", "jour", "comment", @@ -214,6 +215,7 @@ def formsemestre_list_saisies_notes(formsemestre_id, format="html"): "value": "Note", "comment": "Remarque", "user_name": "Enseignant", + "evaluation_id": "evaluation_id", "titre": "Module", "description": "Evaluation", "jour": "Date éval.", diff --git a/app/static/css/releve-but.css b/app/static/css/releve-but.css index 3f132d6a40..a20c8bfa30 100644 --- a/app/static/css/releve-but.css +++ b/app/static/css/releve-but.css @@ -1,298 +1,322 @@ -/* Bulletin BUT, Seb. L. 2021-12-06 */ -/*******************/ -/* Styles généraux */ -/*******************/ -.wait{ - width: 60px; - height: 6px; - margin: auto; - background: #424242; /* la réponse à tout */ - animation: wait .4s infinite alternate; -} -@keyframes wait{ - 100%{transform: translateY(40px) rotate(1turn);} -} -main{ - --couleurPrincipale: rgb(240,250,255); - --couleurFondTitresUE: rgb(206,255,235); - --couleurFondTitresRes: rgb(125, 170, 255); - --couleurFondTitresSAE: rgb(211, 255, 255); - --couleurSecondaire: #fec; - --couleurIntense: #c09; - --couleurSurlignage: rgba(232, 255, 132, 0.47); - max-width: 1000px; - margin: auto; - display: none; -} -.ready .wait{display: none;} -.ready main{display: block;} -h2{ - margin: 0; - color: black; -} -section{ - background: #FFF; - border-radius: 16px; - border: 1px solid #AAA; - padding: 16px 32px; - margin: 8px 0; -} -section>div:nth-child(1){ - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; -} -.CTA_Liste{ - display: flex; - gap: 4px; - align-items: center; - background: var(--couleurIntense); - color: #FFF; - padding: 4px 8px; - border-radius: 4px; - box-shadow: 0 2px 2px rgba(0,0,0,0.26); - cursor: pointer; -} -.CTA_Liste>svg{ - transition: 0.2s; -} -.CTA_Liste:hover{ - outline: 2px solid #424242; -} -.listeOff svg{ - transform: rotate(180deg); -} -.listeOff .syntheseModule, -.listeOff .eval{ - display: none; -} - -.moduleOnOff>.syntheseModule, -.moduleOnOff>.eval{ - display: none; -} -.listeOff .moduleOnOff>.syntheseModule, -.listeOff .moduleOnOff>.eval{ - display: flex !important; -} - -.listeOff .ue::before, -.listeOff .module::before, -.moduleOnOff .ue::before, -.moduleOnOff .module::before{ - transform: rotate(0); -} -.listeOff .moduleOnOff .ue::before, -.listeOff .moduleOnOff .module::before{ - transform: rotate(180deg) !important; -} - -/***********************/ -/* Options d'affichage */ -/***********************/ -.hide_abs .absences, -.hide_abs_modules .module>.absences, -.hide_coef .synthese em, -.hide_coef .eval>em, -.hide_date_inscr .dateInscription, -.hide_ects .ects{ - display: none; -} - -.module>.absences, -.module .moyenne, -.module .info{ - display: none; -} - -/************/ -/* Etudiant */ -/************/ -.info_etudiant{ - color: #000; - text-decoration: none; -} -.etudiant{ - display: flex; - align-items: center; - gap: 16px; - border-color: var(--couleurPrincipale); - background: var(--couleurPrincipale); - color: rgb(0, 0, 0); -} -.civilite{ - font-weight: bold; - font-size: 130%; -} - -/************/ -/* Semestre */ -/************/ -.flex{ - display: flex; - gap: 16px; -} -.infoSemestre{ - display: flex; - flex-wrap: wrap; - justify-content: center; - gap: 4px; - flex: none; -} -.infoSemestre>div{ - border: 1px solid var(--couleurIntense); - padding: 4px 8px; - border-radius: 4px; - display: grid; - grid-template-columns: auto auto; - column-gap: 4px; -} -.infoSemestre>div:nth-child(1){ - margin-right: auto; -} -.infoSemestre>div>div:nth-child(even){ - text-align: right; -} -.rang{ - text-decoration: underline var(--couleurIntense); -} -.decision{ - margin: 5px 0; - font-weight: bold; - font-size: 20px; - text-decoration: underline var(--couleurIntense); -} -.enteteSemestre{ - color: black; - font-weight: bold; - font-size: 20px; - margin-bottom: 4px; -} - -/***************/ -/* Synthèse */ -/***************/ -.synthese .ue, -.synthese h3{ - background: var(--couleurFondTitresUE); -} -.synthese em, -.eval em{ - opacity: 0.6; - min-width: 80px; - display: inline-block; -} - -/***************/ -/* Evaluations */ -/***************/ -.module, .ue { - background: var(--couleurSecondaire); - color: #000; - padding: 4px 32px; - border-radius: 4px; - display: flex; - gap: 16px; - margin: 4px 0 2px 0; - overflow-x: auto; - overflow-y: hidden; - cursor: pointer; - position: relative; -} -.module::before, .ue::before { - content:url("data:image/svg+xml;utf8,"); - width: 26px; - height: 26px; - position: absolute; - bottom: 0; - left: 50%; - margin-left: -13px; - transform: rotate(180deg); - transition: 0.2s; -} -h3{ - display: flex; - align-items: center; - margin: 0 auto 0 0; - position: sticky; - left: -32px; - z-index: 1; - font-size: 16px; - background: var(--couleurSecondaire); -} -.sae .module, .sae h3{ - background: var(--couleurFondTitresSAE); -} - -.moyenne{ - font-weight: bold; - text-align: right; -} -.info{ - opacity: 0.9; -} -.syntheseModule{ - cursor: pointer; -} -.eval, .syntheseModule{ - position: relative; - display: flex; - justify-content: space-between; - margin: 0 0 0 28px; - padding: 0px 4px; - border-bottom: 1px solid #aaa; -} -.eval>div, .syntheseModule>div{ - display: flex; - gap: 4px; -} - -.eval:hover, .syntheseModule:hover{ - background: var(--couleurSurlignage); - /* color: #FFF; */ -} -.complement{ - pointer-events:none; - position: absolute; - bottom: 100%; - right: 0; - padding: 8px; - border-radius: 4px; - background: #FFF; - color: #000; - border: 1px solid var(--couleurIntense); - opacity: 0; - display: grid !important; - grid-template-columns: auto auto; - gap: 0 !important; - column-gap: 4px !important; -} -.eval:hover .complement{ - opacity: 1; - z-index: 1; -} -.complement>div:nth-child(even){ - text-align: right; -} -.complement>div:nth-child(1), -.complement>div:nth-child(2){ - font-weight: bold; -} -.complement>div:nth-child(1), -.complement>div:nth-child(7){ - margin-bottom: 8px; -} - -.absences{ - display: grid; - grid-template-columns: auto auto; - column-gap: 4px; - text-align: right; - border-left: 1px solid; - padding-left: 16px; -} -.absences>div:nth-child(1), -.absences>div:nth-child(2){ - font-weight: bold; -} \ No newline at end of file +/* Bulletin BUT, Seb. L. 2021-12-06 */ +/*******************/ +/* Styles généraux */ +/*******************/ +.wait{ + width: 60px; + height: 6px; + margin: auto; + background: #424242; /* la réponse à tout */ + animation: wait .4s infinite alternate; +} +@keyframes wait{ + 100%{transform: translateY(40px) rotate(1turn);} +} +main{ + --couleurPrincipale: rgb(240,250,255); + --couleurFondTitresUE: rgb(206,255,235); + --couleurFondTitresRes: rgb(125, 170, 255); + --couleurFondTitresSAE: rgb(211, 255, 255); + --couleurSecondaire: #fec; + --couleurIntense: #c09; + --couleurSurlignage: rgba(232, 255, 132, 0.47); + max-width: 1000px; + margin: auto; + display: none; +} +.ready .wait{display: none;} +.ready main{display: block;} +h2{ + margin: 0; + color: black; +} +section{ + background: #FFF; + border-radius: 16px; + border: 1px solid #AAA; + padding: 16px 32px; + margin: 8px 0; +} +section>div:nth-child(1){ + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; +} +.CTA_Liste{ + display: flex; + gap: 4px; + align-items: center; + background: var(--couleurIntense); + color: #FFF; + padding: 4px 8px; + border-radius: 4px; + box-shadow: 0 2px 2px rgba(0,0,0,0.26); + cursor: pointer; +} +.CTA_Liste>svg{ + transition: 0.2s; +} +.CTA_Liste:hover{ + outline: 2px solid #424242; +} +.listeOff svg{ + transform: rotate(180deg); +} +.listeOff .syntheseModule, +.listeOff .eval{ + display: none; +} + +.moduleOnOff>.syntheseModule, +.moduleOnOff>.eval{ + display: none; +} +.listeOff .moduleOnOff>.syntheseModule, +.listeOff .moduleOnOff>.eval{ + display: flex !important; +} + +.listeOff .ue::before, +.listeOff .module::before, +.moduleOnOff .ue::before, +.moduleOnOff .module::before{ + transform: rotate(0); +} +.listeOff .moduleOnOff .ue::before, +.listeOff .moduleOnOff .module::before{ + transform: rotate(180deg) !important; +} + +/***********************/ +/* Options d'affichage */ +/***********************/ +.hide_abs .absencesRecap, +/*.hide_abs .absences,*/ +.hide_abs_modules .module>.absences, +.hide_coef .synthese em, +.hide_coef .eval>em, +.hide_date_inscr .dateInscription, +.hide_ects .ects{ + display: none; +} + +/*.module>.absences,*/ +.module .moyenne, +.module .info{ + display: none; +} + +/************/ +/* Etudiant */ +/************/ +.info_etudiant{ + color: #000; + text-decoration: none; +} +.etudiant{ + display: flex; + align-items: center; + gap: 16px; + border-color: var(--couleurPrincipale); + background: var(--couleurPrincipale); + color: rgb(0, 0, 0); +} +.civilite{ + font-weight: bold; + font-size: 130%; +} + +/************/ +/* Semestre */ +/************/ +.flex{ + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} +.infoSemestre{ + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 4px; +} +.infoSemestre>div{ + border: 1px solid var(--couleurIntense); + padding: 4px 8px; + border-radius: 4px; + display: grid; + grid-template-columns: auto auto; + column-gap: 4px; + flex: none; +} +.infoSemestre>div:nth-child(1){ + margin-right: auto; +} +.infoSemestre>div>div:nth-child(even){ + text-align: right; +} +.rang{ + text-decoration: underline var(--couleurIntense); +} +.decision{ + margin: 5px 0; + font-weight: bold; + font-size: 20px; + text-decoration: underline var(--couleurIntense); +} +.enteteSemestre{ + color: black; + font-weight: bold; + font-size: 20px; + margin-bottom: 4px; +} +/***************/ +/* Zone custom */ +/***************/ +.custom:empty{ + display: none; +} + +/***************/ +/* Synthèse */ +/***************/ +.synthese .ue, +.synthese h3{ + background: var(--couleurFondTitresUE); +} +.synthese em, +.eval em{ + opacity: 0.6; + min-width: 80px; + display: inline-block; +} +.ueBonus, +.ueBonus h3{ + background: var(--couleurFondTitresSAE) !important; + color: #000 !important; +} + +/***************/ +/* Evaluations */ +/***************/ +.evaluations>div, +.sae>div{ + scroll-margin-top: 60px; +} +.module, .ue { + background: var(--couleurSecondaire); + color: #000; + padding: 4px 32px; + border-radius: 4px; + display: flex; + gap: 16px; + margin: 4px 0 2px 0; + overflow-x: auto; + overflow-y: hidden; + cursor: pointer; + position: relative; +} +.module::before, .ue::before { + content:url("data:image/svg+xml;utf8,"); + width: 26px; + height: 26px; + position: absolute; + bottom: 0; + left: calc(50% - 13px); + transform: rotate(180deg); + transition: 0.2s; +} +@media screen and (max-width: 1000px) { + /* Placer le chevron à gauche au lieu du milieu */ + .module::before, .ue::before { + left: 2px; + bottom: calc(50% - 13px); + } +} +h3{ + display: flex; + align-items: center; + margin: 0 auto 0 0; + position: sticky; + left: -32px; + z-index: 1; + font-size: 16px; + background: var(--couleurSecondaire); +} +.sae .module, .sae h3{ + background: var(--couleurFondTitresSAE); +} + +.moyenne{ + font-weight: bold; + text-align: right; +} +.info{ + opacity: 0.9; +} +.syntheseModule{ + cursor: pointer; +} +.eval, .syntheseModule{ + position: relative; + display: flex; + justify-content: space-between; + margin: 0 0 0 28px; + padding: 0px 4px; + border-bottom: 1px solid #aaa; +} +.eval>div, .syntheseModule>div{ + display: flex; + gap: 4px; +} + +.eval:hover, .syntheseModule:hover{ + background: var(--couleurSurlignage); + /* color: #FFF; */ +} +.complement{ + pointer-events:none; + position: absolute; + bottom: 100%; + right: 0; + padding: 8px; + border-radius: 4px; + background: #FFF; + color: #000; + border: 1px solid var(--couleurIntense); + opacity: 0; + display: grid !important; + grid-template-columns: auto auto; + gap: 0 !important; + column-gap: 4px !important; +} +.eval:hover .complement{ + opacity: 1; + z-index: 1; +} +.complement>div:nth-child(even){ + text-align: right; +} +.complement>div:nth-child(1), +.complement>div:nth-child(2){ + font-weight: bold; +} +.complement>div:nth-child(1), +.complement>div:nth-child(7){ + margin-bottom: 8px; +} + +/*.absences{ + display: grid; + grid-template-columns: auto auto; + column-gap: 4px; + text-align: right; + border-left: 1px solid; + padding-left: 16px; +} +.absences>div:nth-child(1), +.absences>div:nth-child(2){ + font-weight: bold; +}*/ diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index ecb4f3b9af..c5758cb5fd 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -881,6 +881,19 @@ div.sco_help { span.wtf-field ul.errors li { color: red; } + +#bonus_description { + color:rgb(6, 73, 6); + padding: 5px; + margin-top:5px; + border: 2px solid blue; + border-radius: 5px; + background-color: cornsilk; +} +#bonus_description div.bonus_description_head{ + font-weight: bold; +} + .configuration_logo div.img { } @@ -1308,6 +1321,20 @@ td.formsemestre_status_cell { white-space: nowrap; } +span.mod_coef_indicator, span.ue_color_indicator { + display:inline-block; + width: 10px; + height: 10px; +} +span.mod_coef_indicator_zero { + display:inline-block; + width: 9px; + height: 9px; + border: 1px solid rgb(156, 156, 156); +} + + + span.status_ue_acro { font-weight: bold; } span.status_ue_title { font-style: italic; padding-left: 1cm;} span.status_module_cat { font-weight: bold; } @@ -1499,6 +1526,16 @@ table.moduleimpl_evaluations td.eval_poids { color:rgb(0, 0, 255); } +span.eval_coef_ue { + color:rgb(6, 73, 6); + font-style: normal; + font-size: 80%; + margin-right: 2em; +} +span.eval_coef_ue_titre { + +} + /* Formulaire edition des partitions */ form#editpart table { border: 1px solid gray; diff --git a/app/static/js/configuration.js b/app/static/js/config_logos.js similarity index 54% rename from app/static/js/configuration.js rename to app/static/js/config_logos.js index b537d572f9..56b7cd27d5 100644 --- a/app/static/js/configuration.js +++ b/app/static/js/config_logos.js @@ -1,5 +1,5 @@ function submit_form() { - $("#configuration_form").submit(); + $("#config_logos_form").submit(); } $(function () { diff --git a/app/static/js/edit_ue.js b/app/static/js/edit_ue.js index 34e8e9c8b1..8424496ca9 100644 --- a/app/static/js/edit_ue.js +++ b/app/static/js/edit_ue.js @@ -3,8 +3,26 @@ $().ready(function () { update_ue_list(); $("#tf_ue_code").bind("keyup", update_ue_list); + + $("select#tf_type").change(function () { + update_bonus_description(); + }); + update_bonus_description(); }); +function update_bonus_description() { + var ue_type = $("#tf_type")[0].value; + if (ue_type == "1") { /* UE SPORT */ + $("#bonus_description").show(); + var query = "/ScoDoc/get_bonus_description/default"; + $.get(query, '', function (data) { + $("#bonus_description").html(data); + }); + } else { + $("#bonus_description").html(""); + $("#bonus_description").hide(); + } +} function update_ue_list() { var ue_id = $("#tf_ue_id")[0].value; diff --git a/app/static/js/releve-but.js b/app/static/js/releve-but.js index 521e97da76..aceec68ca0 100644 --- a/app/static/js/releve-but.js +++ b/app/static/js/releve-but.js @@ -15,13 +15,10 @@ class releveBUT extends HTMLElement { /* Style du module */ const styles = document.createElement('link'); styles.setAttribute('rel', 'stylesheet'); - styles.setAttribute('href', '/ScoDoc/static/css/releve-but.css'); - /* variante "ScoDoc" ou "Passerelle" (ENT) ? */ - if (location.href.split("/")[3] == "ScoDoc") { /* un peu osé... */ - styles.setAttribute('href', '/ScoDoc/static/css/releve-but.css'); + if (location.href.split("/")[3] == "ScoDoc") { + styles.setAttribute('href', '/ScoDoc/static/css/releve-but.css'); // Scodoc } else { - // Passerelle - styles.setAttribute('href', '/assets/styles/releve-but.css'); + styles.setAttribute('href', '/assets/styles/releve-but.css'); // Passerelle } this.shadow.appendChild(styles); } @@ -49,6 +46,8 @@ class releveBUT extends HTMLElement { this.showSynthese(data); this.showEvaluations(data); + this.showCustom(data); + this.setOptions(data.options); this.shadow.querySelectorAll(".CTA_Liste").forEach(e => { @@ -57,7 +56,7 @@ class releveBUT extends HTMLElement { this.shadow.querySelectorAll(".ue, .module").forEach(e => { e.addEventListener("click", this.moduleOnOff) }) - this.shadow.querySelectorAll(".syntheseModule").forEach(e => { + this.shadow.querySelectorAll(":not(.ueBonus)+.syntheseModule").forEach(e => { e.addEventListener("click", this.goTo) }) @@ -77,6 +76,11 @@ class releveBUT extends HTMLElement {
+ + + +
+ @@ -169,8 +173,8 @@ class releveBUT extends HTMLElement { output += `
- Numéro étudiant : ${data.etudiant.code_nip} - - Code INE : ${data.etudiant.code_ine} + Numéro étudiant : ${data.etudiant.code_nip || "~"} - + Code INE : ${data.etudiant.code_ine || "~"}
${data.formation.titre}
`; @@ -183,6 +187,13 @@ class releveBUT extends HTMLElement { this.shadow.querySelector(".infoEtudiant").innerHTML = output; } + /*******************************/ + /* Affichage local */ + /*******************************/ + showCustom(data) { + this.shadow.querySelector(".custom").innerHTML = data.custom || ""; + } + /*******************************/ /* Information sur le semestre */ /*******************************/ @@ -196,6 +207,11 @@ class releveBUT extends HTMLElement {
Max. promo. :
${data.semestre.notes.max}
Moy. promo. :
${data.semestre.notes.moy}
Min. promo. :
${data.semestre.notes.min}
+ +
+
Absences
+
N.J. ${data.semestre.absences?.injustifie ?? "-"}
+
Total ${data.semestre.absences?.total ?? "-"}
`; /*${data.semestre.groupes.map(groupe => { return ` @@ -210,7 +226,7 @@ class releveBUT extends HTMLElement { }).join("") }*/ this.shadow.querySelector(".infoSemestre").innerHTML = output; - /*this.shadow.querySelector(".decision").innerHTML = data.semestre.decision.code;*/ + this.shadow.querySelector(".decision").innerHTML = data.semestre.decision?.code || ""; } /*******************************/ @@ -219,32 +235,44 @@ class releveBUT extends HTMLElement { showSynthese(data) { let output = ``; Object.entries(data.ues).forEach(([ue, dataUE]) => { - output += ` - -
-
-

- ${(dataUE.competence) ? dataUE.competence + " - " : ""}${ue} -

-
-
Moyenne : ${dataUE.moyenne?.value || "-"}
-
- Bonus : ${dataUE.bonus || 0} - - Malus : ${dataUE.malus || 0} -  - - ECTS : ${dataUE.ECTS.acquis} / ${dataUE.ECTS.total} - -
-
-
-
Abs N.J.
${dataUE.absences?.injustifie || 0}
-
Total
${dataUE.absences?.total || 0}
+ if (dataUE.type == 1) { // UE Sport / Bonus + output += ` +
+
+

Bonus

+
${dataUE.bonus_description}
+ ${this.ueSport(dataUE.modules)}
- ${this.synthese(data, dataUE.ressources)} - ${this.synthese(data, dataUE.saes)} -
- `; + `; + } else { + output += ` +
+
+

+ ${ue}${(dataUE.titre) ? " - " + dataUE.titre : ""} +

+
+
Moyenne : ${dataUE.moyenne?.value || "-"}
+
+ Bonus : ${dataUE.bonus || 0} - + Malus : ${dataUE.malus || 0} +  - + ECTS : ${dataUE.ECTS.acquis} / ${dataUE.ECTS.total} + +
+
`; + /*
+
Abs N.J.
${dataUE.absences?.injustifie || 0}
+
Total
${dataUE.absences?.total || 0}
+
*/ + output += ` +
+ ${this.synthese(data, dataUE.ressources)} + ${this.synthese(data, dataUE.saes)} +
+ `; + } }); this.shadow.querySelector(".synthese").innerHTML = output; } @@ -252,7 +280,7 @@ class releveBUT extends HTMLElement { let output = ""; Object.entries(modules).forEach(([module, dataModule]) => { let titre = data.ressources[module]?.titre || data.saes[module]?.titre; - let url = data.ressources[module]?.url || data.saes[module]?.url; + //let url = data.ressources[module]?.url || data.saes[module]?.url; output += `
${module} - ${titre}
@@ -265,6 +293,23 @@ class releveBUT extends HTMLElement { }) return output; } + ueSport(modules) { + let output = ""; + Object.values(modules).forEach((module) => { + Object.values(module.evaluations).forEach((evaluation) => { + output += ` +
+
${module.titre} - ${evaluation.description}
+
+ ${evaluation.note.value ?? "-"} + Coef. ${evaluation.coef} +
+
+ `; + }) + }) + return output; + } /*******************************/ /* Evaluations */ @@ -305,7 +350,7 @@ class releveBUT extends HTMLElement { evaluations.forEach((evaluation) => { output += `
-
${this.URL(evaluation.url, evaluation.description)}
+
${this.URL(evaluation.url, evaluation.description || "Évaluation")}
${evaluation.note.value} Coef. ${evaluation.coef} @@ -363,4 +408,4 @@ class releveBUT extends HTMLElement { } } -customElements.define('releve-but', releveBUT); \ No newline at end of file +customElements.define('releve-but', releveBUT); diff --git a/app/templates/config_codes_decisions.html b/app/templates/config_codes_decisions.html new file mode 100644 index 0000000000..0c2f32b240 --- /dev/null +++ b/app/templates/config_codes_decisions.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% block app_content %} +

Configuration des codes de décision exportés vers Apogée

+ + +
+

Ces codes (ADM, AJ, ...) sont utilisés pour représenter les décisions de jury +et les validations de semestres ou d'UE. les valeurs indiquées ici sont utilisées +dans les exports Apogée. +

+

Ne les modifier que si vous savez ce que vous faites ! +

+
+
+
+ {{ wtf.quick_form(form) }} +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/app/templates/config_logos.html b/app/templates/config_logos.html new file mode 100644 index 0000000000..f4bd543cb5 --- /dev/null +++ b/app/templates/config_logos.html @@ -0,0 +1,125 @@ +{# -*- mode: jinja-html -*- #} +{% extends 'base.html' %} +{% import 'bootstrap/wtf.html' as wtf %} + +{% macro render_field(field, with_label=True) %} +
+ {% if with_label %} + {{ field.label }} : + {% endif %} + {{ field(**kwargs)|safe }} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+
+{% endmacro %} + +{% macro render_add_logo(add_logo_form) %} +
+

Ajouter un logo

+ {{ add_logo_form.hidden_tag() }} + {{ render_field(add_logo_form.name) }} + {{ render_field(add_logo_form.upload) }} + {{ render_field(add_logo_form.do_insert, False, onSubmit="submit_form") }} +
+{% endmacro %} + +{% macro render_logo(dept_form, logo_form) %} +
+ {{ logo_form.hidden_tag() }} + {% if logo_form.titre %} + + +
+

{{ logo_form.titre }}

+
+
{{ logo_form.description or "" }}
+ + + {% else %} + + + +

Logo personalisé: {{ logo_form.logo_id.data }}

+
+ {{ logo_form.description or "" }} + + + {% endif %} + + +
+ pas de logo chargé +
+ + +

{{ logo_form.logo.logoname }} (Format: {{ logo_form.logo.suffix }})

+ Taille: {{ logo_form.logo.size }} px + {% if logo_form.logo.mm %}   /   {{ logo_form.logo.mm }} mm {% endif %}
+ Aspect ratio: {{ logo_form.logo.aspect_ratio }}
+ Usage: {{ logo_form.logo.get_usage() }} + + +

Modifier l'image

+ {{ render_field(logo_form.upload, False, onchange="submit_form()") }} + {% if logo_form.can_delete %} +

Supprimer l'image

+ {{ render_field(logo_form.do_delete, False, onSubmit="submit_form()") }} + {% endif %} + + +
+{% endmacro %} + +{% macro render_logos(dept_form) %} + + {% for logo_entry in dept_form.logos.entries %} + {% set logo_form = logo_entry.form %} + {{ render_logo(dept_form, logo_form) }} + {% else %} +

+

Aucun logo défini en propre à ce département

+

+ {% endfor %} +
+{% endmacro %} + +{% block app_content %} + + + + + + {{ form.hidden_tag() }} + + + +{% endblock %} \ No newline at end of file diff --git a/app/templates/configuration.html b/app/templates/configuration.html index d4df6ed4ae..823772de52 100644 --- a/app/templates/configuration.html +++ b/app/templates/configuration.html @@ -19,102 +19,53 @@
{% endmacro %} -{% macro render_add_logo(add_logo_form) %} -
-

Ajouter un logo

- {{ add_logo_form.hidden_tag() }} - {{ render_field(add_logo_form.name) }} - {{ render_field(add_logo_form.upload) }} - {{ render_field(add_logo_form.do_insert, False, onSubmit="submit_form") }} -
-{% endmacro %} - -{% macro render_logo(dept_form, logo_form) %} -
- {{ logo_form.hidden_tag() }} - {% if logo_form.titre %} - - -

{{ logo_form.titre }}

-
{{ logo_form.description or "" }}
- - - {% else %} - - -

Logo personalisé: {{ logo_form.logo_id.data }}

- {{ logo_form.description or "" }} - - - {% endif %} - - -
- pas de logo chargé
- -

{{ logo_form.logo.logoname }} (Format: {{ logo_form.logo.suffix }})

- Taille: {{ logo_form.logo.size }} px - {% if logo_form.logo.mm %}   /   {{ logo_form.logo.mm }} mm {% endif %}
- Aspect ratio: {{ logo_form.logo.aspect_ratio }}
- Usage: {{ logo_form.logo.get_usage() }} - -

Modifier l'image

- {{ render_field(logo_form.upload, False, onchange="submit_form()") }} - {% if logo_form.can_delete %} -

Supprimer l'image

- {{ render_field(logo_form.do_delete, False, onSubmit="submit_form()") }} - {% endif %} - - -
-{% endmacro %} - -{% macro render_logos(dept_form) %} - - {% for logo_entry in dept_form.logos.entries %} - {% set logo_form = logo_entry.form %} - {{ render_logo(dept_form, logo_form) }} - {% else %} -

Aucun logo défini en propre à ce département

- {% endfor %} -
-{% endmacro %} - {% block app_content %} - - -
{{ form.hidden_tag() }} -
-{% endblock %} \ No newline at end of file + +{% endblock %} + +{% block scripts %} +{{ super() }} + + +{% endblock %} diff --git a/app/templates/pn/form_mods.html b/app/templates/pn/form_mods.html index cba2834780..e5ca79e5ab 100644 --- a/app/templates/pn/form_mods.html +++ b/app/templates/pn/form_mods.html @@ -71,12 +71,12 @@ {% endfor %} - {% if editable and formation.ues.count() and formation.ues[0].matieres.count() %} + {% if editable and matiere_parent %}
  • {{create_element_msg}}
  • diff --git a/app/templates/pn/form_ues.html b/app/templates/pn/form_ues.html index 8700116c18..d6b1fe1272 100644 --- a/app/templates/pn/form_ues.html +++ b/app/templates/pn/form_ues.html @@ -30,6 +30,8 @@ }}">{% if editable and not ue.modules.count() %}{{icons.delete|safe}}{% else %}{{icons.delete_disabled|safe}}{% endif %} + {{ue.acronyme}} {{ue.titre}} diff --git a/app/templates/scodoc/help/modules.html b/app/templates/scodoc/help/modules.html index d01a5d3514..cd6e0767b0 100644 --- a/app/templates/scodoc/help/modules.html +++ b/app/templates/scodoc/help/modules.html @@ -24,4 +24,24 @@ la documentation.

    {%endif%} + + {% if formsemestres %} +

    + Ce module est utilisé dans des semestres déjà mis en place, il faut prêter attention + aux conséquences des changements effectués ici: par exemple les coefficients vont modifier + les notes moyennes calculées. Les modules déjà utilisés ne peuvent pas être changés de semestre, ni détruits. + Si vous souhaitez faire cela, allez d'abord modifier les semestres concernés pour déselectionner le module. +

    +

    Semestres utilisant ce module:

    + + {%endif%} +
    \ No newline at end of file diff --git a/app/views/notes.py b/app/views/notes.py index be32ead8b0..b170363160 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -290,20 +290,26 @@ def formsemestre_bulletinetud( if etudid: etud = models.Identite.query.get_or_404(etudid) elif code_nip: - etud = models.Identite.query.filter_by( - code_nip=str(code_nip) - ).first_or_404() + etud = ( + models.Identite.query.filter_by(code_nip=str(code_nip)) + .filter_by(dept_id=formsemestre.dept_id) + .first_or_404() + ) elif code_ine: - etud = models.Identite.query.filter_by( - code_ine=str(code_ine) - ).first_or_404() + etud = ( + models.Identite.query.filter_by(code_ine=str(code_ine)) + .filter_by(dept_id=formsemestre.dept_id) + .first_or_404() + ) else: raise ScoValueError( "Paramètre manquant: spécifier code_nip ou etudid ou code_ine" ) if format == "json": r = bulletin_but.BulletinBUT(formsemestre) - return jsonify(r.bulletin_etud(etud, formsemestre)) + return jsonify( + r.bulletin_etud(etud, formsemestre, force_publishing=force_publishing) + ) elif format == "html": return render_template( "but/bulletin.html", @@ -314,6 +320,7 @@ def formsemestre_bulletinetud( formsemestre_id=formsemestre_id, etudid=etudid, format="json", + force_publishing=1, # pour ScoDoc lui même ), sco=ScoData(), ) diff --git a/app/views/scodoc.py b/app/views/scodoc.py index 6a9bb58a26..0b37da5445 100644 --- a/app/views/scodoc.py +++ b/app/views/scodoc.py @@ -32,50 +32,40 @@ Emmanuel Viennet, 2021 """ import datetime import io - -import wtforms.validators - -from app.auth.models import User -import os +import re import flask from flask import abort, flash, url_for, redirect, render_template, send_file from flask import request -from flask.app import Flask import flask_login from flask_login.utils import login_required, current_user -from flask_wtf import FlaskForm -from flask_wtf.file import FileField, FileAllowed -from werkzeug.exceptions import BadRequest, NotFound -from wtforms import SelectField, SubmitField, FormField, validators, Form, FieldList -from wtforms.fields import IntegerField -from wtforms.fields.simple import BooleanField, StringField, TextAreaField, HiddenField -from wtforms.validators import ValidationError, DataRequired, Email, EqualTo +from PIL import Image as PILImage + +from werkzeug.exceptions import BadRequest, NotFound + -import app from app import db -from app.forms.main import config_forms +from app.auth.models import User +from app.forms.main import config_logos, config_main from app.forms.main.create_dept import CreateDeptForm +from app.forms.main.config_apo import CodesDecisionsForm +from app import models from app.models import Departement, Identite from app.models import departements from app.models import FormSemestre, FormSemestreInscription -import sco_version -from app.scodoc import sco_logos +from app.models import ScoDocSiteConfig +from app.scodoc import sco_codes_parcours, sco_logos from app.scodoc import sco_find_etud from app.scodoc import sco_utils as scu from app.decorators import ( admin_required, scodoc7func, scodoc, - permission_required_compat_scodoc7, - permission_required, ) from app.scodoc.sco_exceptions import AccessDenied -from app.scodoc.sco_logos import find_logo from app.scodoc.sco_permissions import Permission from app.views import scodoc_bp as bp - -from PIL import Image as PILImage +import sco_version @bp.route("/") @@ -133,6 +123,28 @@ def toggle_dept_vis(dept_id): return redirect(url_for("scodoc.index")) +@bp.route("/ScoDoc/config_codes_decisions", methods=["GET", "POST"]) +@admin_required +def config_codes_decisions(): + """Form config codes decisions""" + form = CodesDecisionsForm() + if request.method == "POST" and form.cancel.data: # cancel button + return redirect(url_for("scodoc.index")) + if form.validate_on_submit(): + for code in models.config.CODES_SCODOC_TO_APO: + ScoDocSiteConfig.set_code_apo(code, getattr(form, code).data) + flash(f"Codes décisions enregistrés.") + return redirect(url_for("scodoc.index")) + elif request.method == "GET": + for code in models.config.CODES_SCODOC_TO_APO: + getattr(form, code).data = ScoDocSiteConfig.get_code_apo(code) + return render_template( + "config_codes_decisions.html", + form=form, + title="Configuration des codes de décisions", + ) + + @bp.route("/ScoDoc/table_etud_in_accessible_depts", methods=["POST"]) @login_required def table_etud_in_accessible_depts(): @@ -239,10 +251,37 @@ def about(scodoc_dept=None): @bp.route("/ScoDoc/configuration", methods=["GET", "POST"]) @admin_required def configuration(): - auth_name = str(current_user) + "Page de configuration globale" if not current_user.is_administrator(): - raise AccessDenied("invalid user (%s) must be SuperAdmin" % auth_name) - return config_forms.configuration() + raise AccessDenied("invalid user (%s) must be SuperAdmin" % current_user) + return config_main.configuration() + + +@bp.route("/ScoDoc/get_bonus_description/", methods=["GET"]) +def get_bonus_description(bonus_name: str): + "description text/html du bonus" + if bonus_name == "default": + bonus_name = "" + bonus_class = ScoDocSiteConfig.get_bonus_sport_class_from_name(bonus_name) + text = bonus_class.__doc__ + fields = re.split(r"\n\n", text, maxsplit=1) + if len(fields) > 1: + first_line, text = fields + else: + first_line, text = "", fields[0] + + return f"""
    {first_line}
    +
    {text}
    + """ + + +@bp.route("/ScoDoc/configure_logos", methods=["GET", "POST"]) +@admin_required +def configure_logos(): + "Page de configuration des logos (globale)" + if not current_user.is_administrator(): + raise AccessDenied("invalid user (%s) must be SuperAdmin" % current_user) + return config_logos.config_logos() SMALL_SIZE = (200, 200) @@ -257,14 +296,16 @@ def _return_logo(name="header", dept_id="", small=False, strict: bool = True): suffix = logo.suffix if small: with PILImage.open(logo.filepath) as im: - im.thumbnail(SMALL_SIZE) - stream = io.BytesIO() # on garde le même format (on pourrait plus simplement générer systématiquement du JPEG) fmt = { # adapt suffix to be compliant with PIL save format "PNG": "PNG", "JPG": "JPEG", "JPEG": "JPEG", }[suffix.upper()] + if fmt == "JPEG": + im = im.convert("RGB") + im.thumbnail(SMALL_SIZE) + stream = io.BytesIO() im.save(stream, fmt) stream.seek(0) return send_file(stream, mimetype=f"image/{fmt}") diff --git a/app/views/users.py b/app/views/users.py index cc9b9db01a..0655da5018 100644 --- a/app/views/users.py +++ b/app/views/users.py @@ -62,7 +62,7 @@ from app.decorators import ( permission_required, ) -from app.scodoc import html_sco_header, sco_import_users, sco_excel +from app.scodoc import html_sco_header, sco_import_users, sco_excel, sco_roles_default from app.scodoc import sco_users from app.scodoc import sco_utils as scu from app.scodoc import sco_xml @@ -81,7 +81,7 @@ _l = _ class ChangePasswordForm(FlaskForm): user_name = HiddenField() old_password = PasswordField(_l("Identifiez-vous")) - new_password = PasswordField(_l("Nouveau mot de passe")) + new_password = PasswordField(_l("Nouveau mot de passe de l'utilisateur")) bis_password = PasswordField( _l("Répéter"), validators=[ @@ -150,11 +150,12 @@ def user_info(user_name, format="json"): @permission_required(Permission.ScoUsersAdmin) @scodoc7func def create_user_form(user_name=None, edit=0, all_roles=1): - "form. création ou edition utilisateur" + "form. création ou édition utilisateur" if user_name is not None: # scodoc7func converti en int ! user_name = str(user_name) + Role.insert_roles() # assure la mise à jour des rôles en base auth_dept = current_user.dept - from_mail = current_user.email + from_mail = current_app.config["SCODOC_MAIL_FROM"] # current_user.email initvalues = {} edit = int(edit) all_roles = int(all_roles) @@ -191,7 +192,7 @@ def create_user_form(user_name=None, edit=0, all_roles=1): else: # Les rôles standards créés à l'initialisation de ScoDoc: standard_roles = [ - Role.get_named_role(r) for r in ("Ens", "Secr", "Admin", "RespPe") + Role.get_named_role(r) for r in sco_roles_default.ROLES_ATTRIBUABLES_DEPT ] # Départements auxquels ont peut associer des rôles via ce dialogue: # si SuperAdmin, tous les rôles standards dans tous les départements @@ -215,6 +216,11 @@ def create_user_form(user_name=None, edit=0, all_roles=1): editable_roles_set = { (r, dept) for r in standard_roles for dept in administrable_dept_acronyms } + if current_user.is_administrator(): + editable_roles_set |= { + (Role.get_named_role(r), "") + for r in sco_roles_default.ROLES_ATTRIBUABLES_SCODOC + } # if not edit: submitlabel = "Créer utilisateur" @@ -577,8 +583,8 @@ def create_user_form(user_name=None, edit=0, all_roles=1): # A: envoi de welcome + procedure de reset # B: envoi de welcome seulement (mot de passe saisie dans le formulaire) # C: Aucun envoi (mot de passe saisi dans le formulaire) - if vals["welcome"] == "1": - if vals["reset_password:list"] == "1": + if vals["welcome"] != "1": + if vals["reset_password"] != "1": mode = Mode.WELCOME_AND_CHANGE_PASSWORD else: mode = Mode.WELCOME_ONLY diff --git a/config.py b/config.py index ae507b1e5d..fca2fc5148 100755 --- a/config.py +++ b/config.py @@ -26,6 +26,9 @@ class Config: SCODOC_ADMIN_LOGIN = os.environ.get("SCODOC_ADMIN_LOGIN") or "admin" ADMINS = [SCODOC_ADMIN_MAIL] SCODOC_ERR_MAIL = os.environ.get("SCODOC_ERR_MAIL") + # Le "from" des mails émis. Attention: peut être remplacée par la préférence email_from_addr: + SCODOC_MAIL_FROM = os.environ.get("SCODOC_MAIL_FROM") or ("no-reply@" + MAIL_SERVER) + BOOTSTRAP_SERVE_LOCAL = os.environ.get("BOOTSTRAP_SERVE_LOCAL") SCODOC_DIR = os.environ.get("SCODOC_DIR", "/opt/scodoc") SCODOC_VAR_DIR = os.environ.get("SCODOC_VAR_DIR", "/opt/scodoc-data") diff --git a/migrations/versions/28874ed6af64_augmente_taille_codes_apogee.py b/migrations/versions/28874ed6af64_augmente_taille_codes_apogee.py new file mode 100644 index 0000000000..7ac8c8c365 --- /dev/null +++ b/migrations/versions/28874ed6af64_augmente_taille_codes_apogee.py @@ -0,0 +1,84 @@ +"""augmente taille codes Apogée + +Revision ID: 28874ed6af64 +Revises: f40fbaf5831c +Create Date: 2022-01-19 22:57:59.678313 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "28874ed6af64" +down_revision = "f40fbaf5831c" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + op.alter_column( + "notes_formsemestre_etapes", + "etape_apo", + existing_type=sa.VARCHAR(length=24), + type_=sa.String(length=512), + existing_nullable=True, + ) + op.alter_column( + "notes_formsemestre_inscription", + "etape", + existing_type=sa.VARCHAR(length=24), + type_=sa.String(length=512), + existing_nullable=True, + ) + op.alter_column( + "notes_modules", + "code_apogee", + existing_type=sa.VARCHAR(length=24), + type_=sa.String(length=512), + existing_nullable=True, + ) + op.alter_column( + "notes_ue", + "code_apogee", + existing_type=sa.VARCHAR(length=24), + type_=sa.String(length=512), + existing_nullable=True, + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column( + "notes_ue", + "code_apogee", + existing_type=sa.String(length=512), + type_=sa.VARCHAR(length=24), + existing_nullable=True, + ) + op.alter_column( + "notes_modules", + "code_apogee", + existing_type=sa.String(length=512), + type_=sa.VARCHAR(length=24), + existing_nullable=True, + ) + op.alter_column( + "notes_formsemestre_inscription", + "etape", + existing_type=sa.String(length=512), + type_=sa.VARCHAR(length=24), + existing_nullable=True, + ) + op.alter_column( + "notes_formsemestre_etapes", + "etape_apo", + existing_type=sa.String(length=512), + type_=sa.VARCHAR(length=24), + existing_nullable=True, + ) + + # ### end Alembic commands ### diff --git a/migrations/versions/c95d5a3bf0de_couleur_ue.py b/migrations/versions/c95d5a3bf0de_couleur_ue.py new file mode 100644 index 0000000000..c44dcdb8ef --- /dev/null +++ b/migrations/versions/c95d5a3bf0de_couleur_ue.py @@ -0,0 +1,28 @@ +"""couleur UE + +Revision ID: c95d5a3bf0de +Revises: 28874ed6af64 +Create Date: 2022-01-24 21:44:55.205544 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "c95d5a3bf0de" +down_revision = "28874ed6af64" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("notes_ue", sa.Column("color", sa.Text(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("notes_ue", "color") + # ### end Alembic commands ### diff --git a/scodoc.py b/scodoc.py index 3db205e4b0..28dfe7cfee 100755 --- a/scodoc.py +++ b/scodoc.py @@ -21,9 +21,8 @@ from app import clear_scodoc_cache from app import models from app.auth.models import User, Role, UserRole -from app.models import ScoPreference from app.scodoc.sco_logos import make_logo_local -from app.models import Formation, UniteEns, Module +from app.models import Formation, UniteEns, Matiere, Module from app.models import FormSemestre, FormSemestreInscription from app.models import ModuleImpl, ModuleImplInscription from app.models import Identite @@ -63,6 +62,7 @@ def make_shell_context(): "logout_user": logout_user, "mapp": mapp, "models": models, + "Matiere": Matiere, "Module": Module, "ModuleImpl": ModuleImpl, "ModuleImplInscription": ModuleImplInscription, @@ -133,7 +133,7 @@ def user_create(username, role, dept, nom=None, prenom=None): # user-create "Create a new user" r = Role.get_named_role(role) if not r: - sys.stderr.write("user_create: role {r} does not exists\n".format(r=role)) + sys.stderr.write("user_create: role {r} does not exist\n".format(r=role)) return 1 u = User.query.filter_by(user_name=username).first() if u: @@ -289,20 +289,28 @@ def user_role(username, dept_acronym=None, add_role_name=None, remove_role_name= db.session.commit() +def abort_if_false(ctx, param, value): + if not value: + ctx.abort() + + @app.cli.command() +@click.option( + "--yes", + is_flag=True, + callback=abort_if_false, + expose_value=False, + prompt=f"""Attention: Cela va effacer toutes les données du département + (étudiants, notes, formations, etc) + Voulez-vous vraiment continuer ? + """, +) @click.argument("dept") def delete_dept(dept): # delete-dept """Delete existing departement""" from app.scodoc import notesdb as ndb from app.scodoc import sco_dept - click.confirm( - f"""Attention: Cela va effacer toutes les données du département {dept} - (étudiants, notes, formations, etc) - Voulez-vous vraiment continuer ? - """, - abort=True, - ) db.reflect() ndb.open_db_connection() d = models.Departement.query.filter_by(acronym=dept).first() diff --git a/tools/doc_poursuites_etudes/distrib/modeles/un_avis.tex b/tools/doc_poursuites_etudes/distrib/modeles/un_avis.tex index 05a6320f1f..302568fd89 100644 --- a/tools/doc_poursuites_etudes/distrib/modeles/un_avis.tex +++ b/tools/doc_poursuites_etudes/distrib/modeles/un_avis.tex @@ -17,7 +17,7 @@ % ************************************************************ % En-tête de l'avis % ************************************************************ -\begin{entete}{logos/logo_header} +\begin{entete}{logos/header} \textbf{\Huge{Avis de Poursuites d'Etudes}} \\ \ligne \\ \normalsize{Département **DeptFullName**} \\ diff --git a/tools/import_scodoc7_dept.py b/tools/import_scodoc7_dept.py index 597b9baf69..75f5e9a3bc 100644 --- a/tools/import_scodoc7_dept.py +++ b/tools/import_scodoc7_dept.py @@ -170,6 +170,11 @@ def import_scodoc7_dept(dept_id: str, dept_db_uri=None): logging.info(f"connecting to database {dept_db_uri}") cnx = psycopg2.connect(dept_db_uri) cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + # FIX : des dates aberrantes (dans le futur) peuvent tenir en SQL mais pas en Python + cursor.execute( + """UPDATE scolar_events SET event_date='2021-09-30' WHERE event_date > '2200-01-01'""" + ) + cnx.commit() # Create dept: dept = models.Departement(acronym=dept_id, description="migré de ScoDoc7") db.session.add(dept) @@ -374,6 +379,8 @@ def convert_object( new_ref = id_from_scodoc7[old_ref] elif (not is_table) and table_name in { "scolog", + "entreprise_correspondant", + "entreprise_contact", "etud_annotations", "notes_notes_log", "scolar_news", @@ -389,7 +396,6 @@ def convert_object( new_ref = None elif is_table and table_name in { "notes_semset_formsemestre", - "entreprise_contact", }: # pour anciennes installs où des relations n'avait pas été déclarées clés étrangères # eg: notes_semset_formsemestre.semset_id n'était pas une clé