diff --git a/README.md b/README.md index 209a2a01..f571216a 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,10 @@ Flask, SQLAlchemy, au lien de Python2/Zope dans les versions précédentes). ### État actuel (26 jan 22) - - 9.1 (master) reproduit l'ensemble des fonctions de ScoDoc 7 (donc pas de BUT), sauf: + - 9.1.5x (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.2 (branche refactor_nt) est la version de développement. + - 9.2 (branche dev92) est la version de développement. ### Lignes de commandes diff --git a/app/auth/models.py b/app/auth/models.py index 32768df0..329bc386 100644 --- a/app/auth/models.py +++ b/app/auth/models.py @@ -76,7 +76,9 @@ class User(UserMixin, db.Model): "Departement", foreign_keys=[Departement.acronym], primaryjoin=(dept == Departement.acronym), - lazy="dynamic", + lazy="select", + passive_deletes="all", + uselist=False, ) def __init__(self, **kwargs): @@ -236,7 +238,7 @@ class User(UserMixin, db.Model): def get_dept_id(self) -> int: "returns user's department id, or None" if self.dept: - return self._departement.first().id + return self._departement.id return None # Permissions management: diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 5b17c814..771746bf 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -9,14 +9,15 @@ import datetime from flask import url_for, g -from app.models.formsemestre import FormSemestre +from app.comp.res_but import ResultatsSemestreBUT +from app.models import FormSemestre, Identite from app.scodoc import sco_utils as scu from app.scodoc import sco_bulletins_json +from app.scodoc import sco_bulletins_pdf 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 class BulletinBUT: @@ -28,6 +29,7 @@ class BulletinBUT: def __init__(self, formsemestre: FormSemestre): """ """ self.res = ResultatsSemestreBUT(formsemestre) + self.prefs = sco_preferences.SemPreferences(formsemestre.id) def etud_ue_mod_results(self, etud, ue, modimpls) -> dict: "dict synthèse résultats dans l'UE pour les modules indiqués" @@ -84,7 +86,7 @@ class BulletinBUT: "saes": self.etud_ue_mod_results(etud, ue, res.saes), } if ue.type != UE_SPORT: - if sco_preferences.get_preference("bul_show_ue_rangs", res.formsemestre.id): + if self.prefs["bul_show_ue_rangs"]: rangs, effectif = res.ue_rangs[ue.id] rang = rangs[etud.id] else: @@ -155,9 +157,7 @@ class BulletinBUT: if e.visibulletin and ( modimpl_results.evaluations_etat[e.id].is_complete - or sco_preferences.get_preference( - "bul_show_all_evals", res.formsemestre.id - ) + or self.prefs["bul_show_all_evals"] ) ], } @@ -216,9 +216,11 @@ class BulletinBUT: 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 + def bulletin_etud( + self, etud: Identite, formsemestre, force_publishing=False + ) -> dict: + """Le bulletin de l'étudiant dans ce semestre: dict pour la version JSON / HTML. + - Si force_publishing, rempli le bulletin même si bul_hide_xml est vrai (bulletins non publiés). """ res = self.res @@ -239,7 +241,9 @@ class BulletinBUT: }, "formsemestre_id": formsemestre.id, "etat_inscription": etat_inscription, - "options": sco_preferences.bulletin_option_affichage(formsemestre.id), + "options": sco_preferences.bulletin_option_affichage( + formsemestre.id, self.prefs + ), } if not published: return d @@ -254,7 +258,7 @@ class BulletinBUT: "inscription": "", # inutilisé mais nécessaire pour le js de Seb. "groupes": [], # XXX TODO "absences": { - "injustifie": nbabsjust, + "injustifie": nbabs - nbabsjust, "total": nbabs, }, } @@ -312,3 +316,12 @@ class BulletinBUT: ) return d + + def bulletin_etud_complet(self, etud) -> dict: + """Bulletin dict complet avec toutes les infos pour les bulletins pdf""" + d = self.bulletin_etud(force_publishing=True) + d["filigranne"] = sco_bulletins_pdf.get_filigranne( + self.res.get_etud_etat(etud.id), self.prefs + ) + # XXX TODO A COMPLETER + raise NotImplementedError() diff --git a/app/but/bulletin_but_xml_compat.py b/app/but/bulletin_but_xml_compat.py index 69b8f568..73e06c4d 100644 --- a/app/but/bulletin_but_xml_compat.py +++ b/app/but/bulletin_but_xml_compat.py @@ -145,7 +145,7 @@ def bulletin_but_xml_compat( doc.append(Element("note_max", value="20")) # notes toujours sur 20 doc.append(Element("bonus_sport_culture", value=str(bonus))) # Liste les UE / modules /evals - for ue in results.ues: + for ue in results.ues: # avec bonus rang_ue = 0 # XXX TODO rang de l'étudiant dans cette UE nb_inscrits_ue = ( nb_inscrits # approx: compliqué de définir le "nb d'inscrit à une UE" @@ -161,25 +161,31 @@ def bulletin_but_xml_compat( doc.append(x_ue) if ue.type != sco_codes_parcours.UE_SPORT: v = results.etud_moy_ue[ue.id][etud.id] + vmin = results.etud_moy_ue[ue.id].min() + vmax = results.etud_moy_ue[ue.id].max() else: - v = 0 # XXX TODO valeur bonus sport pour cet étudiant + v = results.bonus or 0.0 + vmin = vmax = 0.0 x_ue.append( Element( "note", value=scu.fmt_note(v), - min=scu.fmt_note(results.etud_moy_ue[ue.id].min()), - max=scu.fmt_note(results.etud_moy_ue[ue.id].max()), + min=scu.fmt_note(vmin), + max=scu.fmt_note(vmax), ) ) x_ue.append(Element("ects", value=str(ue.ects if ue.ects else 0))) x_ue.append(Element("rang", value=str(rang_ue))) x_ue.append(Element("effectif", value=str(nb_inscrits_ue))) # Liste les modules rattachés à cette UE - for modimpl in results.modimpls: + for modimpl in results.formsemestre.modimpls: # Liste ici uniquement les modules rattachés à cette UE if modimpl.module.ue.id == ue.id: # mod_moy = scu.fmt_note(results.etud_moy_ue[ue.id][etud.id]) - coef = results.modimpl_coefs_df[modimpl.id][ue.id] + try: + coef = results.modimpl_coefs_df[modimpl.id][ue.id] + except KeyError: + coef = 0.0 x_mod = Element( "module", id=str(modimpl.id), @@ -192,40 +198,46 @@ def bulletin_but_xml_compat( modimpl.module.code_apogee or "" ), ) - # XXX TODO rangs et effectifs - # --- notes de chaque eval: - if version != "short": - for e in modimpl.evaluations: - if e.visibulletin or version == "long": - x_eval = Element( - "evaluation", - jour=e.jour.isoformat() if e.jour else "", - heure_debut=e.heure_debut.isoformat() - if e.heure_debut - else "", - heure_fin=e.heure_fin.isoformat() - if e.heure_debut - else "", - coefficient=str(e.coefficient), - # pas les poids en XML compat - evaluation_type=str(e.evaluation_type), - description=scu.quote_xml_attr(e.description), - # notes envoyées sur 20, ceci juste pour garder trace: - note_max_origin=str(e.note_max), - ) - x_mod.append(x_eval) - x_eval.append( - Element( - "note", - value=scu.fmt_note( - results.modimpls_results[ - e.moduleimpl_id - ].evals_notes[e.id][etud.id], - note_max=e.note_max, - ), + # XXX TODO rangs et effectifs + # --- notes de chaque eval: + if version != "short": + for e in modimpl.evaluations: + if e.visibulletin or version == "long": + x_eval = Element( + "evaluation", + jour=e.jour.isoformat() if e.jour else "", + heure_debut=e.heure_debut.isoformat() + if e.heure_debut + else "", + heure_fin=e.heure_fin.isoformat() + if e.heure_debut + else "", + coefficient=str(e.coefficient), + # pas les poids en XML compat + evaluation_type=str(e.evaluation_type), + description=scu.quote_xml_attr(e.description), + # notes envoyées sur 20, ceci juste pour garder trace: + note_max_origin=str(e.note_max), ) - ) - # XXX TODO: Evaluations incomplètes ou futures: XXX + x_mod.append(x_eval) + try: + x_eval.append( + Element( + "note", + value=scu.fmt_note( + results.modimpls_results[ + e.moduleimpl_id + ].evals_notes[e.id][etud.id], + note_max=e.note_max, + ), + ) + ) + except KeyError: + x_eval.append( + Element("note", value="", note_max="") + ) + + # XXX TODO: Evaluations incomplètes ou futures: XXX # XXX TODO UE capitalisee (listee seulement si meilleure que l'UE courante) # --- Absences diff --git a/app/but/forms/refcomp_forms.py b/app/but/forms/refcomp_forms.py index 6fcd5950..ce16292d 100644 --- a/app/but/forms/refcomp_forms.py +++ b/app/but/forms/refcomp_forms.py @@ -19,10 +19,12 @@ class FormationRefCompForm(FlaskForm): class RefCompLoadForm(FlaskForm): + referentiel_standard = SelectField( + "Choisir un référentiel de compétences officiel BUT" + ) upload = FileField( - label="Sélectionner un fichier XML Orébut", + label="Ou bien sélectionner un fichier XML au format Orébut", validators=[ - FileRequired(), FileAllowed( [ "xml", @@ -33,3 +35,13 @@ class RefCompLoadForm(FlaskForm): ) submit = SubmitField("Valider") cancel = SubmitField("Annuler") + + def validate(self): + if not super().validate(): + return False + if (self.referentiel_standard.data == "0") == (not self.upload.data): + self.referentiel_standard.errors.append( + "Choisir soit un référentiel, soit un fichier xml" + ) + return False + return True diff --git a/app/but/import_refcomp.py b/app/but/import_refcomp.py index 4f055147..0f97cd95 100644 --- a/app/but/import_refcomp.py +++ b/app/but/import_refcomp.py @@ -6,6 +6,8 @@ from xml.etree import ElementTree from typing import TextIO +import sqlalchemy + from app import db from app.models.but_refcomp import ( @@ -19,7 +21,7 @@ from app.models.but_refcomp import ( ApcAnneeParcours, ApcParcoursNiveauCompetence, ) -from app.scodoc.sco_exceptions import ScoFormatError +from app.scodoc.sco_exceptions import ScoFormatError, ScoValueError def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None): @@ -27,6 +29,16 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None): peut lever TypeError ou ScoFormatError Résultat: instance de ApcReferentielCompetences """ + # Vérifie que le même fichier n'a pas déjà été chargé: + if ApcReferentielCompetences.query.filter_by( + scodoc_orig_filename=orig_filename, dept_id=dept_id + ).count(): + raise ScoValueError( + f"""Un référentiel a déjà été chargé d'un fichier de même nom. + ({orig_filename}) + Supprimez-le ou changer le nom du fichier.""" + ) + try: root = ElementTree.XML(xml_data) except ElementTree.ParseError as exc: @@ -42,7 +54,16 @@ def orebut_import_refcomp(xml_data: str, dept_id: int, orig_filename=None): if not competences: raise ScoFormatError("élément 'competences' manquant") for competence in competences.findall("competence"): - c = ApcCompetence(**ApcCompetence.attr_from_xml(competence.attrib)) + try: + c = ApcCompetence(**ApcCompetence.attr_from_xml(competence.attrib)) + db.session.flush() + except sqlalchemy.exc.IntegrityError: + # ne devrait plus se produire car pas d'unicité de l'id: donc inutile + db.session.rollback() + raise ScoValueError( + f"""Un référentiel a déjà été chargé avec les mêmes compétences ! ({competence.attrib["id"]}) + """ + ) ref.competences.append(c) # --- SITUATIONS situations = competence.find("situations") diff --git a/app/comp/aux_stats.py b/app/comp/aux_stats.py index 3337c2b8..3f25a5ad 100644 --- a/app/comp/aux_stats.py +++ b/app/comp/aux_stats.py @@ -21,14 +21,17 @@ class StatsMoyenne: Les valeurs NAN ou non numériques sont toujours enlevées. Si vals is None, renvoie des zéros (utilisé pour UE bonus) """ - if vals is None or len(vals) == 0: + try: + if vals is None or len(vals) == 0 or np.isnan(vals).all(): + 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)) + except TypeError: # que des NaN dans un array d'objets, ou ce genre de choses exotiques... 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 index 60361c32..1c7fc543 100644 --- a/app/comp/bonus_spo.py +++ b/app/comp/bonus_spo.py @@ -87,6 +87,8 @@ class BonusSport: for m in formsemestre.modimpls_sorted ] ) + if not len(modimpl_mask): + modimpl_mask = np.s_[:] # il n'y a rien, on prend tout donc rien self.modimpls_spo = [ modimpl for i, modimpl in enumerate(formsemestre.modimpls_sorted) @@ -134,9 +136,12 @@ class BonusSport: 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 - ) + if nb_etuds == 0: + modimpl_coefs_etuds = modimpl_inscr_spo # vide + else: + 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 @@ -198,6 +203,9 @@ class BonusSportAdditif(BonusSport): En APC: ndarray (nb_etuds, nb_mod_sport, nb_ues_non_bonus) modimpl_coefs_etuds_no_nan: """ + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return bonus_moy_arr = np.sum( np.where( sem_modimpl_moys_inscrits > self.seuil_moy_gen, @@ -249,6 +257,9 @@ class BonusSportMultiplicatif(BonusSport): # bonus = m_0 (a - 1) def compute_bonus(self, sem_modimpl_moys_inscrits, modimpl_coefs_etuds_no_nan): """calcul du bonus""" + if 0 in sem_modimpl_moys_inscrits.shape: + # pas d'étudiants ou pas d'UE ou pas de module... + return # Calcule moyenne pondérée des notes de sport: notes = np.sum( sem_modimpl_moys_inscrits * modimpl_coefs_etuds_no_nan, axis=1 diff --git a/app/comp/moy_sem.py b/app/comp/moy_sem.py index ae167d4e..5caa3d39 100644 --- a/app/comp/moy_sem.py +++ b/app/comp/moy_sem.py @@ -48,14 +48,15 @@ def compute_sem_moys_apc( return moy_gen -def comp_ranks_series(notes: pd.Series) -> dict[int, str]: +def comp_ranks_series(notes: pd.Series) -> (pd.Series, pd.Series): """Calcul rangs à partir d'une séries ("vecteur") de notes (index etudid, valeur numérique) en tenant compte des ex-aequos. - Result: { etudid : rang:str } où rang est une chaine decrivant le rang. + Result: Series { etudid : rang:str } où rang est une chaine decrivant le rang. """ notes = notes.sort_values(ascending=False) # Serie, tri par ordre décroissant - rangs = pd.Series(index=notes.index, dtype=str) # le rang est une chaîne + rangs_str = pd.Series(index=notes.index, dtype=str) # le rang est une chaîne + rangs_int = pd.Series(index=notes.index, dtype=int) # le rang numérique pour tris N = len(notes) nb_ex = 0 # nb d'ex-aequo consécutifs en cours notes_i = notes.iat @@ -67,6 +68,7 @@ def comp_ranks_series(notes: pd.Series) -> dict[int, str]: next = None val = notes_i[i] if nb_ex: + rangs_int[etudid] = i + 1 - nb_ex srang = "%d ex" % (i + 1 - nb_ex) if val == next: nb_ex += 1 @@ -74,9 +76,11 @@ def comp_ranks_series(notes: pd.Series) -> dict[int, str]: nb_ex = 0 else: if val == next: + rangs_int[etudid] = i + 1 - nb_ex srang = "%d ex" % (i + 1 - nb_ex) nb_ex = 1 else: + rangs_int[etudid] = i + 1 srang = "%d" % (i + 1) - rangs[etudid] = srang - return rangs + rangs_str[etudid] = srang + return rangs_str, rangs_int diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 8a8057ac..b0a534e5 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -136,8 +136,13 @@ 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 - + try: + modimpl_coefs_df[mod2impl[mod_coef.module_id]][ + mod_coef.ue_id + ] = mod_coef.coef + except IndexError: + # il peut y avoir en base des coefs sur des modules ou UE qui ont depuis été retirés de la formation + pass # 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) @@ -229,12 +234,12 @@ def compute_ue_moys_apc( modimpl_inscr_df: matrice d'inscription du semestre (etud x modimpl) modimpl_coefs_df: matrice coefficients (UE x modimpl), sans UEs bonus sport - Résultat: DataFrame columns UE (sans sport), rows etudid + Résultat: DataFrame columns UE (sans bonus), rows etudid """ 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: + if nb_modules == 0 or nb_etuds == 0 or nb_ues_no_bonus == 0: return pd.DataFrame( index=modimpl_inscr_df.index, columns=modimpl_coefs_df.index ) @@ -310,6 +315,17 @@ def compute_ue_moys_classic( les coefficients effectifs de chaque UE pour chaque étudiant (sommes de coefs de modules pris en compte) """ + if (not len(modimpl_mask)) or ( + sem_matrix.shape[0] == 0 + ): # aucun module ou aucun étudiant + # etud_moy_gen_s, etud_moy_ue_df, etud_coef_ue_df + return ( + pd.Series( + [0.0] * len(modimpl_inscr_df.index), index=modimpl_inscr_df.index + ), + pd.DataFrame(columns=[ue.id for ue in ues], index=modimpl_inscr_df.index), + pd.DataFrame(columns=[ue.id for ue in ues], index=modimpl_inscr_df.index), + ) # Restreint aux modules sélectionnés: sem_matrix = sem_matrix[:, modimpl_mask] modimpl_inscr = modimpl_inscr_df.values[:, modimpl_mask] @@ -391,8 +407,9 @@ def compute_malus( ) -> pd.DataFrame: """Calcul le malus sur les UE Dans chaque UE, on peut avoir un ou plusieurs modules de MALUS. - Leurs notes sont positives ou négatives. leur somme sera _soustraite_ à la moyenne - de chaque UE. + Leurs notes sont positives ou négatives. + La somme des notes de malus somme est _soustraite_ à la moyenne de chaque UE. + Arguments: - sem_modimpl_moys : notes moyennes aux modules (tous les étuds x tous les modimpls) @@ -415,8 +432,9 @@ def compute_malus( for m in formsemestre.modimpls_sorted ] ) - malus_moys = sem_modimpl_moys[:, modimpl_mask].sum(axis=1) - malus[ue.id] = malus_moys + if len(modimpl_mask): + malus_moys = sem_modimpl_moys[:, modimpl_mask].sum(axis=1) + malus[ue.id] = malus_moys malus.fillna(0.0, inplace=True) return malus diff --git a/app/comp/res_but.py b/app/comp/res_but.py index f70ed0b0..b74efb10 100644 --- a/app/comp/res_but.py +++ b/app/comp/res_but.py @@ -6,9 +6,11 @@ """Résultats semestres BUT """ +import time import numpy as np import pandas as pd +from app import log from app.comp import moy_ue, moy_sem, inscr_mod from app.comp.res_common import NotesTableCompat from app.comp.bonus_spo import BonusSport @@ -30,8 +32,14 @@ class ResultatsSemestreBUT(NotesTableCompat): super().__init__(formsemestre) if not self.load_cached(): + t0 = time.time() self.compute() + t1 = time.time() self.store() + t2 = time.time() + log( + f"ResultatsSemestreBUT: cached formsemestre_id={formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)" + ) def compute(self): "Charge les notes et inscriptions et calcule les moyennes d'UE et gen." diff --git a/app/comp/res_classic.py b/app/comp/res_classic.py index 0d8f3242..2caf515c 100644 --- a/app/comp/res_classic.py +++ b/app/comp/res_classic.py @@ -6,7 +6,7 @@ """Résultats semestres classiques (non APC) """ - +import time import numpy as np import pandas as pd from sqlalchemy.sql import text @@ -40,8 +40,14 @@ class ResultatsSemestreClassic(NotesTableCompat): super().__init__(formsemestre) if not self.load_cached(): + t0 = time.time() self.compute() + t1 = time.time() self.store() + t2 = time.time() + log( + f"ResultatsSemestreClassic: cached formsemestre_id={formsemestre.id} ({(t1-t0):g}s +{(t2-t1):g}s)" + ) # recalculé (aussi rapide que de les cacher) self.moy_min = self.etud_moy_gen.min() self.moy_max = self.etud_moy_gen.max() @@ -110,9 +116,8 @@ class ResultatsSemestreClassic(NotesTableCompat): 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 - ) + # compat nt, utilisé pour l'afficher sur les bulletins: + self.bonus = bonus_mg # --- UE capitalisées self.apply_capitalisation() @@ -209,6 +214,8 @@ def notes_sem_assemble_matrix(modimpls_notes: list[pd.Series]) -> np.ndarray: (Series rendus par compute_module_moy, index: etud) Resultat: ndarray (etud x module) """ + if not len(modimpls_notes): + return np.zeros((0, 0), dtype=float) modimpls_notes_arr = [s.values for s in modimpls_notes] modimpls_notes = np.stack(modimpls_notes_arr) # passe de (mod x etud) à (etud x mod) diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 616ddb71..b019c977 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -4,11 +4,12 @@ # See LICENSE ############################################################################## -from collections import defaultdict, Counter +from collections import Counter from functools import cached_property import numpy as np import pandas as pd +from app import log from app.comp.aux_stats import StatsMoyenne from app.comp import moy_sem from app.comp.res_cache import ResultatsCache @@ -19,8 +20,7 @@ from app.models import FormSemestreUECoef from app.models.ues import UniteEns from app.scodoc import sco_utils as scu from app.scodoc.sco_cache import ResultatsSemestreCache -from app.scodoc.sco_codes_parcours import UE_SPORT, ATT, DEF -from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_codes_parcours import UE_SPORT, DEF # Il faut bien distinguer # - ce qui est caché de façon persistente (via redis): @@ -51,6 +51,7 @@ class ResultatsSemestre(ResultatsCache): "etud_moy_ue: DataFrame columns UE, rows etudid" self.etud_moy_gen = {} self.etud_moy_gen_ranks = {} + self.etud_moy_gen_ranks_int = {} self.modimpls_results: ModuleImplResults = None "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }" self.etud_coef_ue_df = None @@ -183,7 +184,7 @@ class ResultatsSemestre(ResultatsCache): sum_coefs_ue = 0.0 for ue in self.formsemestre.query_ues(): ue_cap = self.get_etud_ue_status(etudid, ue.id) - if ue_cap["is_capitalized"]: + if ue_cap and ue_cap["is_capitalized"]: recompute_mg = True coef = ue_cap["coef_ue"] if not np.isnan(ue_cap["moy"]): @@ -213,15 +214,13 @@ class ResultatsSemestre(ResultatsCache): def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: """L'état de l'UE pour cet étudiant. - L'UE doit être du semestre. - Result: dict. + Result: dict, ou None si l'UE n'est pas dans ce semestre. """ - if not self.validations: - self.validations = res_sem.load_formsemestre_validations(self.formsemestre) ue = UniteEns.query.get(ue_id) # TODO cacher nos UEs ? if ue.type == UE_SPORT: return { "is_capitalized": False, + "was_capitalized": False, "is_external": False, "coef_ue": 0.0, "cur_moy_ue": 0.0, @@ -232,9 +231,16 @@ class ResultatsSemestre(ResultatsCache): "capitalized_ue_id": None, "ects_pot": 0.0, } + if not ue_id in self.etud_moy_ue: + return None + if not self.validations: + self.validations = res_sem.load_formsemestre_validations(self.formsemestre) cur_moy_ue = self.etud_moy_ue[ue_id][etudid] moy_ue = cur_moy_ue - is_capitalized = False + is_capitalized = False # si l'UE prise en compte est une UE capitalisée + was_capitalized = ( + False # s'il y a precedemment une UE capitalisée (pas forcement meilleure) + ) if etudid in self.validations.ue_capitalisees.index: ue_cap = self._get_etud_ue_cap(etudid, ue) if ( @@ -242,6 +248,7 @@ class ResultatsSemestre(ResultatsCache): and not ue_cap.empty and not np.isnan(ue_cap["moy_ue"]) ): + was_capitalized = True if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue): moy_ue = ue_cap["moy_ue"] is_capitalized = True @@ -250,6 +257,7 @@ class ResultatsSemestre(ResultatsCache): return { "is_capitalized": is_capitalized, + "was_capitalized": was_capitalized, "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external, "coef_ue": coef_ue, "ects_pot": ue.ects or 0.0, @@ -301,6 +309,7 @@ class NotesTableCompat(ResultatsSemestre): "bonus_ues", "malus", "etud_moy_gen_ranks", + "etud_moy_gen_ranks_int", "ue_rangs", ) @@ -311,41 +320,54 @@ class NotesTableCompat(ResultatsSemestre): 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: (None, nb_etuds) for m in self.formsemestre.modimpls_sorted - } + self.mod_rangs = None # sera surchargé en Classic, mais pas en APC self.moy_min = "NA" self.moy_max = "NA" self.moy_moy = "NA" self.expr_diagnostics = "" self.parcours = self.formsemestre.formation.get_parcours() - def get_etudids(self, sorted=False) -> list[int]: - """Liste des etudids inscrits, incluant les démissionnaires. - Si sorted, triée par moy. générale décroissante - Sinon, triée par ordre alphabetique de NOM + def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]: + """Liste des étudiants inscrits + order_by = False|'nom'|'moy' tri sur nom ou sur moyenne générale (indicative) + + Note: pour récupérer les etudids des inscrits, non triés, il est plus efficace + d'utiliser `[ ins.etudid for ins in nt.formsemestre.inscriptions ]` + """ + etuds = self.formsemestre.get_inscrits( + include_demdef=include_demdef, order=(order_by == "nom") + ) + if order_by == "moy": + etuds.sort( + key=lambda e: ( + self.etud_moy_gen_ranks_int.get(e.id, 100000), + e.sort_key, + ) + ) + return etuds + + def get_etudids(self) -> list[int]: + """(deprecated) + Liste des etudids inscrits, incluant les démissionnaires. + triée par ordre alphabetique de NOM + (à éviter: renvoie les etudids, mais est moins efficace que get_inscrits) """ # Note: pour avoir les inscrits non triés, # utiliser [ ins.etudid for ins in self.formsemestre.inscriptions ] - if sorted: - # Tri par moy. generale décroissante - return [x[-1] for x in self.T] - return [x["etudid"] for x in self.inscrlist] @cached_property def sem(self) -> dict: - """le formsemestre, comme un dict (nt.sem)""" - return self.formsemestre.to_dict() + """le formsemestre, comme un gros et gras dict (nt.sem)""" + return self.formsemestre.get_infos_dict() @cached_property - def inscrlist(self) -> list[dict]: # utilisé par PE seulement + def inscrlist(self) -> list[dict]: # utilisé par PE """Liste des inscrits au semestre (avec DEM et DEF), sous forme de dict etud, classée dans l'ordre alphabétique de noms. """ - etuds = self.formsemestre.get_inscrits(include_demdef=True) - etuds.sort(key=lambda e: e.sort_key) + etuds = self.formsemestre.get_inscrits(include_demdef=True, order=True) return [e.to_dict_scodoc7() for e in etuds] @cached_property @@ -379,7 +401,7 @@ class NotesTableCompat(ResultatsSemestre): d = modimpl.to_dict() # compat ScoDoc < 9.2: ajoute matières d["mat"] = modimpl.module.matiere.to_dict() - modimpls_dict.append(d) + modimpls_dict.append(d) return modimpls_dict def compute_rangs(self): @@ -387,11 +409,14 @@ class NotesTableCompat(ResultatsSemestre): Moyenne générale: etud_moy_gen_ranks Par UE (sauf ue bonus) """ - self.etud_moy_gen_ranks = moy_sem.comp_ranks_series(self.etud_moy_gen) + ( + self.etud_moy_gen_ranks, + self.etud_moy_gen_ranks_int, + ) = moy_sem.comp_ranks_series(self.etud_moy_gen) for ue in self.formsemestre.query_ues(): moy_ue = self.etud_moy_ue[ue.id] self.ue_rangs[ue.id] = ( - moy_sem.comp_ranks_series(moy_ue), + moy_sem.comp_ranks_series(moy_ue)[0], # juste en chaine int(moy_ue.count()), ) # .count() -> nb of non NaN values @@ -420,12 +445,27 @@ class NotesTableCompat(ResultatsSemestre): Return: True|False, message explicatif """ - return self.parcours.check_barre_ues( - [ - self.get_etud_ue_status(etudid, ue.id) - for ue in self.formsemestre.query_ues() - ] - ) + ue_status_list = [] + for ue in self.formsemestre.query_ues(): + ue_status = self.get_etud_ue_status(etudid, ue.id) + if ue_status: + ue_status_list.append(ue_status) + return self.parcours.check_barre_ues(ue_status_list) + + def all_etuds_have_sem_decisions(self): + """True si tous les étudiants du semestre ont une décision de jury. + Ne regarde pas les décisions d'UE. + """ + for ins in self.formsemestre.inscriptions: + if ins.etat != scu.INSCRIT: + continue # skip démissionnaires + if self.get_etud_decision_sem(ins.etudid) is None: + return False + return True + + def etud_has_decision(self, etudid): + """True s'il y a une décision de jury pour cet étudiant""" + return self.get_etud_decision_ues(etudid) or self.get_etud_decision_sem(etudid) def get_etud_decision_ues(self, etudid: int) -> dict: """Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu. @@ -525,25 +565,32 @@ class NotesTableCompat(ResultatsSemestre): Évaluation "complete" ssi toutes notes saisies ou en attente. """ modimpl = ModuleImpl.query.get(moduleimpl_id) + modimpl_results = self.modimpls_results.get(moduleimpl_id) + if not modimpl_results: + return [] # safeguard evals_results = [] for e in modimpl.evaluations: - if self.modimpls_results[moduleimpl_id].evaluations_completes_dict[e.id]: + if modimpl_results.evaluations_completes_dict.get(e.id, False): d = e.to_dict() - moduleimpl_results = self.modimpls_results[e.moduleimpl_id] d["heure_debut"] = e.heure_debut # datetime.time d["heure_fin"] = e.heure_fin d["jour"] = e.jour # datetime d["notes"] = { etud.id: { "etudid": etud.id, - "value": moduleimpl_results.evals_notes[e.id][etud.id], + "value": modimpl_results.evals_notes[e.id][etud.id], } for etud in self.etuds } d["etat"] = { - "evalattente": moduleimpl_results.evaluations_etat[e.id].nb_attente, + "evalattente": modimpl_results.evaluations_etat[e.id].nb_attente, } evals_results.append(d) + elif e.id not in modimpl_results.evaluations_completes_dict: + # ne devrait pas arriver ? XXX + log( + f"Warning: 220213 get_evals_in_mod {e.id} not in mod {moduleimpl_id} ?" + ) return evals_results def get_evaluations_etats(self): @@ -608,7 +655,7 @@ class NotesTableCompat(ResultatsSemestre): """ table_moyennes = [] etuds_inscriptions = self.formsemestre.etuds_inscriptions - ues = self.formsemestre.query_ues() # sans bonus + ues = self.formsemestre.query_ues(with_sport=True) # avec bonus for etudid in etuds_inscriptions: moy_gen = self.etud_moy_gen.get(etudid, False) if moy_gen is False: @@ -623,8 +670,11 @@ class NotesTableCompat(ResultatsSemestre): ue_is_cap = {} for ue in ues: ue_status = self.get_etud_ue_status(etudid, ue.id) - moy_ues.append(ue_status["moy"]) - ue_is_cap[ue.id] = ue_status["is_capitalized"] + if ue_status: + moy_ues.append(ue_status["moy"]) + ue_is_cap[ue.id] = ue_status["is_capitalized"] + else: + moy_ues.append("?") t = [moy_gen] + list(moy_ues) # Moyennes modules: for modimpl in self.formsemestre.modimpls_sorted: diff --git a/app/comp/res_sem.py b/app/comp/res_sem.py index 5da2c7f0..607ad168 100644 --- a/app/comp/res_sem.py +++ b/app/comp/res_sem.py @@ -25,7 +25,7 @@ def load_formsemestre_results(formsemestre: FormSemestre) -> ResultatsSemestre: """ # --- Try local cache (within the same request context) if not hasattr(g, "formsemestre_results_cache"): - g.formsemestre_results_cache = {} # pylint: disable=C0237 + g.formsemestre_results_cache = {} else: if formsemestre.id in g.formsemestre_results_cache: return g.formsemestre_results_cache[formsemestre.id] diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index 79816425..429c6389 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -81,6 +81,9 @@ class ApcReferentielCompetences(db.Model, XMLModel): ) formations = db.relationship("Formation", backref="referentiel_competence") + def __repr__(self): + return f"" + def to_dict(self): """Représentation complète du ref. de comp. comme un dict. @@ -110,7 +113,8 @@ class ApcCompetence(db.Model, XMLModel): db.Integer, db.ForeignKey("apc_referentiel_competences.id"), nullable=False ) # les compétences dans Orébut sont identifiées par leur id unique - id_orebut = db.Column(db.Text(), nullable=True, index=True, unique=True) + # (mais id_orebut n'est pas unique car le même ref. pourra être chargé dans plusieurs depts) + id_orebut = db.Column(db.Text(), nullable=True, index=True) titre = db.Column(db.Text(), nullable=False, index=True) titre_long = db.Column(db.Text()) couleur = db.Column(db.Text()) @@ -139,6 +143,9 @@ class ApcCompetence(db.Model, XMLModel): cascade="all, delete-orphan", ) + def __repr__(self): + return f"" + def to_dict(self): return { "id_orebut": self.id_orebut, diff --git a/app/models/etudiants.py b/app/models/etudiants.py index ff91981b..18f13380 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -7,12 +7,14 @@ from functools import cached_property from flask import abort, url_for from flask import g, request +import sqlalchemy from app import db from app import models from app.scodoc import notesdb as ndb from app.scodoc.sco_bac import Baccalaureat +import app.scodoc.sco_utils as scu class Identite(db.Model): @@ -73,6 +75,13 @@ class Identite(db.Model): """ return {"M": "M.", "F": "Mme", "X": ""}[self.civilite] + def sex_nom(self, no_accents=False) -> str: + "'M. DUPONTÉ', ou si no_accents, 'M. DUPONTE'" + s = f"{self.civilite_str} {(self.nom_usuel or self.nom).upper()}" + if no_accents: + return scu.suppress_accents(s) + return s + def nom_disp(self) -> str: "Nom à afficher" if self.nom_usuel: @@ -125,6 +134,7 @@ class Identite(db.Model): # ScoDoc7 output_formators: (backward compat) e["etudid"] = self.id e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"]) + e["ne"] = {"M": "", "F": "ne"}.get(self.civilite, "(e)") return {k: e[k] or "" for k in e} # convert_null_outputs_to_empty def to_dict_bul(self, include_urls=True): @@ -302,6 +312,24 @@ class Admission(db.Model): "Le bac. utiliser bac.abbrev() pour avoir une chaine de caractères." return Baccalaureat(self.bac, specialite=self.specialite) + def to_dict(self, no_nulls=False): + """Représentation dictionnaire,""" + e = dict(self.__dict__) + e.pop("_sa_instance_state", None) + if no_nulls: + for k in e: + if e[k] is None: + col_type = getattr( + sqlalchemy.inspect(models.Admission).columns, "apb_groupe" + ).expression.type + if isinstance(col_type, sqlalchemy.Text): + e[k] = "" + elif isinstance(col_type, sqlalchemy.Integer): + e[k] = 0 + elif isinstance(col_type, sqlalchemy.Boolean): + e[k] = False + return e + # Suivi scolarité / débouchés class ItemSuivi(db.Model): diff --git a/app/models/evaluations.py b/app/models/evaluations.py index b4e5f4e2..46244a9a 100644 --- a/app/models/evaluations.py +++ b/app/models/evaluations.py @@ -2,12 +2,16 @@ """ScoDoc models: evaluations """ +import datetime from app import db -from app.models import UniteEns +from app.models import formsemestre +from app.models.formsemestre import FormSemestre +from app.models.moduleimpls import ModuleImpl +from app.models.ues import UniteEns +from app.scodoc.sco_exceptions import ScoValueError import app.scodoc.notesdb as ndb -from app.scodoc import sco_evaluation_db class Evaluation(db.Model): @@ -51,11 +55,11 @@ class Evaluation(db.Model): e["evaluation_id"] = self.id e["jour"] = ndb.DateISOtoDMY(e["jour"]) e["numero"] = ndb.int_null_is_zero(e["numero"]) - return sco_evaluation_db.evaluation_enrich_dict(e) + return evaluation_enrich_dict(e) def from_dict(self, data): """Set evaluation attributes from given dict values.""" - sco_evaluation_db._check_evaluation_args(data) + check_evaluation_args(data) for k in self.__dict__.keys(): if k != "_sa_instance_state" and k != "id" and k in data: setattr(self, k, data[k]) @@ -145,3 +149,89 @@ class EvaluationUEPoids(db.Model): def __repr__(self): return f"" + + +# Fonction héritée de ScoDoc7 à refactorer +def evaluation_enrich_dict(e): + """add or convert some fileds in an evaluation dict""" + # For ScoDoc7 compat + heure_debut_dt = e["heure_debut"] or datetime.time( + 8, 00 + ) # au cas ou pas d'heure (note externe?) + heure_fin_dt = e["heure_fin"] or datetime.time(8, 00) + e["heure_debut"] = ndb.TimefromISO8601(e["heure_debut"]) + e["heure_fin"] = ndb.TimefromISO8601(e["heure_fin"]) + e["jouriso"] = ndb.DateDMYtoISO(e["jour"]) + heure_debut, heure_fin = e["heure_debut"], e["heure_fin"] + d = ndb.TimeDuration(heure_debut, heure_fin) + if d is not None: + m = d % 60 + e["duree"] = "%dh" % (d / 60) + if m != 0: + e["duree"] += "%02d" % m + else: + e["duree"] = "" + if heure_debut and (not heure_fin or heure_fin == heure_debut): + e["descrheure"] = " à " + heure_debut + elif heure_debut and heure_fin: + e["descrheure"] = " de %s à %s" % (heure_debut, heure_fin) + else: + e["descrheure"] = "" + # matin, apresmidi: utile pour se referer aux absences: + if heure_debut_dt < datetime.time(12, 00): + e["matin"] = 1 + else: + e["matin"] = 0 + if heure_fin_dt > datetime.time(12, 00): + e["apresmidi"] = 1 + else: + e["apresmidi"] = 0 + return e + + +def check_evaluation_args(args): + "Check coefficient, dates and duration, raises exception if invalid" + moduleimpl_id = args["moduleimpl_id"] + # check bareme + note_max = args.get("note_max", None) + if note_max is None: + raise ScoValueError("missing note_max") + try: + note_max = float(note_max) + except ValueError: + raise ScoValueError("Invalid note_max value") + if note_max < 0: + raise ScoValueError("Invalid note_max value (must be positive or null)") + # check coefficient + coef = args.get("coefficient", None) + if coef is None: + raise ScoValueError("missing coefficient") + try: + coef = float(coef) + except ValueError: + raise ScoValueError("Invalid coefficient value") + if coef < 0: + raise ScoValueError("Invalid coefficient value (must be positive or null)") + # check date + jour = args.get("jour", None) + args["jour"] = jour + if jour: + modimpl = ModuleImpl.query.get(moduleimpl_id) + formsemestre = modimpl.formsemestre + y, m, d = [int(x) for x in ndb.DateDMYtoISO(jour).split("-")] + jour = datetime.date(y, m, d) + if (jour > formsemestre.date_fin) or (jour < formsemestre.date_debut): + raise ScoValueError( + "La date de l'évaluation (%s/%s/%s) n'est pas dans le semestre !" + % (d, m, y), + dest_url="javascript:history.back();", + ) + heure_debut = args.get("heure_debut", None) + args["heure_debut"] = heure_debut + heure_fin = args.get("heure_fin", None) + args["heure_fin"] = heure_fin + if jour and ((not heure_debut) or (not heure_fin)): + raise ScoValueError("Les heures doivent être précisées") + d = ndb.TimeDuration(heure_debut, heure_fin) + if d and ((d < 0) or (d > 60 * 12)): + raise ScoValueError("Heures de l'évaluation incohérentes !") diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 9ed1636a..2d491d7e 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -117,10 +117,12 @@ class FormSemestre(db.Model): return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>" def to_dict(self): + "dict (compatible ScoDoc7)" d = dict(self.__dict__) d.pop("_sa_instance_state", None) # ScoDoc7 output_formators: (backward compat) d["formsemestre_id"] = self.id + d["titre_num"] = self.titre_num() if self.date_debut: d["date_debut"] = self.date_debut.strftime("%d/%m/%Y") d["date_debut_iso"] = self.date_debut.isoformat() @@ -144,6 +146,8 @@ class FormSemestre(db.Model): d["annee_debut"] = str(self.date_debut.year) d["annee"] = d["annee_debut"] d["annee_fin"] = str(self.date_fin.year) + if d["annee_fin"] != d["annee_debut"]: + d["annee"] += "-" + str(d["annee_fin"]) d["mois_debut_ord"] = self.date_debut.month d["mois_fin_ord"] = self.date_fin.month # La période: considère comme "S1" (ou S3) les débuts en aout-sept-octobre @@ -152,15 +156,8 @@ class FormSemestre(db.Model): d["periode"] = 1 # typiquement, début en septembre: S1, S3... else: d["periode"] = 2 # typiquement, début en février: S2, S4... - d["titre_num"] = self.titre_num - d["titreannee"] = "%s %s %s" % ( - d["titre_num"], - self.modalite or "", - self.date_debut.year, - ) - if d["annee_fin"] != d["annee_debut"]: - d["titreannee"] += "-" + str(d["annee_fin"]) - d["annee"] += "-" + str(d["annee_fin"]) + d["titre_num"] = self.titre_num() + d["titreannee"] = self.titre_annee() d["mois_debut"] = f"{self.date_debut.month} {self.date_debut.year}" d["mois_fin"] = f"{self.date_fin.month} {self.date_fin.year}" d["titremois"] = "%s %s (%s - %s)" % ( @@ -332,6 +329,15 @@ class FormSemestre(db.Model): "-".join((imputation_dept, parcours_name, modalite, semestre_id, annee_sco)) ) + def titre_annee(self) -> str: + """ """ + titre_annee = ( + f"{self.titre_num()} {self.modalite or ''} {self.date_debut.year}" + ) + if self.date_fin.year != self.date_debut.year: + titre_annee += "-" + str(self.date_fin.year) + return titre_annee + def titre_mois(self) -> str: """Le titre et les dates du semestre, pour affichage dans des listes Ex: "BUT QLIO (PN 2022) semestre 1 FI (Sept 2022 - Jan 2023)" @@ -359,15 +365,19 @@ class FormSemestre(db.Model): etudid, self.date_debut.isoformat(), self.date_fin.isoformat() ) - def get_inscrits(self, include_demdef=False) -> list[Identite]: + def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]: """Liste des étudiants inscrits à ce semestre Si include_demdef, tous les étudiants, avec les démissionnaires et défaillants. + Si order, tri par clé sort_key """ if include_demdef: - return [ins.etud for ins in self.inscriptions] + etuds = [ins.etud for ins in self.inscriptions] else: - return [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] + etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT] + if order: + etuds.sort(key=lambda e: e.sort_key) + return etuds @cached_property def etudids_actifs(self) -> set: diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py index d51a620b..700dec26 100644 --- a/app/models/moduleimpls.py +++ b/app/models/moduleimpls.py @@ -79,7 +79,7 @@ class ModuleImpl(db.Model): ) def to_dict(self): - """as a dict, with the same conversions as in ScoDoc7""" + """as a dict, with the same conversions as in ScoDoc7, including module""" e = dict(self.__dict__) e.pop("_sa_instance_state", None) # ScoDoc7 output_formators: (backward compat) diff --git a/app/models/notes.py b/app/models/notes.py index 7e558357..239d7bb4 100644 --- a/app/models/notes.py +++ b/app/models/notes.py @@ -4,8 +4,9 @@ """ from app import db -from app.models import SHORT_STR_LEN -from app.models import CODE_STR_LEN + +import app.scodoc.notesdb as ndb +import app.scodoc.sco_utils as scu class BulAppreciations(db.Model): @@ -67,3 +68,32 @@ class NotesNotesLog(db.Model): comment = db.Column(db.Text) # texte libre date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) uid = db.Column(db.Integer, db.ForeignKey("user.id")) + + +def etud_has_notes_attente(etudid, formsemestre_id): + """Vrai si cet etudiant a au moins une note en attente dans ce semestre. + (ne compte que les notes en attente dans des évaluation avec coef. non nul). + """ + # XXX ancienne méthode de notes_table à ré-écrire + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + """SELECT n.* + FROM notes_notes n, notes_evaluation e, notes_moduleimpl m, + notes_moduleimpl_inscription i + WHERE n.etudid = %(etudid)s + and n.value = %(code_attente)s + and n.evaluation_id = e.id + and e.moduleimpl_id = m.id + and m.formsemestre_id = %(formsemestre_id)s + and e.coefficient != 0 + and m.id = i.moduleimpl_id + and i.etudid=%(etudid)s + """, + { + "formsemestre_id": formsemestre_id, + "etudid": etudid, + "code_attente": scu.NOTES_ATTENTE, + }, + ) + return len(cursor.fetchall()) > 0 diff --git a/app/pe/pe_avislatex.py b/app/pe/pe_avislatex.py index 2ff50715..fc64253c 100644 --- a/app/pe/pe_avislatex.py +++ b/app/pe/pe_avislatex.py @@ -87,7 +87,7 @@ def get_tags_latex(code_latex): """ if code_latex: # changé par EV: était r"([\*]{2}[a-zA-Z0-9:éèàâêëïôöù]+[\*]{2})" - res = re.findall(r"([\*]{2}[^ \t\n\r\f\v\*]+[\*]{2})", code_latex) + res = re.findall(r"([\*]{2}[^\t\n\r\f\v\*]+[\*]{2})", code_latex) return [tag[2:-2] for tag in res] else: return [] diff --git a/app/pe/pe_jurype.py b/app/pe/pe_jurype.py index 694a410f..2720ad43 100644 --- a/app/pe/pe_jurype.py +++ b/app/pe/pe_jurype.py @@ -46,9 +46,12 @@ import io import os from zipfile import ZipFile +from app.comp import res_sem +from app.comp.res_common import NotesTableCompat +from app.models import FormSemestre + from app.scodoc.gen_tables import GenTable, SeqGenTable import app.scodoc.sco_utils as scu -from app.scodoc import sco_cache from app.scodoc import sco_codes_parcours # sco_codes_parcours.NEXT -> sem suivant from app.scodoc import sco_etud from app.scodoc import sco_formsemestre @@ -174,6 +177,8 @@ class JuryPE(object): self.PARCOURSINFO_DICT = {} # Les parcours des étudiants self.syntheseJury = {} # Le jury de synthèse + self.semestresDeScoDoc = sco_formsemestre.do_formsemestre_list() + # Calcul du jury PE self.exe_calculs_juryPE(semBase) self.synthetise_juryPE() @@ -317,12 +322,10 @@ class JuryPE(object): etudiants = [] for sem in semsListe: # pour chacun des semestres de la liste - # nt = self.get_notes_d_un_semestre( sem['formsemestre_id'] ) nt = self.get_cache_notes_d_un_semestre(sem["formsemestre_id"]) - # sco_cache.NotesTableCache.get( sem['formsemestre_id']) etudiantsDuSemestre = ( nt.get_etudids() - ) # nt.identdict.keys() # identification des etudiants du semestre + ) # identification des etudiants du semestre if pe_tools.PE_DEBUG: pe_tools.pe_print( @@ -486,14 +489,14 @@ class JuryPE(object): lastdate = max(sesdates) # date de fin de l'inscription la plus récente # if PETable.AFFICHAGE_DEBUG_PE == True : pe_tools.pe_print(" derniere inscription = ", lastDateSem) - semestresDeScoDoc = sco_formsemestre.do_formsemestre_list() + if sonDernierSidValide is None: # si l'étudiant n'a validé aucun semestre, les prend tous ? (à vérifier) - semestresSuperieurs = semestresDeScoDoc + semestresSuperieurs = self.semestresDeScoDoc else: semestresSuperieurs = [ sem - for sem in semestresDeScoDoc + for sem in self.semestresDeScoDoc if sem["semestre_id"] > sonDernierSidValide ] # Semestre de rang plus élevé que son dernier sem valide datesDesSemestresSuperieurs = [ @@ -1127,9 +1130,10 @@ class JuryPE(object): # ------------------------------------------------------------------------------------------------------------------ # ------------------------------------------------------------------------------------------------------------------ - def get_cache_notes_d_un_semestre(self, formsemestre_id): # inutile en realité ! + def get_cache_notes_d_un_semestre(self, formsemestre_id: int) -> NotesTableCompat: """Charge la table des notes d'un formsemestre""" - return sco_cache.NotesTableCache.get(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + return res_sem.load_formsemestre_results(formsemestre) # ------------------------------------------------------------------------------------------------------------------ diff --git a/app/pe/pe_semestretag.py b/app/pe/pe_semestretag.py index 695bbe5e..f48e69c4 100644 --- a/app/pe/pe_semestretag.py +++ b/app/pe/pe_semestretag.py @@ -37,9 +37,13 @@ Created on Fri Sep 9 09:15:05 2016 """ from app import log +from app.comp import res_sem +from app.comp.res_common import NotesTableCompat +from app.models import FormSemestre +from app.models.moduleimpls import ModuleImpl + from app.models.ues import UniteEns from app.scodoc import sco_codes_parcours -from app.scodoc import sco_cache from app.scodoc import sco_tag_module from app.pe import pe_tagtable @@ -52,7 +56,7 @@ class SemestreTag(pe_tagtable.TableTag): - nt: le tableau de notes du semestre considéré - nt.inscrlist: étudiants inscrits à ce semestre, par ordre alphabétique (avec demissions) - nt.identdict: { etudid : ident } - - nt._modimpls : liste des moduleimpl { ... 'module_id', ...} + - liste des moduleimpl { ... 'module_id', ...} Attributs supplémentaires : - inscrlist/identdict: étudiants inscrits hors démissionnaires ou défaillants @@ -97,7 +101,11 @@ class SemestreTag(pe_tagtable.TableTag): self.nt = notetable # Les attributs hérités : la liste des étudiants - self.inscrlist = [etud for etud in self.nt.inscrlist if etud["etat"] == "I"] + self.inscrlist = [ + etud + for etud in self.nt.inscrlist + if self.nt.get_etud_etat(etud["etudid"]) == "I" + ] self.identdict = { etudid: ident for (etudid, ident) in self.nt.identdict.items() @@ -107,12 +115,15 @@ class SemestreTag(pe_tagtable.TableTag): # Les modules pris en compte dans le calcul des moyennes par tag => ceux des UE standards self.modimpls = [ modimpl - for modimpl in self.nt._modimpls - if modimpl["ue"]["type"] == sco_codes_parcours.UE_STANDARD + for modimpl in self.nt.formsemestre.modimpls_sorted + if modimpl.module.ue.type == sco_codes_parcours.UE_STANDARD ] # la liste des modules (objet modimpl) - # self._modimpl_ids = [modimpl['moduleimpl_id'] for modimpl in self._modimpls] # la liste de id des modules (modimpl_id) self.somme_coeffs = sum( - [modimpl["module"]["coefficient"] for modimpl in self.modimpls] + [ + modimpl.module.coefficient + for modimpl in self.modimpls + if modimpl.module.coefficient is not None + ] ) # ----------------------------------------------------------------------------- @@ -156,9 +167,9 @@ class SemestreTag(pe_tagtable.TableTag): tagdict = {} for modimpl in self.modimpls: - modimpl_id = modimpl["moduleimpl_id"] + modimpl_id = modimpl.id # liste des tags pour le modimpl concerné: - tags = sco_tag_module.module_tag_list(modimpl["module_id"]) + tags = sco_tag_module.module_tag_list(modimpl.module.id) for ( tag @@ -172,17 +183,13 @@ class SemestreTag(pe_tagtable.TableTag): # Ajout du modimpl au tagname considéré tagdict[tagname][modimpl_id] = { - "module_id": modimpl["module_id"], # les données sur le module - "coeff": modimpl["module"][ - "coefficient" - ], # le coeff du module dans le semestre + "module_id": modimpl.module.id, # les données sur le module + "coeff": modimpl.module.coefficient, # le coeff du module dans le semestre "ponderation": ponderation, # la pondération demandée pour le tag sur le module - "module_code": modimpl["module"][ - "code" - ], # le code qui doit se retrouver à l'identique dans des ue capitalisee - "ue_id": modimpl["ue"]["ue_id"], # les données sur l'ue - "ue_code": modimpl["ue"]["ue_code"], - "ue_acronyme": modimpl["ue"]["acronyme"], + "module_code": modimpl.module.code, # le code qui doit se retrouver à l'identique dans des ue capitalisee + "ue_id": modimpl.module.ue.id, # les données sur l'ue + "ue_code": modimpl.module.ue.ue_code, + "ue_acronyme": modimpl.module.ue.acronyme, } return tagdict @@ -218,7 +225,9 @@ class SemestreTag(pe_tagtable.TableTag): def get_moyennes_DUT(self): """Lit les moyennes DUT du semestre pour tous les étudiants et les renvoie au même format que comp_MoyennesTag""" - return [(self.nt.moy_gen[etudid], 1.0, etudid) for etudid in self.get_etudids()] + return [ + (self.nt.etud_moy_gen[etudid], 1.0, etudid) for etudid in self.get_etudids() + ] # ----------------------------------------------------------------------------- def get_noteEtCoeff_modimpl(self, modimpl_id, etudid, profondeur=2): @@ -230,7 +239,7 @@ class SemestreTag(pe_tagtable.TableTag): """ (note, coeff_norm) = (None, None) - modimpl = get_moduleimpl(self.nt, modimpl_id) # Le module considéré + modimpl = get_moduleimpl(modimpl_id) # Le module considéré if modimpl == None or profondeur < 0: return (None, None) @@ -238,14 +247,14 @@ class SemestreTag(pe_tagtable.TableTag): ue_capitalisees = self.get_ue_capitalisees( etudid ) # les ue capitalisées des étudiants - ue_capitalisees_id = [ - ue.id for ue in ue_capitalisees - ] # les id des ue capitalisées + ue_capitalisees_id = { + ue_cap["ue_id"] for ue_cap in ue_capitalisees + } # les id des ue capitalisées # Si le module ne fait pas partie des UE capitalisées - if modimpl["module"]["ue_id"] not in ue_capitalisees_id: + if modimpl.module.ue.id not in ue_capitalisees_id: note = self.nt.get_etud_mod_moy(modimpl_id, etudid) # lecture de la note - coeff = modimpl["module"]["coefficient"] # le coeff + coeff = modimpl.module.coefficient # le coeff coeff_norm = ( coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0 ) # le coeff normalisé @@ -256,29 +265,30 @@ class SemestreTag(pe_tagtable.TableTag): self.nt, etudid, modimpl_id ) # la moyenne actuelle # A quel semestre correspond l'ue capitalisée et quelles sont ses notes ? - # fid_prec = [ ue['formsemestre_id'] for ue in ue_capitalisees if ue['ue_id'] == modimpl['module']['ue_id'] ][0] - # semestre_id = modimpl['module']['semestre_id'] fids_prec = [ - ue["formsemestre_id"] - for ue in ue_capitalisees - if ue.ue_code == modimpl["ue"]["ue_code"] + ue_cap["formsemestre_id"] + for ue_cap in ue_capitalisees + if ue_cap["ue_code"] == modimpl.module.ue.ue_code ] # and ue['semestre_id'] == semestre_id] if len(fids_prec) > 0: # => le formsemestre_id du semestre dont vient la capitalisation fid_prec = fids_prec[0] # Lecture des notes de ce semestre - nt_prec = sco_cache.NotesTableCache.get( - fid_prec - ) # le tableau de note du semestre considéré + # le tableau de note du semestre considéré: + formsemestre_prec = FormSemestre.query.get_or_404(fid_prec) + nt_prec: NotesTableCompat = res_sem.load_formsemestre_results( + formsemestre_prec + ) # Y-a-t-il un module équivalent c'est à dire correspondant au même code (le module_id n'étant pas significatif en cas de changement de PPN) + modimpl_prec = [ - module - for module in nt_prec._modimpls - if module["module"]["code"] == modimpl["module"]["code"] + modi + for modi in nt_prec.formsemestre.modimpls_sorted + if modi.module.code == modimpl.module.code ] if len(modimpl_prec) > 0: # si une correspondance est trouvée - modprec_id = modimpl_prec[0]["moduleimpl_id"] + modprec_id = modimpl_prec[0].id moy_ue_capitalisee = get_moy_ue_from_nt(nt_prec, etudid, modprec_id) if ( moy_ue_capitalisee is None @@ -286,7 +296,7 @@ class SemestreTag(pe_tagtable.TableTag): note = self.nt.get_etud_mod_moy( modimpl_id, etudid ) # lecture de la note - coeff = modimpl["module"]["coefficient"] # le coeff + coeff = modimpl.module.coefficient # le coeff coeff_norm = ( coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0 ) # le coeff normalisé @@ -300,13 +310,11 @@ class SemestreTag(pe_tagtable.TableTag): return (note, coeff_norm) # ----------------------------------------------------------------------------- - def get_ue_capitalisees(self, etudid) -> list[UniteEns]: - """Renvoie la liste des ue_id effectivement capitalisées par un étudiant""" - ue_ids = [ - ue_id - for ue_id in self.nt.validations.ue_capitalisees.loc[[etudid]]["ue_id"] - ] - return [UniteEns.query.get(ue_id) for ue_id in ue_ids] + def get_ue_capitalisees(self, etudid) -> list[dict]: + """Renvoie la liste des capitalisation effectivement capitalisées par un étudiant""" + if etudid in self.nt.validations.ue_capitalisees.index: + return self.nt.validations.ue_capitalisees.loc[[etudid]].to_dict("records") + return [] # ----------------------------------------------------------------------------- def get_listesNotesEtCoeffsTagEtudiant(self, tag, etudid): @@ -472,37 +480,27 @@ def comp_coeff_pond(coeffs, ponderations): # ----------------------------------------------------------------------------- -def get_moduleimpl(nt, modimpl_id): - """Renvoie l'objet modimpl dont l'id est modimpl_id fourni dans la note table nt, - en utilisant l'attribut nt._modimpls""" - modimplids = [ - modimpl["moduleimpl_id"] for modimpl in nt._modimpls - ] # la liste de id des modules (modimpl_id) - if modimpl_id not in modimplids: - if SemestreTag.DEBUG: - log( - "SemestreTag.get_moduleimpl( %s ) : le modimpl recherche n'existe pas" - % (modimpl_id) - ) - return None - return nt._modimpls[modimplids.index(modimpl_id)] +def get_moduleimpl(modimpl_id) -> dict: + """Renvoie l'objet modimpl dont l'id est modimpl_id""" + modimpl = ModuleImpl.query.get(modimpl_id) + if modimpl: + return modimpl + if SemestreTag.DEBUG: + log( + "SemestreTag.get_moduleimpl( %s ) : le modimpl recherche n'existe pas" + % (modimpl_id) + ) + return None # ********************************************** -def get_moy_ue_from_nt(nt, etudid, modimpl_id): - """Renvoie la moyenne de l'UE d'un etudid dans laquelle se trouve le module de modimpl_id - en partant du note table nt""" - mod = get_moduleimpl(nt, modimpl_id) # le module - indice = 0 - while indice < len(nt._ues): - if ( - nt._ues[indice]["ue_id"] == mod["module"]["ue_id"] - ): # si les ue_id correspond - data = [ - ligne for ligne in nt.T if ligne[-1] == etudid - ] # les notes de l'étudiant - if data: - return data[0][indice + 1] # la moyenne à l'ue - else: - indice += 1 - return None # si non trouvé +def get_moy_ue_from_nt(nt, etudid, modimpl_id) -> float: + """Renvoie la moyenne de l'UE d'un etudid dans laquelle se trouve + le module de modimpl_id + """ + # ré-écrit + modimpl = get_moduleimpl(modimpl_id) # le module + ue_status = nt.get_etud_ue_status(etudid, modimpl.module.ue.id) + if ue_status is None: + return None + return ue_status["moy"] diff --git a/app/pe/pe_tagtable.py b/app/pe/pe_tagtable.py index 54e5e395..0e5045cb 100644 --- a/app/pe/pe_tagtable.py +++ b/app/pe/pe_tagtable.py @@ -68,7 +68,7 @@ class TableTag(object): self.taglist = [] self.resultats = {} - self.etud_moy_gen_ranks = {} + self.rangs = {} self.statistiques = {} # ***************************************************************************************************************** diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py index 25442194..1cf4ea92 100644 --- a/app/scodoc/TrivialFormulator.py +++ b/app/scodoc/TrivialFormulator.py @@ -254,13 +254,13 @@ class TF(object): continue # allowed empty field, skip # type typ = descr.get("type", "string") - if val != "" and val != None: + if val != "" and val is not None: # check only non-null values if typ[:3] == "int": try: val = int(val) self.values[field] = val - except: + except ValueError: msg.append( "La valeur du champ '%s' doit être un nombre entier" % field ) @@ -270,30 +270,24 @@ class TF(object): try: val = float(val.replace(",", ".")) # allow , self.values[field] = val - except: + except ValueError: msg.append( "La valeur du champ '%s' doit être un nombre" % field ) ok = 0 - if typ[:3] == "int" or typ == "float" or typ == "real": - if ( - val != "" - and val != None - and "min_value" in descr - and val < descr["min_value"] - ): + if ( + ok + and (typ[:3] == "int" or typ == "float" or typ == "real") + and val != "" + and val != None + ): + if "min_value" in descr and self.values[field] < descr["min_value"]: msg.append( "La valeur (%d) du champ '%s' est trop petite (min=%s)" % (val, field, descr["min_value"]) ) ok = 0 - - if ( - val != "" - and val != None - and "max_value" in descr - and val > descr["max_value"] - ): + if "max_value" in descr and self.values[field] > descr["max_value"]: msg.append( "La valeur (%s) du champ '%s' est trop grande (max=%s)" % (val, field, descr["max_value"]) diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index d014d9fa..b1ac97b8 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -171,7 +171,7 @@ class NotesTable: def __init__(self, formsemestre_id): # log(f"NotesTable( formsemestre_id={formsemestre_id} )") - # raise NotImplementedError() # XXX + raise NotImplementedError() # XXX if not formsemestre_id: raise ValueError("invalid formsemestre_id (%s)" % formsemestre_id) self.formsemestre_id = formsemestre_id @@ -954,9 +954,12 @@ class NotesTable: Return: True|False, message explicatif """ - return self.parcours.check_barre_ues( - [self.get_etud_ue_status(etudid, ue["ue_id"]) for ue in self._ues] - ) + ue_status_list = [] + for ue in self._ues: + ue_status = self.get_etud_ue_status(etudid, ue["ue_id"]) + if ue_status: + ue_status_list.append(ue_status) + return self.parcours.check_barre_ues(ue_status_list) def get_table_moyennes_triees(self): return self.T @@ -1160,9 +1163,11 @@ class NotesTable: nt_cap = sco_cache.NotesTableCache.get( ue_cap["formsemestre_id"] ) # > UE capitalisees par un etud - moy_ue_cap = nt_cap.get_etud_ue_status(etudid, ue_cap["ue_id"])[ - "moy" - ] + ue_cap_status = nt_cap.get_etud_ue_status(etudid, ue_cap["ue_id"]) + if ue_cap_status: + moy_ue_cap = ue_cap_status["moy"] + else: + moy_ue_cap = "" ue_cap["moy_ue"] = moy_ue_cap if ( isinstance(moy_ue_cap, float) diff --git a/app/scodoc/sco_abs.py b/app/scodoc/sco_abs.py index 0b38559f..73345296 100644 --- a/app/scodoc/sco_abs.py +++ b/app/scodoc/sco_abs.py @@ -479,7 +479,7 @@ def _get_abs_description(a, cursor=None): ) if Mlist: M = Mlist[0] - module += "%s " % M["module"]["code"] + module += "%s " % (M["module"]["code"] or "(module sans code)") if desc: return "(%s) %s" % (desc, module) diff --git a/app/scodoc/sco_abs_views.py b/app/scodoc/sco_abs_views.py index 2f108c24..686d589d 100644 --- a/app/scodoc/sco_abs_views.py +++ b/app/scodoc/sco_abs_views.py @@ -33,7 +33,9 @@ import datetime from flask import url_for, g, request, abort from app import log -from app.models import Identite +from app.comp import res_sem +from app.comp.res_common import NotesTableCompat +from app.models import Identite, FormSemestre import app.scodoc.sco_utils as scu from app.scodoc import notesdb as ndb from app.scodoc.scolog import logdb @@ -118,13 +120,16 @@ def doSignaleAbsence( if moduleimpl_id and moduleimpl_id != "NULL": mod = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] formsemestre_id = mod["formsemestre_id"] - nt = sco_cache.NotesTableCache.get(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) ues = nt.get_ues_stat_dict() for ue in ues: modimpls = nt.get_modimpls_dict(ue_id=ue["ue_id"]) for modimpl in modimpls: if modimpl["moduleimpl_id"] == moduleimpl_id: - indication_module = "dans le module %s" % modimpl["module"]["code"] + indication_module = "dans le module %s" % ( + modimpl["module"]["code"] or "(pas de code)" + ) H = [ html_sco_header.sco_header( page_title=f"Signalement d'une absence pour {etud.nomprenom}", @@ -179,11 +184,12 @@ def SignaleAbsenceEtud(): # etudid implied menu_module = "" else: formsemestre_id = etud["cursem"]["formsemestre_id"] + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + ues = nt.get_ues_stat_dict() require_module = sco_preferences.get_preference( "abs_require_module", formsemestre_id ) - nt = sco_cache.NotesTableCache.get(formsemestre_id) - ues = nt.get_ues_stat_dict() if require_module: menu_module = """