diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 1e861c9e..17658ffa 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -650,13 +650,10 @@ class DecisionsProposeesAnnee(DecisionsProposees): à poursuivre après le semestre courant. """ # La poursuite d'études dans un semestre pair d’une même année - # est de droit pour tout étudiant. - # Pas de redoublements directs de S_impair vers S_impair - # (pourront être traités manuellement) - if ( - self.formsemestre.semestre_id % 2 - ) and self.formsemestre.semestre_id < sco_codes.CursusBUT.NB_SEM: - return {self.formsemestre.semestre_id + 1} + # est de droit pour tout étudiant: + if (self.formsemestre.semestre_id % 2) and sco_codes.CursusBUT.NB_SEM: + ids.add(self.formsemestre.semestre_id + 1) + # La poursuite d’études dans un semestre impair est possible si # et seulement si l’étudiant a obtenu : # - la moyenne à plus de la moitié des regroupements cohérents d’UE ; @@ -670,7 +667,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): if ( self.jury_annuel and code in sco_codes.BUT_CODES_PASSAGE - and self.formsemestre.semestre_id < sco_codes.CursusBUT.NB_SEM + and self.formsemestre_pair.semestre_id < sco_codes.CursusBUT.NB_SEM ): ids.add(self.formsemestre.semestre_id + 1) diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index d4eef0dc..80c665f2 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -43,7 +43,7 @@ from app.models import ( UniteEns, ) from app.comp import moy_mod -from app.scodoc import sco_codes_parcours +from app.scodoc import codes_cursus from app.scodoc import sco_preferences from app.scodoc.codes_cursus import UE_SPORT from app.scodoc.sco_utils import ModuleType diff --git a/app/models/modules.py b/app/models/modules.py index 791398db..e3610148 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -5,7 +5,8 @@ from app import db from app.models import APO_CODE_STR_LEN from app.models.but_refcomp import ApcParcours, app_critiques_modules, parcours_modules from app.scodoc import sco_utils as scu -from app.scodoc.sco_codes_parcours import UE_SPORT +from app.scodoc.codes_cursus import UE_SPORT +from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_utils import ModuleType diff --git a/app/pe/pe_jurype.py b/app/pe/pe_jurype.py index f55623d3..79b92f2b 100644 --- a/app/pe/pe_jurype.py +++ b/app/pe/pe_jurype.py @@ -65,8 +65,10 @@ def comp_nom_semestre_dans_parcours(sem): """Le nom a afficher pour titrer un semestre par exemple: "semestre 2 FI 2015" """ - formation: Formation = Formation.query.get_or_404(sem["formation_id"]) - parcours = codes_cursus.get_cursus_from_code(formation.type_parcours) + from app.scodoc import sco_formations + + F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] + parcours = codes_cursus.get_cursus_from_code(F["type_parcours"]) return "%s %s %s %s" % ( parcours.SESSION_NAME, # eg "semestre" sem["semestre_id"], # eg 2 diff --git a/app/scodoc/codes_cursus.py b/app/scodoc/codes_cursus.py index e7af230d..001a1b8a 100644 --- a/app/scodoc/codes_cursus.py +++ b/app/scodoc/codes_cursus.py @@ -824,8 +824,7 @@ FORMATION_CURSUS_DESCRS = [p[1].__doc__ for p in _tp] # intitulés (eg pour men FORMATION_CURSUS_TYPES = [p[0] for p in _tp] # codes numeriques (TYPE_CURSUS) -def get_cursus_from_code(code_cursus: int) -> TypeCursus: - "renvoie le cursus de code indiqué" +def get_cursus_from_code(code_cursus): cursus = SCO_CURSUS.get(code_cursus) if cursus is None: log(f"Warning: invalid code_cursus: {code_cursus}") diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py new file mode 100644 index 00000000..0379bbc2 --- /dev/null +++ b/app/scodoc/notes_table.py @@ -0,0 +1,1356 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2023 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 +# +############################################################################## + +"""Calculs sur les notes et cache des résultats + + Ancien code ScoDoc 7 en cours de rénovation +""" + +from operator import itemgetter + +from flask import g, url_for + +from app.but import bulletin_but +from app.models import FormSemestre, Identite +from app.models import ScoDocSiteConfig +import app.scodoc.sco_utils as scu +from app.scodoc.sco_utils import ModuleType +import app.scodoc.notesdb as ndb +from app import log +from app.scodoc.sco_formulas import NoteVector +from app.scodoc.sco_exceptions import ScoValueError + +from app.scodoc.sco_formsemestre import ( + formsemestre_uecoef_list, + formsemestre_uecoef_create, +) +from app.scodoc.codes_cursus import ( + DEF, + UE_SPORT, + ue_is_fondamentale, + ue_is_professionnelle, +) +from app.scodoc import sco_cache +from app.scodoc import codes_cursus +from app.scodoc import sco_compute_moy +from app.scodoc.sco_cursus import formsemestre_get_etud_capitalisation +from app.scodoc import sco_cursus_dut +from app.scodoc import sco_edit_matiere +from app.scodoc import sco_edit_module +from app.scodoc import sco_edit_ue +from app.scodoc import sco_etud +from app.scodoc import sco_evaluations +from app.scodoc import sco_formations +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_groups +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_preferences + + +def comp_ranks(T): + """Calcul rangs à partir d'une liste ordonnée de tuples [ (valeur, ..., etudid) ] + (valeur est une note numérique), en tenant compte des ex-aequos + Le resultat est: { etudid : rang } où rang est une chaine decrivant le rang + """ + rangs = {} # { etudid : rang } (rang est une chaine) + nb_ex = 0 # nb d'ex-aequo consécutifs en cours + for i in range(len(T)): + # test ex-aequo + if i < len(T) - 1: + next = T[i + 1][0] + else: + next = None + moy = T[i][0] + if nb_ex: + srang = "%d ex" % (i + 1 - nb_ex) + if moy == next: + nb_ex += 1 + else: + nb_ex = 0 + else: + if moy == next: + srang = "%d ex" % (i + 1 - nb_ex) + nb_ex = 1 + else: + srang = "%d" % (i + 1) + rangs[T[i][-1]] = srang # str(i+1) + return rangs + + +def get_sem_ues_modimpls(formsemestre_id, modimpls=None): + """Get liste des UE du semestre (à partir des moduleimpls) + (utilisé quand on ne peut pas construire nt et faire nt.get_ues_stat_dict()) + """ + if modimpls is None: + modimpls = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id) + uedict = {} + for modimpl in modimpls: + mod = sco_edit_module.module_list(args={"module_id": modimpl["module_id"]})[0] + modimpl["module"] = mod + if not mod["ue_id"] in uedict: + ue = sco_edit_ue.ue_list(args={"ue_id": mod["ue_id"]})[0] + uedict[ue["ue_id"]] = ue + ues = list(uedict.values()) + ues.sort(key=lambda u: u["numero"]) + return ues, modimpls + + +def comp_etud_sum_coef_modules_ue(formsemestre_id, etudid, ue_id): + """Somme des coefficients des modules de l'UE dans lesquels cet étudiant est inscrit + ou None s'il n'y a aucun module. + + (nécessaire pour éviter appels récursifs de nt, qui peuvent boucler) + """ + infos = ndb.SimpleDictFetch( + """SELECT mod.coefficient + FROM notes_modules mod, notes_moduleimpl mi, notes_moduleimpl_inscription ins + WHERE mod.id = mi.module_id + and ins.etudid = %(etudid)s + and ins.moduleimpl_id = mi.id + and mi.formsemestre_id = %(formsemestre_id)s + and mod.ue_id = %(ue_id)s + """, + {"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id}, + ) + + if not infos: + return None + else: + s = sum(x["coefficient"] for x in infos) + return s + + +class NotesTable: + """Une NotesTable représente un tableau de notes pour un semestre de formation. + Les colonnes sont des modules. + Les lignes des étudiants. + On peut calculer les moyennes par étudiant (pondérées par les coefs) + ou les moyennes par module. + + Attributs publics (en lecture): + - inscrlist: étudiants inscrits à ce semestre, par ordre alphabétique (avec demissions) + - identdict: { etudid : ident } + - sem : le formsemestre + get_table_moyennes_triees: [ (moy_gen, moy_ue1, moy_ue2, ... moy_ues, moy_mod1, ..., moy_modn, etudid) ] + (où toutes les valeurs sont soit des nombres soit des chaines spéciales comme 'NA', 'NI'), + incluant les UE de sport + + - bonus[etudid] : valeur du bonus "sport". + + Attributs privés: + - _modmoys : { moduleimpl_id : { etudid: note_moyenne_dans_ce_module } } + - _ues : liste des UE de ce semestre (hors capitalisees) + - _matmoys : { matiere_id : { etudid: note moyenne dans cette matiere } } + + """ + + def __init__(self, formsemestre_id): + # log(f"NotesTable( formsemestre_id={formsemestre_id} )") + raise NotImplementedError() # XXX + if not formsemestre_id: + raise ValueError("invalid formsemestre_id (%s)" % formsemestre_id) + self.formsemestre_id = formsemestre_id + cnx = ndb.GetDBConnexion() + self.sem = sco_formsemestre.get_formsemestre(formsemestre_id) + self.moduleimpl_stats = {} # { moduleimpl_id : {stats} } + self._uecoef = {} # { ue_id : coef } cache coef manuels ue cap + self._evaluations_etats = None # liste des evaluations avec état + self.use_ue_coefs = sco_preferences.get_preference( + "use_ue_coefs", formsemestre_id + ) + # si vrai, bloque calcul des moy gen. et d'UE.: + self.block_moyennes = self.sem["block_moyennes"] + # Infos sur les etudiants + self.inscrlist = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id} + ) + # infos identite etudiant + # xxx sous-optimal: 1/select par etudiant -> 0.17" pour identdict sur GTR1 ! + self.identdict = {} # { etudid : ident } + self.inscrdict = {} # { etudid : inscription } + for x in self.inscrlist: + i = sco_etud.etudident_list(cnx, {"etudid": x["etudid"]})[0] + self.identdict[x["etudid"]] = i + self.inscrdict[x["etudid"]] = x + x["nomp"] = (i["nom_usuel"] or i["nom"]) + i["prenom"] # pour tri + + # Tri les etudids par NOM + self.inscrlist.sort(key=itemgetter("nomp")) + + # { etudid : rang dans l'ordre alphabetique } + self._rang_alpha = {e["etudid"]: i for i, e in enumerate(self.inscrlist)} + + self.bonus = scu.DictDefault(defaultvalue=0) + # Notes dans les modules { moduleimpl_id : { etudid: note_moyenne_dans_ce_module } } + ( + self._modmoys, + self._modimpls, + self._valid_evals_per_mod, + valid_evals, + mods_att, + self.expr_diagnostics, + ) = sco_compute_moy.formsemestre_compute_modimpls_moyennes( + self, formsemestre_id + ) + self._mods_att = mods_att # liste des modules avec des notes en attente + self._matmoys = {} # moyennes par matieres + self._valid_evals = {} # { evaluation_id : eval } + for e in valid_evals: + self._valid_evals[e["evaluation_id"]] = e # Liste des modules et UE + uedict = {} # public member: { ue_id : ue } + self.uedict = uedict # les ues qui ont un modimpl dans ce semestre + for modimpl in self._modimpls: + # module has been added by formsemestre_compute_modimpls_moyennes + mod = modimpl["module"] + if not mod["ue_id"] in uedict: + ue = sco_edit_ue.ue_list(args={"ue_id": mod["ue_id"]})[0] + uedict[ue["ue_id"]] = ue + else: + ue = uedict[mod["ue_id"]] + modimpl["ue"] = ue # add ue dict to moduleimpl + self._matmoys[mod["matiere_id"]] = {} + mat = sco_edit_matiere.matiere_list(args={"matiere_id": mod["matiere_id"]})[ + 0 + ] + modimpl["mat"] = mat # add matiere dict to moduleimpl + # calcul moyennes du module et stocke dans le module + # nb_inscrits, nb_notes, nb_abs, nb_neutre, moy, median, last_modif= + + self.formation = sco_formations.formation_list( + args={"formation_id": self.sem["formation_id"]} + )[0] + self.parcours = codes_cursus.get_cursus_from_code( + self.formation["type_parcours"] + ) + + # En APC, il faut avoir toutes les UE du semestre + # (elles n'ont pas nécessairement un module rattaché): + if self.parcours.APC_SAE: + formsemestre = FormSemestre.query.get(formsemestre_id) + for ue in formsemestre.query_ues(): + if ue.id not in self.uedict: + self.uedict[ue.id] = ue.to_dict() + + # Decisions jury et UE capitalisées + self.comp_decisions_jury() + self.comp_ue_capitalisees() + + # Liste des moyennes de tous, en chaines de car., triées + self._ues = list(uedict.values()) + self._ues.sort(key=lambda u: u["numero"]) + + T = [] + + self.moy_gen = {} # etudid : moy gen (avec UE capitalisées) + self.moy_ue = {} # ue_id : { etudid : moy ue } (valeur numerique) + self.etud_moy_infos = {} # etudid : resultats de comp_etud_moy_gen() + valid_moy = [] # liste des valeurs valides de moyenne generale (pour min/max) + for ue in self._ues: + self.moy_ue[ue["ue_id"]] = {} + self._etud_moy_ues = {} # { etudid : { ue_id : {'moy', 'sum_coefs', ... } } + + for etudid in self.get_etudids(): + etud_moy_gen = self.comp_etud_moy_gen(etudid, cnx) + self.etud_moy_infos[etudid] = etud_moy_gen + ue_status = etud_moy_gen["moy_ues"] + self._etud_moy_ues[etudid] = ue_status + + moy_gen = etud_moy_gen["moy"] + self.moy_gen[etudid] = moy_gen + if etud_moy_gen["sum_coefs"] > 0: + valid_moy.append(moy_gen) + + moy_ues = [] + for ue in self._ues: + moy_ue = ue_status[ue["ue_id"]]["moy"] + moy_ues.append(moy_ue) + self.moy_ue[ue["ue_id"]][etudid] = moy_ue + + t = [moy_gen] + moy_ues + # + is_cap = {} # ue_id : is_capitalized + for ue in self._ues: + is_cap[ue["ue_id"]] = ue_status[ue["ue_id"]]["is_capitalized"] + + for modimpl in self.get_modimpls_dict(): + val = self.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) + if is_cap[modimpl["module"]["ue_id"]]: + t.append("-c-") + else: + t.append(val) + # + t.append(etudid) + T.append(t) + + self.T = T + # tri par moyennes décroissantes, + # en laissant les demissionnaires a la fin, par ordre alphabetique + self.T.sort(key=self._row_key) + + if len(valid_moy): + self.moy_min = min(valid_moy) + self.moy_max = max(valid_moy) + else: + self.moy_min = self.moy_max = "NA" + + # calcul rangs (/ moyenne generale) + self.etud_moy_gen_ranks = comp_ranks(T) + + self.rangs_groupes = ( + {} + ) # { group_id : { etudid : rang } } (lazy, see get_etud_rang_group) + self.group_etuds = ( + {} + ) # { group_id : set of etudids } (lazy, see get_etud_rang_group) + + # calcul rangs dans chaque UE + ue_rangs = ( + {} + ) # ue_rangs[ue_id] = ({ etudid : rang }, nb_inscrits) (rang est une chaine) + for ue in self._ues: + ue_id = ue["ue_id"] + val_ids = [ + (self.moy_ue[ue_id][etudid], etudid) for etudid in self.moy_ue[ue_id] + ] + ue_eff = len( + [x for x in val_ids if isinstance(x[0], float)] + ) # nombre d'étudiants avec une note dans l'UE + val_ids.sort(key=self._row_key) + ue_rangs[ue_id] = ( + comp_ranks(val_ids), + ue_eff, + ) # et non: len(self.moy_ue[ue_id]) qui est l'effectif de la promo + self.ue_rangs = ue_rangs + # ---- calcul rangs dans les modules + self.mod_rangs = {} + for modimpl in self._modimpls: + vals = self._modmoys[modimpl["moduleimpl_id"]] + val_ids = [(vals[etudid], etudid) for etudid in vals.keys()] + val_ids.sort(key=self._row_key) + self.mod_rangs[modimpl["moduleimpl_id"]] = (comp_ranks(val_ids), len(vals)) + # + self.compute_moy_moy() + # + log(f"NotesTable( formsemestre_id={formsemestre_id} ) done.") + + def _row_key(self, x): + """clé de tri par moyennes décroissantes, + en laissant les demissionnaires a la fin, par ordre alphabetique. + (moy_gen, rang_alpha) + """ + try: + moy = -float(x[0]) + except (ValueError, TypeError): + moy = 1000.0 + return (moy, self._rang_alpha[x[-1]]) + + def get_etudids(self, sorted=False): + if sorted: + # Tri par moy. generale décroissante + return [x[-1] for x in self.T] + else: + # Tri par ordre alphabetique de NOM + return [x["etudid"] for x in self.inscrlist] + + def get_sexnom(self, etudid): + "M. DUPONT" + etud = self.identdict[etudid] + return etud["civilite_str"] + " " + (etud["nom_usuel"] or etud["nom"]).upper() + + def get_nom_short(self, etudid): + "formatte nom d'un etud (pour table recap)" + etud = self.identdict[etudid] + # Attention aux caracteres multibytes pour decouper les 2 premiers: + return ( + (etud["nom_usuel"] or etud["nom"]).upper() + + " " + + etud["prenom"].capitalize()[:2] + + "." + ) + + def get_nom_long(self, etudid): + "formatte nom d'un etud: M. Pierre DUPONT" + etud = self.identdict[etudid] + return sco_etud.format_nomprenom(etud) + + def get_displayed_etud_code(self, etudid): + 'code à afficher sur les listings "anonymes"' + return self.identdict[etudid]["code_nip"] or self.identdict[etudid]["etudid"] + + def get_etud_etat(self, etudid): + "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)" + if etudid in self.inscrdict: + return self.inscrdict[etudid]["etat"] + else: + return "" + + def get_etud_etat_html(self, etudid): + etat = self.inscrdict[etudid]["etat"] + if etat == scu.INSCRIT: + return "" + elif etat == scu.DEMISSION: + return ' (DEMISSIONNAIRE) ' + elif etat == DEF: + return ' (DEFAILLANT) ' + else: + return ' (%s) ' % etat + + def get_ues_stat_dict(self, filter_sport=False): # was get_ues() + """Liste des UEs, ordonnée par numero. + Si filter_sport, retire les UE de type SPORT + """ + if not filter_sport: + return self._ues + else: + return [ue for ue in self._ues if ue["type"] != UE_SPORT] + + def get_modimpls_dict(self, ue_id=None): + "Liste des modules pour une UE (ou toutes si ue_id==None), triés par matières." + if ue_id is None: + r = self._modimpls + else: + r = [m for m in self._modimpls if m["ue"]["ue_id"] == ue_id] + # trie la liste par ue.numero puis mat.numero puis mod.numero + r.sort( + key=lambda x: (x["ue"]["numero"], x["mat"]["numero"], x["module"]["numero"]) + ) + return r + + def get_etud_eval_note(self, etudid, evaluation_id): + "note d'un etudiant a une evaluation" + return self._valid_evals[evaluation_id]["notes"][etudid] + + def get_evals_in_mod(self, moduleimpl_id): + "liste des evaluations valides dans un module" + return [ + e for e in self._valid_evals.values() if e["moduleimpl_id"] == moduleimpl_id + ] + + def get_mod_stats(self, moduleimpl_id): + """moyenne generale, min, max pour un module + Ne prend en compte que les evaluations où toutes les notes sont entrées + Cache le resultat. + """ + if moduleimpl_id in self.moduleimpl_stats: + return self.moduleimpl_stats[moduleimpl_id] + nb_notes = 0 + sum_notes = 0.0 + nb_missing = 0 + moys = self._modmoys[moduleimpl_id] + vals = [] + for etudid in self.get_etudids(): + # saute les demissionnaires et les défaillants: + if self.inscrdict[etudid]["etat"] != scu.INSCRIT: + continue + val = moys.get(etudid, None) # None si non inscrit + try: + vals.append(float(val)) + except: + nb_missing = nb_missing + 1 + sum_notes = sum(vals) + nb_notes = len(vals) + if nb_notes > 0: + moy = sum_notes / nb_notes + max_note, min_note = max(vals), min(vals) + else: + moy, min_note, max_note = "NA", "-", "-" + s = { + "moy": moy, + "max": max_note, + "min": min_note, + "nb_notes": nb_notes, + "nb_missing": nb_missing, + "nb_valid_evals": len(self._valid_evals_per_mod[moduleimpl_id]), + } + self.moduleimpl_stats[moduleimpl_id] = s + return s + + def compute_moy_moy(self): + """precalcule les moyennes d'UE et generale (moyennes sur tous + les etudiants), et les stocke dans self.moy_moy, self.ue['moy'] + + Les moyennes d'UE ne tiennent pas compte des capitalisations. + """ + ues = self.get_ues_stat_dict() + sum_moy = 0 # la somme des moyennes générales valides + nb_moy = 0 # le nombre de moyennes générales valides + for ue in ues: + ue["_notes"] = [] # liste tmp des valeurs de notes valides dans l'ue + nb_dem = 0 # nb d'étudiants démissionnaires dans le semestre + nb_def = 0 # nb d'étudiants défaillants dans le semestre + T = self.get_table_moyennes_triees() + for t in T: + etudid = t[-1] + # saute les demissionnaires et les défaillants: + if self.inscrdict[etudid]["etat"] != scu.INSCRIT: + if self.inscrdict[etudid]["etat"] == scu.DEMISSION: + nb_dem += 1 + if self.inscrdict[etudid]["etat"] == DEF: + nb_def += 1 + continue + try: + sum_moy += float(t[0]) + nb_moy += 1 + except: + pass + i = 0 + for ue in ues: + i += 1 + try: + ue["_notes"].append(float(t[i])) + except: + pass + self.nb_demissions = nb_dem + self.nb_defaillants = nb_def + if nb_moy > 0: + self.moy_moy = sum_moy / nb_moy + else: + self.moy_moy = "-" + + i = 0 + for ue in ues: + i += 1 + ue["nb_vals"] = len(ue["_notes"]) + if ue["nb_vals"] > 0: + ue["moy"] = sum(ue["_notes"]) / ue["nb_vals"] + ue["max"] = max(ue["_notes"]) + ue["min"] = min(ue["_notes"]) + else: + ue["moy"], ue["max"], ue["min"] = "", "", "" + del ue["_notes"] + + def get_etud_mod_moy(self, moduleimpl_id, etudid): + """moyenne d'un etudiant dans un module (ou NI si non inscrit)""" + return self._modmoys[moduleimpl_id].get(etudid, "NI") + + def get_etud_mat_moy(self, matiere_id, etudid): + """moyenne d'un étudiant dans une matière (ou NA si pas de notes)""" + matmoy = self._matmoys.get(matiere_id, None) + if not matmoy: + return "NM" # non inscrit + # log('*** oups: get_etud_mat_moy(%s, %s)' % (matiere_id, etudid)) + # raise ValueError('matiere invalide !') # should not occur + return matmoy.get(etudid, "NA") + + def comp_etud_moy_ue(self, etudid, ue_id=None, cnx=None): + """Calcule moyenne gen. pour un etudiant dans une UE + Ne prend en compte que les evaluations où toutes les notes sont entrées + Return a dict(moy, nb_notes, nb_missing, sum_coefs) + Si pas de notes, moy == 'NA' et sum_coefs==0 + Si non inscrit, moy == 'NI' et sum_coefs==0 + """ + assert ue_id + modimpls = self.get_modimpls_dict(ue_id) + nb_notes = 0 # dans cette UE + sum_notes = 0.0 + sum_coefs = 0.0 + nb_missing = 0 # nb de modules sans note dans cette UE + + notes_bonus_gen = [] # liste des notes de sport et culture + coefs_bonus_gen = [] + + ue_malus = 0.0 # malus à appliquer à cette moyenne d'UE + + notes = NoteVector() + coefs = NoteVector() + coefs_mask = NoteVector() # 0/1, 0 si coef a ete annulé + + matiere_id_last = None + matiere_sum_notes = matiere_sum_coefs = 0.0 + + est_inscrit = False # inscrit à l'un des modules de cette UE ? + + for modimpl in modimpls: + # module ne faisant pas partie d'une UE capitalisee + val = self._modmoys[modimpl["moduleimpl_id"]].get(etudid, "NI") + # si 'NI', etudiant non inscrit a ce module + if val != "NI": + est_inscrit = True + if modimpl["module"]["module_type"] == ModuleType.STANDARD: + coef = modimpl["module"]["coefficient"] + if modimpl["ue"]["type"] != UE_SPORT: + notes.append(val, name=modimpl["module"]["code"]) + try: + sum_notes += val * coef + sum_coefs += coef + nb_notes = nb_notes + 1 + coefs.append(coef) + coefs_mask.append(1) + matiere_id = modimpl["module"]["matiere_id"] + if ( + matiere_id_last + and matiere_id != matiere_id_last + and matiere_sum_coefs + ): + self._matmoys[matiere_id_last][etudid] = ( + matiere_sum_notes / matiere_sum_coefs + ) + matiere_sum_notes = matiere_sum_coefs = 0.0 + matiere_sum_notes += val * coef + matiere_sum_coefs += coef + matiere_id_last = matiere_id + except TypeError: # val == "NI" "NA" + assert val == "NI" or val == "NA" or val == "ERR" + nb_missing = nb_missing + 1 + coefs.append(0) + coefs_mask.append(0) + + else: # UE_SPORT: + # la note du module de sport agit directement sur la moyenne gen. + try: + notes_bonus_gen.append(float(val)) + coefs_bonus_gen.append(coef) + except: + # log('comp_etud_moy_ue: exception: val=%s coef=%s' % (val,coef)) + pass + elif modimpl["module"]["module_type"] == ModuleType.MALUS: + try: + ue_malus += val + except: + pass # si non inscrit ou manquant, ignore + elif modimpl["module"]["module_type"] in ( + ModuleType.RESSOURCE, + ModuleType.SAE, + ): + # XXX temporaire pour ne pas bloquer durant le dev + pass + else: + raise ValueError( + "invalid module type (%s)" % modimpl["module"]["module_type"] + ) + + if matiere_id_last and matiere_sum_coefs: + self._matmoys[matiere_id_last][etudid] = ( + matiere_sum_notes / matiere_sum_coefs + ) + + # Calcul moyenne: + if sum_coefs > 0: + moy = sum_notes / sum_coefs + if ue_malus: + moy -= ue_malus + moy = max(scu.NOTES_MIN, min(moy, 20.0)) + moy_valid = True + else: + moy = "NA" + moy_valid = False + + # Recalcule la moyenne en utilisant une formule utilisateur + expr_diag = {} + formula = sco_compute_moy.get_ue_expression(self.formsemestre_id, ue_id) + if formula: + moy = sco_compute_moy.compute_user_formula( + self.sem, + etudid, + moy, + moy_valid, + notes, + coefs, + coefs_mask, + formula, + diag_info=expr_diag, + ) + if expr_diag: + expr_diag["ue_id"] = ue_id + self.expr_diagnostics.append(expr_diag) + + return dict( + moy=moy, + nb_notes=nb_notes, + nb_missing=nb_missing, + sum_coefs=sum_coefs, + notes_bonus_gen=notes_bonus_gen, + coefs_bonus_gen=coefs_bonus_gen, + expr_diag=expr_diag, + ue_malus=ue_malus, + est_inscrit=est_inscrit, + ) + + def comp_etud_moy_gen(self, etudid, cnx): + """Calcule moyenne gen. pour un etudiant + Return a dict: + moy : moyenne générale + nb_notes, nb_missing, sum_coefs + ects_pot : (float) nb de crédits ECTS qui seraient validés (sous réserve de validation par le jury), + ects_pot_fond: (float) nb d'ECTS issus d'UE fondamentales (non électives) + ects_pot_pro: (float) nb d'ECTS issus d'UE pro + moy_ues : { ue_id : ue_status } + où ue_status = { + 'est_inscrit' : True si étudiant inscrit à au moins un module de cette UE + 'moy' : moyenne, avec capitalisation eventuelle + 'capitalized_ue_id' : id de l'UE capitalisée + 'coef_ue' : coef de l'UE utilisé pour le calcul de la moyenne générale + (la somme des coefs des modules, ou le coef d'UE capitalisée, + ou encore le coef d'UE si l'option use_ue_coefs est active) + 'cur_moy_ue' : moyenne de l'UE en cours (sans considérer de capitalisation) + 'cur_coef_ue': coefficient de l'UE courante (inutilisé ?) + 'is_capitalized' : True|False, + 'ects_pot' : (float) nb de crédits ECTS qui seraient validés (sous réserve de validation par le jury), + 'ects_pot_fond': 0. si UE non fondamentale, = ects_pot sinon, + 'ects_pot_pro' : 0 si UE non pro, = ects_pot sinon, + 'formsemestre_id' : (si capitalisee), + 'event_date' : (si capitalisee) + } + Si pas de notes, moy == 'NA' et sum_coefs==0 + + Prend toujours en compte les UE capitalisées. + """ + # Si l'étudiant a Démissionné ou est DEFaillant, on n'enregistre pas ses moyennes + block_computation = ( + self.inscrdict[etudid]["etat"] == "D" + or self.inscrdict[etudid]["etat"] == DEF + or self.block_moyennes + ) + + moy_ues = {} + notes_bonus_gen = ( + [] + ) # liste des notes de sport et culture (s'appliquant à la MG) + coefs_bonus_gen = [] + nb_notes = 0 # nb de notes d'UE (non capitalisees) + sum_notes = 0.0 # somme des notes d'UE + # somme des coefs d'UE (eux-même somme des coefs de modules avec notes): + sum_coefs = 0.0 + + nb_missing = 0 # nombre d'UE sans notes + sem_ects_pot = 0.0 + sem_ects_pot_fond = 0.0 + sem_ects_pot_pro = 0.0 + + for ue in self.get_ues_stat_dict(): + # - On calcule la moyenne d'UE courante: + if not block_computation: + mu = self.comp_etud_moy_ue(etudid, ue_id=ue["ue_id"], cnx=cnx) + else: + mu = dict( + moy="NA", + nb_notes=0, + nb_missing=0, + sum_coefs=0, + notes_bonus_gen=0, + coefs_bonus_gen=0, + expr_diag="", + est_inscrit=False, + ) + # infos supplementaires pouvant servir au calcul du bonus sport + mu["ue"] = ue + moy_ues[ue["ue_id"]] = mu + + # - Faut-il prendre une UE capitalisée ? + if mu["moy"] != "NA" and mu["est_inscrit"]: + max_moy_ue = mu["moy"] + else: + # pas de notes dans l'UE courante, ou pas inscrit + max_moy_ue = 0.0 + if not mu["est_inscrit"]: + coef_ue = 0.0 + else: + if self.use_ue_coefs: + coef_ue = mu["ue"]["coefficient"] + else: + # coef UE = sum des coefs modules + coef_ue = mu["sum_coefs"] + + # is_capitalized si l'UE prise en compte est une UE capitalisée + mu["is_capitalized"] = False + # was_capitalized s'il y a precedemment une UE capitalisée (pas forcement meilleure) + mu["was_capitalized"] = False + + is_external = False + event_date = None + if not block_computation: + for ue_cap in self.ue_capitalisees[etudid]: + if ue_cap["ue_code"] == ue["ue_code"]: + 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 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 + mu["is_capitalized"] = True + capitalized_ue_id = ue_cap["ue_id"] + formsemestre_id = ue_cap["formsemestre_id"] + coef_ue = self.get_etud_ue_cap_coef( + etudid, ue, ue_cap, cnx=cnx + ) + is_external = ue_cap["is_external"] + + mu["cur_moy_ue"] = mu["moy"] # la moyenne dans le sem. courant + if mu["est_inscrit"]: + mu["cur_coef_ue"] = mu["sum_coefs"] + else: + mu["cur_coef_ue"] = 0.0 + mu["moy"] = max_moy_ue # la moyenne d'UE a prendre en compte + mu["is_external"] = is_external # validation externe (dite "antérieure") + mu["coef_ue"] = coef_ue # coef reel ou coef de l'ue si capitalisee + + if mu["is_capitalized"]: + mu["formsemestre_id"] = formsemestre_id + mu["capitalized_ue_id"] = capitalized_ue_id + if mu["was_capitalized"]: + mu["event_date"] = event_date + # - ECTS ? ("pot" pour "potentiels" car les ECTS ne seront acquises qu'apres validation du jury + if ( + isinstance(mu["moy"], float) + and mu["moy"] >= self.parcours.NOTES_BARRE_VALID_UE + ): + mu["ects_pot"] = ue["ects"] or 0.0 + if ue_is_fondamentale(ue["type"]): + mu["ects_pot_fond"] = mu["ects_pot"] + else: + mu["ects_pot_fond"] = 0.0 + if ue_is_professionnelle(ue["type"]): + mu["ects_pot_pro"] = mu["ects_pot"] + else: + mu["ects_pot_pro"] = 0.0 + else: + mu["ects_pot"] = 0.0 + mu["ects_pot_fond"] = 0.0 + mu["ects_pot_pro"] = 0.0 + sem_ects_pot += mu["ects_pot"] + sem_ects_pot_fond += mu["ects_pot_fond"] + sem_ects_pot_pro += mu["ects_pot_pro"] + + # - Calcul moyenne générale dans le semestre: + if mu["is_capitalized"]: + try: + sum_notes += mu["moy"] * mu["coef_ue"] + sum_coefs += mu["coef_ue"] + except: # pas de note dans cette UE + pass + else: + if mu["coefs_bonus_gen"]: + notes_bonus_gen.extend(mu["notes_bonus_gen"]) + coefs_bonus_gen.extend(mu["coefs_bonus_gen"]) + # + try: + sum_notes += mu["moy"] * mu["sum_coefs"] + sum_coefs += mu["sum_coefs"] + nb_notes = nb_notes + 1 + except TypeError: + nb_missing = nb_missing + 1 + # Le resultat: + infos = dict( + nb_notes=nb_notes, + nb_missing=nb_missing, + sum_coefs=sum_coefs, + moy_ues=moy_ues, + ects_pot=sem_ects_pot, + ects_pot_fond=sem_ects_pot_fond, + ects_pot_pro=sem_ects_pot_pro, + sem=self.sem, + ) + # ---- Calcul moyenne (avec bonus sport&culture) + if sum_coefs <= 0 or block_computation: + infos["moy"] = "NA" + else: + if self.use_ue_coefs: + # Calcul optionnel (mai 2020) + # moyenne pondére par leurs coefficients des moyennes d'UE + sum_moy_ue = 0 + sum_coefs_ue = 0 + for mu in moy_ues.values(): + # mu["moy"] can be a number, or "NA", or "ERR" (user-defined UE formulas) + if ( + (mu["ue"]["type"] != UE_SPORT) + and scu.isnumber(mu["moy"]) + and (mu["est_inscrit"] or mu["is_capitalized"]) + ): + coef_ue = mu["ue"]["coefficient"] + sum_moy_ue += mu["moy"] * coef_ue + sum_coefs_ue += coef_ue + if sum_coefs_ue != 0: + infos["moy"] = sum_moy_ue / sum_coefs_ue + else: + infos["moy"] = "NA" + else: + # Calcul standard ScoDoc: moyenne pondérée des notes de modules + infos["moy"] = sum_notes / sum_coefs + + if notes_bonus_gen and infos["moy"] != "NA": + # regle de calcul maison (configurable, voir bonus_sport.py) + if sum(coefs_bonus_gen) <= 0 and len(coefs_bonus_gen) != 1: + log( + "comp_etud_moy_gen: invalid or null coefficient (%s) for notes_bonus_gen=%s (etudid=%s, formsemestre_id=%s)" + % ( + coefs_bonus_gen, + notes_bonus_gen, + etudid, + self.formsemestre_id, + ) + ) + bonus = 0 + else: + 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( + notes_bonus_gen, coefs_bonus_gen, infos=infos + ) + else: + bonus = 0.0 + self.bonus[etudid] = bonus + infos["moy"] += bonus + infos["moy"] = min(infos["moy"], 20.0) # clip bogus bonus + + return infos + + def get_etud_moy_gen(self, etudid): # -> float | str + """Moyenne generale de cet etudiant dans ce semestre. + Prend en compte les UE capitalisées. + Si pas de notes: 'NA' + """ + return self.moy_gen[etudid] + + def get_etud_moy_infos(self, etudid): # XXX OBSOLETE + """Infos sur moyennes""" + return self.etud_moy_infos[etudid] + + # was etud_has_all_ue_over_threshold: + def etud_check_conditions_ues(self, etudid): + """Vrai si les conditions sur les UE sont remplies. + Ne considère que les UE ayant des notes (moyenne calculée). + (les UE sans notes ne sont pas comptées comme sous la barre) + Prend en compte les éventuelles UE capitalisées. + + Pour les parcours habituels, cela revient à vérifier que + les moyennes d'UE sont toutes > à leur barre (sauf celles sans notes) + + Pour les parcours non standards (LP2014), cela peut être plus compliqué. + + Return: True|False, message explicatif + """ + 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 + + def get_etud_rang(self, etudid) -> str: + return self.etud_moy_gen_ranks.get(etudid, "999") + + def get_etud_rang_group(self, etudid, group_id): + """Returns rank of etud in this group and number of etuds in group. + If etud not in group, returns None. + """ + if not group_id in self.rangs_groupes: + # lazy: fill rangs_groupes on demand + # { groupe : { etudid : rang } } + if not group_id in self.group_etuds: + # lazy fill: list of etud in group_id + etuds = sco_groups.get_group_members(group_id) + self.group_etuds[group_id] = set([x["etudid"] for x in etuds]) + # 1- build T restricted to group + Tr = [] + for t in self.get_table_moyennes_triees(): + t_etudid = t[-1] + if t_etudid in self.group_etuds[group_id]: + Tr.append(t) + # + self.rangs_groupes[group_id] = comp_ranks(Tr) + + return ( + self.rangs_groupes[group_id].get(etudid, None), + len(self.rangs_groupes[group_id]), + ) + + def get_table_moyennes_dict(self): + """{ etudid : (liste des moyennes) } comme get_table_moyennes_triees""" + D = {} + for t in self.T: + D[t[-1]] = t + return D + + def get_moduleimpls_attente(self): + "Liste des moduleimpls avec des notes en attente" + return self._mods_att + + # Decisions existantes du jury + def comp_decisions_jury(self): + """Cherche les decisions du jury pour le semestre (pas les UE). + Calcule l'attribut: + decisions_jury = { etudid : { 'code' : None|ATT|..., 'assidu' : 0|1 }} + decision_jury_ues={ etudid : { ue_id : { 'code' : Note|ADM|CMP, 'event_date' }}} + Si la decision n'a pas été prise, la clé etudid n'est pas présente. + Si l'étudiant est défaillant, met un code DEF sur toutes les UE + """ + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + """SELECT etudid, code, assidu, compense_formsemestre_id, event_date + FROM scolar_formsemestre_validation + WHERE formsemestre_id=%(formsemestre_id)s AND ue_id is NULL; + """, + {"formsemestre_id": self.formsemestre_id}, + ) + decisions_jury = {} + for ( + etudid, + code, + assidu, + compense_formsemestre_id, + event_date, + ) in cursor.fetchall(): + decisions_jury[etudid] = { + "code": code, + "assidu": assidu, + "compense_formsemestre_id": compense_formsemestre_id, + "event_date": ndb.DateISOtoDMY(event_date), + } + + self.decisions_jury = decisions_jury + # UEs: + cursor.execute( + "select etudid, ue_id, code, event_date from scolar_formsemestre_validation where formsemestre_id=%(formsemestre_id)s and ue_id is not NULL;", + {"formsemestre_id": self.formsemestre_id}, + ) + decisions_jury_ues = {} + for (etudid, ue_id, code, event_date) in cursor.fetchall(): + if etudid not in decisions_jury_ues: + decisions_jury_ues[etudid] = {} + # Calcul des ECTS associes a cette UE: + ects = 0.0 + if codes_cursus.code_ue_validant(code): + ue = self.uedict.get(ue_id, None) + if ue is None: # not in list for this sem ??? (probably an error) + log( + "Warning: %s capitalized an UE %s which is not part of current sem %s" + % (etudid, ue_id, self.formsemestre_id) + ) + ue = sco_edit_ue.ue_list(args={"ue_id": ue_id})[0] + self.uedict[ue_id] = ue # record this UE + if ue_id not in self._uecoef: + cl = formsemestre_uecoef_list( + cnx, + args={ + "formsemestre_id": self.formsemestre_id, + "ue_id": ue_id, + }, + ) + if not cl: + # cas anormal: UE capitalisee, pas dans ce semestre, et sans coef + log("Warning: setting UE coef to zero") + formsemestre_uecoef_create( + cnx, + args={ + "formsemestre_id": self.formsemestre_id, + "ue_id": ue_id, + "coefficient": 0, + }, + ) + + ects = ue["ects"] or 0.0 # 0 if None + + decisions_jury_ues[etudid][ue_id] = { + "code": code, + "ects": ects, # 0. si non UE validée ou si mode de calcul different (?) + "event_date": ndb.DateISOtoDMY(event_date), + } + + self.decisions_jury_ues = decisions_jury_ues + + def get_etud_decision_sem(self, etudid): + """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu. + { 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id } + Si état défaillant, force le code a DEF + """ + if self.get_etud_etat(etudid) == DEF: + return { + "code": DEF, + "assidu": False, + "event_date": "", + "compense_formsemestre_id": None, + } + else: + return self.decisions_jury.get(etudid, None) + + def get_etud_decision_ues(self, etudid): + """Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu. + Ne tient pas compte des UE capitalisées. + { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : } + Ne renvoie aucune decision d'UE pour les défaillants + """ + if self.get_etud_etat(etudid) == DEF: + return {} + else: + return self.decisions_jury_ues.get(etudid, None) + + def sem_has_decisions(self): + """True si au moins une decision de jury dans ce semestre""" + if [x for x in self.decisions_jury_ues.values() if x]: + return True + + return len([x for x in self.decisions_jury_ues.values() if x]) > 0 + + 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 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 (todo: à voir ?) + """ + for etudid in self.get_etudids(): + if self.inscrdict[etudid]["etat"] == "D": + continue # skip demissionnaires + if self.get_etud_decision_sem(etudid) is None: + return False + return True + + # Capitalisation des UEs + def comp_ue_capitalisees(self): + """Cherche pour chaque etudiant ses UE capitalisées dans ce semestre. + Calcule l'attribut: + ue_capitalisees = { etudid : + [{ 'moy':, 'event_date' : ,'formsemestre_id' : }, ...] } + """ + self.ue_capitalisees = scu.DictDefault(defaultvalue=[]) + cnx = None + semestre_id = self.sem["semestre_id"] + for etudid in self.get_etudids(): + capital = formsemestre_get_etud_capitalisation( + self.formation["id"], + semestre_id, + ndb.DateDMYtoISO(self.sem["date_debut"]), + etudid, + ) + for ue_cap in capital: + # Si la moyenne d'UE n'avait pas été stockée (anciennes versions de ScoDoc) + # il faut la calculer ici et l'enregistrer + if ue_cap["moy_ue"] is None: + log( + "comp_ue_capitalisees: recomputing UE moy (etudid=%s, ue_id=%s formsemestre_id=%s)" + % (etudid, ue_cap["ue_id"], ue_cap["formsemestre_id"]) + ) + nt_cap = sco_cache.NotesTableCache.get( + ue_cap["formsemestre_id"] + ) # > UE capitalisees par un etud + 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) + and moy_ue_cap >= self.parcours.NOTES_BARRE_VALID_UE + ): + if not cnx: + cnx = ndb.GetDBConnexion() + sco_cursus_dut.do_formsemestre_validate_ue( + cnx, + nt_cap, + ue_cap["formsemestre_id"], + etudid, + ue_cap["ue_id"], + ue_cap["code"], + ) + else: + log( + "*** valid inconsistency: moy_ue_cap=%s (etudid=%s, ue_id=%s formsemestre_id=%s)" + % ( + moy_ue_cap, + etudid, + ue_cap["ue_id"], + ue_cap["formsemestre_id"], + ) + ) + ue_cap["moy"] = ue_cap["moy_ue"] # backward compat (needs refactoring) + self.ue_capitalisees[etudid].append(ue_cap) + if cnx: + cnx.commit() + # log('comp_ue_capitalisees=\n%s' % pprint.pformat(self.ue_capitalisees) ) + + # def comp_etud_sum_coef_modules_ue( etudid, ue_id): + # """Somme des coefficients des modules de l'UE dans lesquels cet étudiant est inscrit + # ou None s'il n'y a aucun module + # """ + # c_list = [ mod['module']['coefficient'] + # for mod in self._modimpls + # if (( mod['module']['ue_id'] == ue_id) + # and self._modmoys[mod['moduleimpl_id']].get(etudid, False) is not False) + # ] + # if not c_list: + # return None + # return sum(c_list) + + def get_etud_ue_cap_coef(self, etudid, ue, ue_cap, cnx=None): + """Calcule le coefficient d'une UE capitalisée, pour cet étudiant, + injectée dans le semestre courant. + + ue : ue du semestre courant + + ue_cap = resultat de formsemestre_get_etud_capitalisation + { 'ue_id' (dans le semestre source), + 'ue_code', 'moy', 'event_date','formsemestre_id' } + """ + # log("get_etud_ue_cap_coef\nformsemestre_id='%s'\netudid='%s'\nue=%s\nue_cap=%s\n" % (self.formsemestre_id, etudid, ue, ue_cap)) + # 1- Coefficient explicitement déclaré dans le semestre courant pour cette UE ? + if ue["ue_id"] not in self._uecoef: + self._uecoef[ue["ue_id"]] = formsemestre_uecoef_list( + cnx, + args={"formsemestre_id": self.formsemestre_id, "ue_id": ue["ue_id"]}, + ) + + if len(self._uecoef[ue["ue_id"]]): + # utilisation du coef manuel + return self._uecoef[ue["ue_id"]][0]["coefficient"] + + # 2- Mode automatique: calcul du coefficient + # Capitalisation depuis un autre semestre ScoDoc ? + coef = None + if ue_cap["formsemestre_id"]: + # Somme des coefs dans l'UE du semestre d'origine (nouveau: 23/01/2016) + coef = comp_etud_sum_coef_modules_ue( + ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"] + ) + if coef != None: + return coef + else: + # Capitalisation UE externe: quel coef appliquer ? + # Si l'étudiant est inscrit dans le semestre courant, + # somme des coefs des modules de l'UE auxquels il est inscrit + c = comp_etud_sum_coef_modules_ue(self.formsemestre_id, etudid, ue["ue_id"]) + if c is not None: # inscrit à au moins un module de cette UE + return c + # arfff: aucun moyen de déterminer le coefficient de façon sûre + log( + "* oups: calcul coef UE impossible\nformsemestre_id='%s'\netudid='%s'\nue=%s\nue_cap=%s" + % (self.formsemestre_id, etudid, ue, ue_cap) + ) + raise ScoValueError( + """

Coefficient de l'UE capitalisée %s impossible à déterminer + pour l'étudiant %s

+

Il faut saisir le coefficient de cette UE avant de continuer

+
+ """ + % ( + ue["acronyme"], + url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + ), + self.get_nom_long(etudid), + url_for( + "notes.formsemestre_edit_uecoefs", + scodoc_dept=g.scodoc_dept, + formsemestre_id=self.formsemestre_id, + err_ue_id=ue["ue_id"], + ), + ) + ) + + return 0.0 # ? + + def get_etud_ue_status(self, etudid, ue_id): + "Etat de cette UE (note, coef, capitalisation, ...)" + return self._etud_moy_ues[etudid][ue_id] + + def etud_has_notes_attente(self, etudid): + """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). + """ + 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": self.formsemestre_id, + "etudid": etudid, + "code_attente": scu.NOTES_ATTENTE, + }, + ) + return len(cursor.fetchall()) > 0 + + def get_evaluations_etats(self): # evaluation_list_in_sem + """[ {...evaluation et son etat...} ]""" + if self._evaluations_etats is None: + self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem( + self.formsemestre_id + ) + + return self._evaluations_etats + + def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]: + """Liste des évaluations de ce module""" + return [ + e + for e in self.get_evaluations_etats() + if e["moduleimpl_id"] == moduleimpl_id + ] + + def apc_recompute_moyennes(self): + """recalcule les moyennes en APC (BUT) + et modifie en place le tableau T. + XXX Raccord provisoire avant refonte de cette classe. + """ + assert self.parcours.APC_SAE + formsemestre = FormSemestre.query.get(self.formsemestre_id) + results = bulletin_but.ResultatsSemestreBUT(formsemestre) + + # Rappel des épisodes précédents: T est une liste de liste + # Colonnes: 0 moy_gen, moy_ue1, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid + ues = self.get_ues_stat_dict() # incluant le(s) UE de sport + for t in self.T: + etudid = t[-1] + if etudid in results.etud_moy_gen: # evite les démissionnaires + t[0] = results.etud_moy_gen[etudid] + for i, ue in enumerate(ues, start=1): + if ue["type"] != UE_SPORT: + # 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: + self.etud_moy_gen_ranks = results.etud_moy_gen_ranks diff --git a/app/scodoc/sco_export_results.py b/app/scodoc/sco_export_results.py index d4e9a378..9b01e259 100644 --- a/app/scodoc/sco_export_results.py +++ b/app/scodoc/sco_export_results.py @@ -39,6 +39,8 @@ from app.models import Formation from app.scodoc import html_sco_header from app.scodoc import sco_bac from app.scodoc import codes_cursus +from app.scodoc import sco_cache +from app.scodoc import sco_formations from app.scodoc import sco_preferences from app.scodoc import sco_pv_dict from app.scodoc import sco_etud @@ -64,7 +66,9 @@ def _build_results_table(start_date=None, end_date=None, types_parcours=[]): semlist = [dpv["formsemestre"] for dpv in dpv_by_sem.values() if dpv] semlist_parcours = [] for sem in semlist: - sem["formation"] = Formation.query.get_or_404(sem["formation_id"]).to_dict() + sem["formation"] = sco_formations.formation_list( + args={"formation_id": sem["formation_id"]} + )[0] sem["parcours"] = codes_cursus.get_cursus_from_code( sem["formation"]["type_parcours"] ) diff --git a/app/scodoc/sco_formations.py b/app/scodoc/sco_formations.py index 29103d94..886a38a7 100644 --- a/app/scodoc/sco_formations.py +++ b/app/scodoc/sco_formations.py @@ -451,24 +451,20 @@ def formation_list_table() -> GenTable: editable = current_user.has_permission(Permission.ScoChangeFormation) # Traduit/ajoute des champs à afficher: - rows = [] - for formation in formations: - acronyme_no_spaces = formation.acronyme.lower().replace(" ", "-") - row = { - "acronyme": formation.acronyme, - "parcours_name": codes_cursus.get_cursus_from_code( - formation.type_parcours - ).NAME, - "titre": formation.titre, - "_titre_target": url_for( - "notes.ue_table", - scodoc_dept=g.scodoc_dept, - formation_id=formation.id, - ), - "_titre_link_class": "stdlink", - "_titre_id": f"""titre-{acronyme_no_spaces}""", - "version": formation.version or 0, - } + for f in formations: + try: + f["parcours_name"] = codes_cursus.get_cursus_from_code( + f["type_parcours"] + ).NAME + except: + f["parcours_name"] = "" + f["_titre_target"] = url_for( + "notes.ue_table", + scodoc_dept=g.scodoc_dept, + formation_id=str(f["formation_id"]), + ) + f["_titre_link_class"] = "stdlink" + f["_titre_id"] = "titre-%s" % f["acronyme"].lower().replace(" ", "-") # Ajoute les semestres associés à chaque formation: row["formsemestres"] = formation.formsemestres.order_by( FormSemestre.date_debut diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py index 3d46b1de..0d9398e3 100644 --- a/app/scodoc/sco_formsemestre.py +++ b/app/scodoc/sco_formsemestre.py @@ -38,7 +38,7 @@ import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu from app import log from app.models import Departement -from app.models import Formation, FormSemestre +from app.models import FormSemestre from app.scodoc import sco_cache, codes_cursus, sco_formations, sco_preferences from app.scodoc.gen_tables import GenTable from app.scodoc.codes_cursus import NO_SEMESTRE_ID @@ -145,8 +145,13 @@ def _formsemestre_enrich(sem): # imports ici pour eviter refs circulaires from app.scodoc import sco_formsemestre_edit - formation: Formation = Formation.query.get_or_404(sem["formation_id"]) - parcours = codes_cursus.get_cursus_from_code(formation.type_parcours) + formations = sco_formations.formation_list( + args={"formation_id": sem["formation_id"]} + ) + if not formations: + raise ScoValueError("pas de formation pour ce semestre !") + F = formations[0] + parcours = codes_cursus.get_cursus_from_code(F["type_parcours"]) # 'S1', 'S2', ... ou '' pour les monosemestres if sem["semestre_id"] != NO_SEMESTRE_ID: sem["sem_id_txt"] = "S%s" % sem["semestre_id"] diff --git a/app/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py index 404105ac..1a1e67a6 100644 --- a/app/scodoc/sco_formsemestre_exterieurs.py +++ b/app/scodoc/sco_formsemestre_exterieurs.py @@ -55,6 +55,7 @@ from app.scodoc import sco_formations from app.scodoc import sco_formsemestre from app.scodoc import sco_formsemestre_inscriptions from app.scodoc import sco_formsemestre_validation +from app.scodoc import sco_etud from app.scodoc.codes_cursus import UE_SPORT diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index 74381193..57e66b4b 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -540,6 +540,58 @@ def formsemestre_page_title(formsemestre_id=None): ) +def fill_formsemestre(sem): + """Add some useful fields to help display formsemestres""" + sem["notes_url"] = scu.NotesURL() + formsemestre_id = sem["formsemestre_id"] + if not sem["etat"]: + sem[ + "locklink" + ] = f"""{scu.icontag("lock_img", border="0", title="Semestre verrouillé")}""" + else: + sem["locklink"] = "" + if sco_preferences.get_preference("bul_display_publication", formsemestre_id): + if sem["bul_hide_xml"]: + eyeicon = scu.icontag("hide_img", border="0", title="Bulletins NON publiés") + else: + eyeicon = scu.icontag("eye_img", border="0", title="Bulletins publiés") + sem[ + "eyelink" + ] = f"""{eyeicon}""" + else: + sem["eyelink"] = "" + F = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] + sem["formation"] = F + parcours = codes_cursus.get_cursus_from_code(F["type_parcours"]) + if sem["semestre_id"] != -1: + sem["num_sem"] = f""", {parcours.SESSION_NAME} {sem["semestre_id"]}""" + else: + sem["num_sem"] = "" # formation sans semestres + if sem["modalite"]: + sem["modalitestr"] = f""" en {sem["modalite"]}""" + else: + sem["modalitestr"] = "" + + sem["etape_apo_str"] = "Code étape Apogée: " + ( + sco_formsemestre.formsemestre_etape_apo_str(sem) or "Pas de code étape" + ) + + inscrits = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id} + ) + sem["nbinscrits"] = len(inscrits) + uresps = [ + sco_users.user_info(responsable_id) for responsable_id in sem["responsables"] + ] + sem["resp"] = ", ".join([u["prenomnom"] for u in uresps]) + sem["nomcomplet"] = ", ".join([u["nomcomplet"] for u in uresps]) + + # Description du semestre sous forme de table exportable def formsemestre_description_table( formsemestre_id: int, with_evals=False, with_parcours=False @@ -552,7 +604,10 @@ def formsemestre_description_table( ).first_or_404() nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) use_ue_coefs = sco_preferences.get_preference("use_ue_coefs", formsemestre_id) - parcours = codes_cursus.get_cursus_from_code(formsemestre.formation.type_parcours) + F = sco_formations.formation_list(args={"formation_id": formsemestre.formation_id})[ + 0 + ] + parcours = codes_cursus.get_cursus_from_code(F["type_parcours"]) # --- Colonnes à proposer: columns_ids = ["UE", "Code", "Module"] if with_parcours: diff --git a/app/scodoc/sco_groups_view.py b/app/scodoc/sco_groups_view.py index bfb75902..c149e90c 100644 --- a/app/scodoc/sco_groups_view.py +++ b/app/scodoc/sco_groups_view.py @@ -795,7 +795,8 @@ def groups_table( ) m["parcours"] = Se.get_cursus_descr() m["code_cursus"], _ = sco_report.get_code_cursus_etud(etud) - rows = [[m.get(k, "") for k in keys] for m in groups_infos.members] + + L = [[m.get(k, "") for k in keys] for m in groups_infos.members] title = "etudiants_%s" % groups_infos.groups_filename xls = sco_excel.excel_simple_table(titles=titles, lines=rows, sheet_name=title) filename = title diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index 1ebf679f..0fa761a6 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -261,8 +261,8 @@ def list_source_sems(sem, delai=None) -> list[dict]: if s["semestre_id"] == codes_cursus.NO_SEMESTRE_ID: continue # - formation: Formation = Formation.query.get_or_404(s["formation_id"]) - parcours = codes_cursus.get_cursus_from_code(formation.type_parcours) + F = sco_formations.formation_list(args={"formation_id": s["formation_id"]})[0] + parcours = codes_cursus.get_cursus_from_code(F["type_parcours"]) if not parcours.ALLOW_SEM_SKIP: if s["semestre_id"] < (sem["semestre_id"] - 1): continue diff --git a/app/scodoc/sco_pv_forms.py b/app/scodoc/sco_pv_forms.py index a596e348..142110df 100644 --- a/app/scodoc/sco_pv_forms.py +++ b/app/scodoc/sco_pv_forms.py @@ -44,7 +44,9 @@ import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app.scodoc import html_sco_header from app.scodoc import codes_cursus -from app.scodoc import sco_pv_dict +from app.scodoc import sco_cursus +from app.scodoc import sco_cursus_dut +from app.scodoc import sco_edit_ue from app.scodoc import sco_etud from app.scodoc import sco_groups from app.scodoc import sco_groups_view @@ -58,6 +60,57 @@ from app.scodoc.sco_pdf import PDFLOCK from app.scodoc.TrivialFormulator import TrivialFormulator +def _descr_decisions_ues(nt, etudid, decisions_ue, decision_sem) -> list[dict]: + """Liste des UE validées dans ce semestre (incluant les UE capitalisées)""" + if not decisions_ue: + return [] + uelist = [] + # Les UE validées dans ce semestre: + for ue_id in decisions_ue.keys(): + try: + if decisions_ue[ue_id] and ( + codes_cursus.code_ue_validant(decisions_ue[ue_id]["code"]) + or ( + (not nt.is_apc) + and ( + # XXX ceci devrait dépendre du parcours et non pas être une option ! #sco8 + decision_sem + and scu.CONFIG.CAPITALIZE_ALL_UES + and codes_cursus.code_semestre_validant(decision_sem["code"]) + ) + ) + ): + ue = sco_edit_ue.ue_list(args={"ue_id": ue_id})[0] + uelist.append(ue) + except: + log( + f"Exception in descr_decisions_ues: ue_id={ue_id} decisions_ue={decisions_ue}" + ) + # Les UE capitalisées dans d'autres semestres: + if etudid in nt.validations.ue_capitalisees.index: + for ue_id in nt.validations.ue_capitalisees.loc[[etudid]]["ue_id"]: + try: + uelist.append(nt.get_etud_ue_status(etudid, ue_id)["ue"]) + except (KeyError, TypeError): + pass + uelist.sort(key=itemgetter("numero")) + + return uelist + + +def _descr_decision_sem(etat, decision_sem): + "résumé textuel de la décision de semestre" + if etat == "D": + decision = "Démission" + else: + if decision_sem: + cod = decision_sem["code"] + decision = codes_cursus.CODES_EXPL.get(cod, "") # + ' (%s)' % cod + else: + decision = "" + return decision + + def _descr_decision_sem_abbrev(etat, decision_sem): "résumé textuel tres court (code) de la décision de semestre" if etat == "D": @@ -70,6 +123,232 @@ def _descr_decision_sem_abbrev(etat, decision_sem): return decision +def descr_autorisations(autorisations: list[ScolarAutorisationInscription]) -> str: + "résumé textuel des autorisations d'inscription (-> 'S1, S3' )" + return ", ".join([f"S{a.semestre_id}" for a in autorisations]) + + +def _comp_ects_by_ue_code(nt, decision_ues): + """Calcul somme des ECTS validés dans ce semestre (sans les UE capitalisées) + decision_ues est le resultat de nt.get_etud_decision_ues + Chaque resultat est un dict: { ue_code : ects } + """ + if not decision_ues: + return {} + + ects_by_ue_code = {} + for ue_id in decision_ues: + d = decision_ues[ue_id] + ue = UniteEns.query.get(ue_id) + ects_by_ue_code[ue.ue_code] = d["ects"] + + return ects_by_ue_code + + +def _comp_ects_capitalises_by_ue_code(nt: NotesTableCompat, etudid: int): + """Calcul somme des ECTS des UE capitalisees""" + ues = nt.get_ues_stat_dict() + ects_by_ue_code = {} + for ue in ues: + ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) + if ue_status and ue_status["is_capitalized"]: + ects_val = float(ue_status["ue"]["ects"] or 0.0) + ects_by_ue_code[ue["ue_code"]] = ects_val + + return ects_by_ue_code + + +def _sum_ects_dicts(s, t): + """Somme deux dictionnaires { ue_code : ects }, + quand une UE de même code apparait deux fois, prend celle avec le plus d'ECTS. + """ + sum_ects = sum(s.values()) + sum(t.values()) + for ue_code in set(s).intersection(set(t)): + sum_ects -= min(s[ue_code], t[ue_code]) + return sum_ects + + +def dict_pvjury( + formsemestre_id, + etudids=None, + with_prev=False, + with_parcours_decisions=False, +): + """Données pour édition jury + etudids == None => tous les inscrits, sinon donne la liste des ids + Si with_prev: ajoute infos sur code jury semestre precedent + Si with_parcours_decisions: ajoute infos sur code decision jury de tous les semestre du parcours + Résultat: + { + 'date' : date de la decision la plus recente, + 'formsemestre' : sem, + 'is_apc' : bool, + 'formation' : { 'acronyme' :, 'titre': ... } + 'decisions' : { [ { 'identite' : {'nom' :, 'prenom':, ...,}, + 'etat' : I ou D ou DEF + 'decision_sem' : {'code':, 'code_prev': }, + 'decisions_ue' : { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' :, + 'acronyme', 'numero': } }, + 'autorisations' : [ { 'semestre_id' : { ... } } ], + 'validation_parcours' : True si parcours validé (diplome obtenu) + 'prev_code' : code (calculé slt si with_prev), + 'mention' : mention (en fct moy gen), + 'sum_ects' : total ECTS acquis dans ce semestre (incluant les UE capitalisées) + 'sum_ects_capitalises' : somme des ECTS des UE capitalisees + } + ] + }, + 'decisions_dict' : { etudid : decision (comme ci-dessus) }, + } + """ + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + if etudids is None: + etudids = nt.get_etudids() + if not etudids: + return {} + cnx = ndb.GetDBConnexion() + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + max_date = "0000-01-01" + has_prev = False # vrai si au moins un etudiant a un code prev + semestre_non_terminal = False # True si au moins un etudiant a un devenir + + decisions = [] + D = {} # même chose que decisions, mais { etudid : dec } + for etudid in etudids: + # etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + etud: Identite = Identite.query.get(etudid) + Se = sco_cursus.get_situation_etud_cursus( + etud.to_dict_scodoc7(), formsemestre_id + ) + semestre_non_terminal = semestre_non_terminal or Se.semestre_non_terminal + d = {} + d["identite"] = nt.identdict[etudid] + d["etat"] = nt.get_etud_etat( + etudid + ) # I|D|DEF (inscription ou démission ou défaillant) + d["decision_sem"] = nt.get_etud_decision_sem(etudid) + d["decisions_ue"] = nt.get_etud_decision_ues(etudid) + if formsemestre.formation.is_apc(): + d.update(but_validations.dict_decision_jury(etud, formsemestre)) + d["last_formsemestre_id"] = Se.get_semestres()[ + -1 + ] # id du dernier semestre (chronologiquement) dans lequel il a été inscrit + + ects_capitalises_by_ue_code = _comp_ects_capitalises_by_ue_code(nt, etudid) + d["sum_ects_capitalises"] = sum(ects_capitalises_by_ue_code.values()) + ects_by_ue_code = _comp_ects_by_ue_code(nt, d["decisions_ue"]) + d["sum_ects"] = _sum_ects_dicts(ects_capitalises_by_ue_code, ects_by_ue_code) + + if d["decision_sem"] and codes_cursus.code_semestre_validant( + d["decision_sem"]["code"] + ): + d["mention"] = scu.get_mention(nt.get_etud_moy_gen(etudid)) + else: + d["mention"] = "" + # Versions "en français": (avec les UE capitalisées d'ailleurs) + dec_ue_list = _descr_decisions_ues( + nt, etudid, d["decisions_ue"], d["decision_sem"] + ) + d["decisions_ue_nb"] = len( + dec_ue_list + ) # avec les UE capitalisées, donc des éventuels doublons + # Mais sur la description (eg sur les bulletins), on ne veut pas + # afficher ces doublons: on uniquifie sur ue_code + _codes = set() + ue_uniq = [] + for ue in dec_ue_list: + if ue["ue_code"] not in _codes: + ue_uniq.append(ue) + _codes.add(ue["ue_code"]) + + d["decisions_ue_descr"] = ", ".join([ue["acronyme"] for ue in ue_uniq]) + if nt.is_apc: + d["decision_sem_descr"] = "" # pas de validation de semestre en BUT + else: + d["decision_sem_descr"] = _descr_decision_sem(d["etat"], d["decision_sem"]) + + autorisations = ScolarAutorisationInscription.query.filter_by( + etudid=etudid, origin_formsemestre_id=formsemestre_id + ).all() + d["autorisations"] = [a.to_dict() for a in autorisations] + d["autorisations_descr"] = descr_autorisations(autorisations) + + d["validation_parcours"] = Se.parcours_validated() + d["parcours"] = Se.get_cursus_descr(filter_futur=True) + if with_parcours_decisions: + d["parcours_decisions"] = Se.get_parcours_decisions() + # Observations sur les compensations: + compensators = sco_cursus_dut.scolar_formsemestre_validation_list( + cnx, args={"compense_formsemestre_id": formsemestre_id, "etudid": etudid} + ) + obs = [] + for compensator in compensators: + # nb: il ne devrait y en avoir qu'un ! + csem = sco_formsemestre.get_formsemestre(compensator["formsemestre_id"]) + obs.append( + "%s compensé par %s (%s)" + % (sem["sem_id_txt"], csem["sem_id_txt"], csem["anneescolaire"]) + ) + + if d["decision_sem"] and d["decision_sem"]["compense_formsemestre_id"]: + compensed = sco_formsemestre.get_formsemestre( + d["decision_sem"]["compense_formsemestre_id"] + ) + obs.append( + f"""{sem["sem_id_txt"]} compense {compensed["sem_id_txt"]} ({compensed["anneescolaire"]})""" + ) + + d["observation"] = ", ".join(obs) + + # Cherche la date de decision (sem ou UE) la plus récente: + if d["decision_sem"]: + date = ndb.DateDMYtoISO(d["decision_sem"]["event_date"]) + if date and date > max_date: # decision plus recente + max_date = date + if d["decisions_ue"]: + for dec_ue in d["decisions_ue"].values(): + if dec_ue: + date = ndb.DateDMYtoISO(dec_ue["event_date"]) + if date and date > max_date: # decision plus recente + max_date = date + # Code semestre precedent + if with_prev: # optionnel car un peu long... + info = sco_etud.get_etud_info(etudid=etudid, filled=True) + if not info: + continue # should not occur + etud = info[0] + if Se.prev and Se.prev_decision: + d["prev_decision_sem"] = Se.prev_decision + d["prev_code"] = Se.prev_decision["code"] + d["prev_code_descr"] = _descr_decision_sem( + scu.INSCRIT, Se.prev_decision + ) + d["prev"] = Se.prev + has_prev = True + else: + d["prev_decision_sem"] = None + d["prev_code"] = "" + d["prev_code_descr"] = "" + d["Se"] = Se + + decisions.append(d) + D[etudid] = d + + return { + "date": ndb.DateISOtoDMY(max_date), + "formsemestre": sem, + "is_apc": nt.is_apc, + "has_prev": has_prev, + "semestre_non_terminal": semestre_non_terminal, + "formation": sco_formations.formation_list( + args={"formation_id": sem["formation_id"]} + )[0], + "decisions": decisions, + "decisions_dict": D, + } + + def pvjury_table( dpv, only_diplome=False, diff --git a/app/scodoc/sco_pvpdf.py b/app/scodoc/sco_pvpdf.py new file mode 100644 index 00000000..ed9cd775 --- /dev/null +++ b/app/scodoc/sco_pvpdf.py @@ -0,0 +1,925 @@ +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2023 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 +# +############################################################################## + +"""Edition des PV de jury +""" +import io +import re + +from PIL import Image as PILImage +from PIL import UnidentifiedImageError + +import reportlab +from reportlab.lib.units import cm, mm +from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_JUSTIFY +from reportlab.platypus import Paragraph, Spacer, Frame, PageBreak +from reportlab.platypus import Table, TableStyle, Image +from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate +from reportlab.lib.pagesizes import A4, landscape +from reportlab.lib import styles +from reportlab.lib.colors import Color + +from flask import g +from app.models import FormSemestre, Identite + +import app.scodoc.sco_utils as scu +from app.scodoc import sco_bulletins_pdf +from app.scodoc import codes_cursus +from app.scodoc import sco_etud +from app.scodoc import sco_pdf +from app.scodoc import sco_preferences +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_logos import find_logo +from app.scodoc.sco_cursus_dut import SituationEtudCursus +from app.scodoc.sco_pdf import SU +import sco_version + +LOGO_FOOTER_ASPECT = scu.CONFIG.LOGO_FOOTER_ASPECT # XXX A AUTOMATISER +LOGO_FOOTER_HEIGHT = scu.CONFIG.LOGO_FOOTER_HEIGHT * mm +LOGO_FOOTER_WIDTH = LOGO_FOOTER_HEIGHT * scu.CONFIG.LOGO_FOOTER_ASPECT + +LOGO_HEADER_ASPECT = scu.CONFIG.LOGO_HEADER_ASPECT # XXX logo IUTV (A AUTOMATISER) +LOGO_HEADER_HEIGHT = scu.CONFIG.LOGO_HEADER_HEIGHT * mm +LOGO_HEADER_WIDTH = LOGO_HEADER_HEIGHT * scu.CONFIG.LOGO_HEADER_ASPECT + + +def page_footer(canvas, doc, logo, preferences, with_page_numbers=True): + "Add footer on page" + width = doc.pagesize[0] # - doc.pageTemplate.left_p - doc.pageTemplate.right_p + foot = Frame( + 0.1 * mm, + 0.2 * cm, + width - 1 * mm, + 2 * cm, + leftPadding=0, + rightPadding=0, + topPadding=0, + bottomPadding=0, + id="monfooter", + showBoundary=0, + ) + + left_foot_style = reportlab.lib.styles.ParagraphStyle({}) + left_foot_style.fontName = preferences["SCOLAR_FONT"] + left_foot_style.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"] + left_foot_style.leftIndent = 0 + left_foot_style.firstLineIndent = 0 + left_foot_style.alignment = TA_RIGHT + right_foot_style = reportlab.lib.styles.ParagraphStyle({}) + right_foot_style.fontName = preferences["SCOLAR_FONT"] + right_foot_style.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"] + right_foot_style.alignment = TA_RIGHT + + p = sco_pdf.make_paras( + f"""{preferences["INSTITUTION_NAME"]}{ + preferences["INSTITUTION_ADDRESS"]}""", + left_foot_style, + ) + + np = Paragraph(f'{doc.page}', right_foot_style) + tabstyle = TableStyle( + [ + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("ALIGN", (0, 0), (-1, -1), "RIGHT"), + # ('INNERGRID', (0,0), (-1,-1), 0.25, black),#debug + # ('LINEABOVE', (0,0), (-1,0), 0.5, black), + ("VALIGN", (1, 0), (1, 0), "MIDDLE"), + ("RIGHTPADDING", (-1, 0), (-1, 0), 1 * cm), + ] + ) + elems = [p] + if logo: + elems.append(logo) + colWidths = [None, LOGO_FOOTER_WIDTH + 2 * mm] + if with_page_numbers: + elems.append(np) + colWidths.append(2 * cm) + else: + elems.append("") + colWidths.append(8 * mm) # force marge droite + tab = Table([elems], style=tabstyle, colWidths=colWidths) + canvas.saveState() # is it necessary ? + foot.addFromList([tab], canvas) + canvas.restoreState() + + +def page_header(canvas, doc, logo, preferences, only_on_first_page=False): + "Ajoute au canvas le frame avec le logo" + if only_on_first_page and int(doc.page) > 1: + return + height = doc.pagesize[1] + head = Frame( + -22 * mm, + height - 13 * mm - LOGO_HEADER_HEIGHT, + 10 * cm, + LOGO_HEADER_HEIGHT + 2 * mm, + leftPadding=0, + rightPadding=0, + topPadding=0, + bottomPadding=0, + id="monheader", + showBoundary=0, + ) + if logo: + canvas.saveState() # is it necessary ? + head.addFromList([logo], canvas) + canvas.restoreState() + + +class CourrierIndividuelTemplate(PageTemplate): + """Template pour courrier avisant des decisions de jury (1 page par étudiant)""" + + def __init__( + self, + document, + pagesbookmarks=None, + author=None, + title=None, + subject=None, + margins=(0, 0, 0, 0), # additional margins in mm (left,top,right, bottom) + preferences=None, # dictionnary with preferences, required + force_header=False, + force_footer=False, # always add a footer (whatever the preferences, use for PV) + template_name="CourrierJuryTemplate", + ): + """Initialise our page template.""" + self.pagesbookmarks = pagesbookmarks or {} + self.pdfmeta_author = author + self.pdfmeta_title = title + self.pdfmeta_subject = subject + self.preferences = preferences + self.force_header = force_header + self.force_footer = force_footer + self.with_footer = ( + self.force_footer or self.preferences["PV_LETTER_WITH_HEADER"] + ) + self.with_header = ( + self.force_header or self.preferences["PV_LETTER_WITH_FOOTER"] + ) + self.with_page_background = self.preferences["PV_LETTER_WITH_BACKGROUND"] + self.with_page_numbers = False + self.header_only_on_first_page = False + # Our doc is made of a single frame + left, top, right, bottom = margins # marge additionnelle en mm + # marges du Frame principal + self.bot_p = 2 * cm + self.left_p = 2.5 * cm + self.right_p = 2.5 * cm + self.top_p = 0 * cm + # log("margins=%s" % str(margins)) + content = Frame( + self.left_p + left * mm, + self.bot_p + bottom * mm, + document.pagesize[0] - self.right_p - self.left_p - left * mm - right * mm, + document.pagesize[1] - self.top_p - self.bot_p - top * mm - bottom * mm, + ) + + PageTemplate.__init__(self, template_name, [content]) + + self.background_image_filename = None + self.logo_footer = None + self.logo_header = None + # Search logos in dept specific dir, then in global scu.CONFIG dir + if template_name == "PVJuryTemplate": + background = find_logo( + logoname="pvjury_background", + dept_id=g.scodoc_dept_id, + ) or find_logo( + logoname="pvjury_background", + dept_id=g.scodoc_dept_id, + prefix="", + ) + else: + background = find_logo( + logoname="letter_background", + dept_id=g.scodoc_dept_id, + ) or find_logo( + logoname="letter_background", + dept_id=g.scodoc_dept_id, + prefix="", + ) + if not self.background_image_filename and background is not None: + self.background_image_filename = background.filepath + + footer = find_logo(logoname="footer", dept_id=g.scodoc_dept_id) + if footer is not None: + self.logo_footer = Image( + footer.filepath, + height=LOGO_FOOTER_HEIGHT, + width=LOGO_FOOTER_WIDTH, + ) + + header = find_logo(logoname="header", dept_id=g.scodoc_dept_id) + if header is not None: + self.logo_header = Image( + header.filepath, + height=LOGO_HEADER_HEIGHT, + width=LOGO_HEADER_WIDTH, + ) + + def beforeDrawPage(self, canv, doc): + """Draws a logo and an contribution message on each page.""" + # ---- Add some meta data and bookmarks + if self.pdfmeta_author: + canv.setAuthor(SU(self.pdfmeta_author)) + if self.pdfmeta_title: + canv.setTitle(SU(self.pdfmeta_title)) + if self.pdfmeta_subject: + canv.setSubject(SU(self.pdfmeta_subject)) + bm = self.pagesbookmarks.get(doc.page, None) + if bm != None: + key = bm + txt = SU(bm) + canv.bookmarkPage(key) + canv.addOutlineEntry(txt, bm) + + # ---- Background image + if self.background_image_filename and self.with_page_background: + canv.drawImage( + self.background_image_filename, 0, 0, doc.pagesize[0], doc.pagesize[1] + ) + + # ---- Header/Footer + if self.with_header: + page_header( + canv, + doc, + self.logo_header, + self.preferences, + self.header_only_on_first_page, + ) + if self.with_footer: + page_footer( + canv, + doc, + self.logo_footer, + self.preferences, + with_page_numbers=self.with_page_numbers, + ) + + +class PVTemplate(CourrierIndividuelTemplate): + """Template pour les pages des PV de jury""" + + def __init__( + self, + document, + author=None, + title=None, + subject=None, + margins=None, # additional margins in mm (left,top,right, bottom) + preferences=None, # dictionnary with preferences, required + ): + if margins is None: + margins = ( + preferences["pv_left_margin"], + preferences["pv_top_margin"], + preferences["pv_right_margin"], + preferences["pv_bottom_margin"], + ) + CourrierIndividuelTemplate.__init__( + self, + document, + author=author, + title=title, + subject=subject, + margins=margins, + preferences=preferences, + force_header=True, + force_footer=True, + template_name="PVJuryTemplate", + ) + self.with_page_numbers = True + self.header_only_on_first_page = True + self.with_header = self.preferences["PV_WITH_HEADER"] + self.with_footer = self.preferences["PV_WITH_FOOTER"] + self.with_page_background = self.preferences["PV_WITH_BACKGROUND"] + + def afterDrawPage(self, canv, doc): + """Called after all flowables have been drawn on a page""" + pass + + def beforeDrawPage(self, canv, doc): + """Called before any flowables are drawn on a page""" + # If the page number is even, force a page break + CourrierIndividuelTemplate.beforeDrawPage(self, canv, doc) + # Note: on cherche un moyen de generer un saut de page double + # (redémarrer sur page impaire, nouvelle feuille en recto/verso). Pas trouvé en Platypus. + # + # if self.__pageNum % 2 == 0: + # canvas.showPage() + # # Increment pageNum again since we've added a blank page + # self.__pageNum += 1 + + +def _simulate_br(paragraph_txt: str, para="") -> str: + """Reportlab bug turnaround (could be removed in a future version). + p is a string with Reportlab intra-paragraph XML tags. + Replaces
(currently ignored by Reportlab) by
+ Also replaces
by
+ """ + return ("
" + para).join( + re.split(r"<.*?br.*?/>", paragraph_txt.replace("
", "
")) + ) + + +def _make_signature_image(signature, leftindent, formsemestre_id) -> Table: + "crée un paragraphe avec l'image signature" + # cree une image PIL pour avoir la taille (W,H) + + f = io.BytesIO(signature) + img = PILImage.open(f) + width, height = img.size + pdfheight = ( + 1.0 + * sco_preferences.get_preference("pv_sig_image_height", formsemestre_id) + * mm + ) + f.seek(0, 0) + + style = styles.ParagraphStyle({}) + style.leading = 1.0 * sco_preferences.get_preference( + "SCOLAR_FONT_SIZE", formsemestre_id + ) # vertical space + style.leftIndent = leftindent + return Table( + [("", Image(f, width=width * pdfheight / float(height), height=pdfheight))], + colWidths=(9 * cm, 7 * cm), + ) + + +def pdf_lettres_individuelles( + formsemestre_id, + etudids=None, + date_jury="", + date_commission="", + signature=None, +): + """Document PDF avec les lettres d'avis pour les etudiants mentionnés + (tous ceux du semestre, ou la liste indiquée par etudids) + Renvoie pdf data ou chaine vide si aucun etudiant avec décision de jury. + """ + from app.scodoc import sco_pvjury + + dpv = sco_pvjury.dict_pvjury(formsemestre_id, etudids=etudids, with_prev=True) + if not dpv: + return "" + # Ajoute infos sur etudiants + etuds = [x["identite"] for x in dpv["decisions"]] + sco_etud.fill_etuds_info(etuds) + # + formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) + prefs = sco_preferences.SemPreferences(formsemestre_id) + params = { + "date_jury": date_jury, + "date_commission": date_commission, + "titre_formation": dpv["formation"]["titre_officiel"], + "htab1": "8cm", # lignes à droite (entete, signature) + "htab2": "1cm", + } + # copie preferences + for name in sco_preferences.get_base_preferences().prefs_name: + params[name] = sco_preferences.get_preference(name, formsemestre_id) + + bookmarks = {} + objects = [] # list of PLATYPUS objects + npages = 0 + for decision in dpv["decisions"]: + if ( + decision["decision_sem"] + or decision.get("decision_annee") + or decision.get("decision_rcue") + ): # decision prise + etud: Identite = Identite.query.get(decision["identite"]["etudid"]) + params["nomEtud"] = etud.nomprenom + bookmarks[npages + 1] = scu.suppress_accents(etud.nomprenom) + try: + objects += pdf_lettre_individuelle( + dpv["formsemestre"], decision, etud, params, signature + ) + except UnidentifiedImageError as exc: + raise ScoValueError( + "Fichier image (signature ou logo ?) invalide !" + ) from exc + objects.append(PageBreak()) + npages += 1 + if npages == 0: + return "" + # Paramètres de mise en page + margins = ( + prefs["left_margin"], + prefs["top_margin"], + prefs["right_margin"], + prefs["bottom_margin"], + ) + + # ----- Build PDF + report = io.BytesIO() # in-memory document, no disk file + document = BaseDocTemplate(report) + document.addPageTemplates( + CourrierIndividuelTemplate( + document, + author=f"{sco_version.SCONAME} {sco_version.SCOVERSION} (E. Viennet)", + title=f"Lettres décision {formsemestre.titre_annee()}", + subject="Décision jury", + margins=margins, + pagesbookmarks=bookmarks, + preferences=prefs, + ) + ) + + document.build(objects) + data = report.getvalue() + return data + + +def _descr_jury(formsemestre: FormSemestre, diplome): + + if not diplome: + if formsemestre.formation.is_apc(): + t = f"""BUT{(formsemestre.semestre_id+1)//2}""" + s = t + else: + t = f"""passage de Semestre {formsemestre.semestre_id} en Semestre {formsemestre.semestre_id + 1}""" + s = "passage de semestre" + else: + t = "délivrance du diplôme" + s = t + return t, s # titre long, titre court + + +def pdf_lettre_individuelle(sem, decision, etud: Identite, params, signature=None): + """ + Renvoie une liste d'objets PLATYPUS pour intégration + dans un autre document. + """ + # + formsemestre_id = sem["formsemestre_id"] + formsemestre = FormSemestre.query.get(formsemestre_id) + Se: SituationEtudCursus = decision["Se"] + t, s = _descr_jury( + formsemestre, Se.parcours_validated() or not Se.semestre_non_terminal + ) + objects = [] + style = reportlab.lib.styles.ParagraphStyle({}) + style.fontSize = 14 + style.fontName = sco_preferences.get_preference("PV_FONTNAME", formsemestre_id) + style.leading = 18 + style.alignment = TA_LEFT + + params["semestre_id"] = formsemestre.semestre_id + params["decision_sem_descr"] = decision["decision_sem_descr"] + params["type_jury"] = t # type de jury (passage ou delivrance) + params["type_jury_abbrv"] = s # idem, abbrégé + params["decisions_ue_descr"] = decision["decisions_ue_descr"] + if decision["decisions_ue_nb"] > 1: + params["decisions_ue_descr_plural"] = "s" + else: + params["decisions_ue_descr_plural"] = "" + + params["INSTITUTION_CITY"] = ( + sco_preferences.get_preference("INSTITUTION_CITY", formsemestre_id) or "" + ) + + if decision["prev_decision_sem"]: + params["prev_semestre_id"] = decision["prev"]["semestre_id"] + + params["prev_decision_sem_txt"] = "" + params["decision_orig"] = "" + + params.update(decision["identite"]) + # fix domicile + if params["domicile"]: + params["domicile"] = params["domicile"].replace("\\n", "
") + + # UE capitalisées: + if decision["decisions_ue"] and decision["decisions_ue_descr"]: + params["decision_ue_txt"] = ( + """Unité%(decisions_ue_descr_plural)s d'Enseignement %(decision_orig)s capitalisée%(decisions_ue_descr_plural)s : %(decisions_ue_descr)s""" + % params + ) + else: + params["decision_ue_txt"] = "" + # Mention + params["mention"] = decision["mention"] + # Informations sur compensations + if decision["observation"]: + params["observation_txt"] = ( + """Observation : %(observation)s.""" % decision + ) + else: + params["observation_txt"] = "" + # Autorisations de passage + if decision["autorisations"] and not Se.parcours_validated(): + if len(decision["autorisations"]) > 1: + s = "s" + else: + s = "" + params[ + "autorisations_txt" + ] = """Vous êtes autorisé%s à continuer dans le%s semestre%s : %s""" % ( + etud.e, + s, + s, + decision["autorisations_descr"], + ) + else: + params["autorisations_txt"] = "" + + if decision["decision_sem"] and Se.parcours_validated(): + params["diplome_txt"] = ( + """Vous avez donc obtenu le diplôme : %(titre_formation)s""" % params + ) + else: + params["diplome_txt"] = "" + + # Les fonctions ci-dessous ajoutent ou modifient des champs: + if formsemestre.formation.is_apc(): + # ajout champs spécifiques PV BUT + add_apc_infos(formsemestre, params, decision) + else: + # ajout champs spécifiques PV DUT + add_classic_infos(formsemestre, params, decision) + + # Corps de la lettre: + objects += sco_bulletins_pdf.process_field( + sco_preferences.get_preference("PV_LETTER_TEMPLATE", sem["formsemestre_id"]), + params, + style, + suppress_empty_pars=True, + ) + + # Signature: + # nota: si semestre terminal, signature par directeur IUT, sinon, signature par + # chef de département. + if Se.semestre_non_terminal: + sig = ( + sco_preferences.get_preference( + "PV_LETTER_PASSAGE_SIGNATURE", formsemestre_id + ) + or "" + ) % params + sig = _simulate_br(sig, '') + objects += sco_pdf.make_paras( + ( + """""" + + sig + + """""" + ) + % params, + style, + ) + else: + sig = ( + sco_preferences.get_preference( + "PV_LETTER_DIPLOMA_SIGNATURE", formsemestre_id + ) + or "" + ) % params + sig = _simulate_br(sig, '') + objects += sco_pdf.make_paras( + ( + """""" + + sig + + """""" + ) + % params, + style, + ) + + if signature: + try: + objects.append( + _make_signature_image(signature, params["htab1"], formsemestre_id) + ) + except UnidentifiedImageError as exc: + raise ScoValueError("Image signature invalide !") from exc + + return objects + + +def add_classic_infos(formsemestre: FormSemestre, params: dict, decision: dict): + """Ajoute les champs pour les formations classiques, donc avec codes semestres""" + if decision["prev_decision_sem"]: + params["prev_code_descr"] = decision["prev_code_descr"] + params[ + "prev_decision_sem_txt" + ] = f"""Décision du semestre antérieur S{params['prev_semestre_id']} : {params['prev_code_descr']}""" + # Décision semestre courant: + if formsemestre.semestre_id >= 0: + params["decision_orig"] = f"du semestre S{formsemestre.semestre_id}" + else: + params["decision_orig"] = "" + + +def add_apc_infos(formsemestre: FormSemestre, params: dict, decision: dict): + """Ajoute les champs pour les formations APC (BUT), donc avec codes RCUE et année""" + annee_but = (formsemestre.semestre_id + 1) // 2 + params["decision_orig"] = f"année BUT{annee_but}" + if decision is None: + params["decision_sem_descr"] = "" + params["decision_ue_txt"] = "" + else: + decision_annee = decision.get("decision_annee") or {} + params["decision_sem_descr"] = decision_annee.get("code") or "" + params[ + "decision_ue_txt" + ] = f"""{params["decision_ue_txt"]}
+ Niveaux de compétences:
{decision.get("descr_decisions_rcue") or ""} + """ + + +# ---------------------------------------------- +def pvjury_pdf( + dpv, + date_commission=None, + date_jury=None, + numeroArrete=None, + VDICode=None, + showTitle=False, + pv_title=None, + with_paragraph_nom=False, + anonymous=False, +): + """Doc PDF récapitulant les décisions de jury + (tableau en format paysage) + dpv: result of dict_pvjury + """ + if not dpv: + return {} + sem = dpv["formsemestre"] + formsemestre_id = sem["formsemestre_id"] + + objects = _pvjury_pdf_type( + dpv, + only_diplome=False, + date_commission=date_commission, + numeroArrete=numeroArrete, + VDICode=VDICode, + date_jury=date_jury, + showTitle=showTitle, + pv_title=pv_title, + with_paragraph_nom=with_paragraph_nom, + anonymous=anonymous, + ) + + jury_de_diplome = not dpv["semestre_non_terminal"] + + # Si Jury de passage et qu'un étudiant valide le parcours (car il a validé antérieurement le dernier semestre) + # alors on génère aussi un PV de diplome (à la suite dans le même doc PDF) + if not jury_de_diplome: + validations_parcours = [x["validation_parcours"] for x in dpv["decisions"]] + if True in validations_parcours: + # au moins un etudiant a validé son diplome: + objects.append(PageBreak()) + objects += _pvjury_pdf_type( + dpv, + only_diplome=True, + date_commission=date_commission, + date_jury=date_jury, + numeroArrete=numeroArrete, + VDICode=VDICode, + showTitle=showTitle, + pv_title=pv_title, + with_paragraph_nom=with_paragraph_nom, + anonymous=anonymous, + ) + + # ----- Build PDF + report = io.BytesIO() # in-memory document, no disk file + document = BaseDocTemplate(report) + document.pagesize = landscape(A4) + document.addPageTemplates( + PVTemplate( + document, + author="%s %s (E. Viennet)" % (sco_version.SCONAME, sco_version.SCOVERSION), + title=SU("PV du jury de %s" % sem["titre_num"]), + subject="PV jury", + preferences=sco_preferences.SemPreferences(formsemestre_id), + ) + ) + + document.build(objects) + data = report.getvalue() + return data + + +def _pvjury_pdf_type( + dpv, + only_diplome=False, + date_commission=None, + date_jury=None, + numeroArrete=None, + VDICode=None, + showTitle=False, + pv_title=None, + anonymous=False, + with_paragraph_nom=False, +): + """Doc PDF récapitulant les décisions de jury pour un type de jury (passage ou delivrance) + dpv: result of dict_pvjury + """ + from app.scodoc import sco_pvjury + + # Jury de diplome si sem. terminal OU que l'on demande les diplomés d'un semestre antérieur + diplome = (not dpv["semestre_non_terminal"]) or only_diplome + + sem = dpv["formsemestre"] + formsemestre_id = sem["formsemestre_id"] + formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) + titre_jury, _ = _descr_jury(formsemestre, diplome) + titre_diplome = pv_title or dpv["formation"]["titre_officiel"] + objects = [] + + style = reportlab.lib.styles.ParagraphStyle({}) + style.fontSize = 12 + style.fontName = sco_preferences.get_preference("PV_FONTNAME", formsemestre_id) + style.leading = 18 + style.alignment = TA_JUSTIFY + + indent = 1 * cm + bulletStyle = reportlab.lib.styles.ParagraphStyle({}) + bulletStyle.fontSize = 12 + bulletStyle.fontName = sco_preferences.get_preference( + "PV_FONTNAME", formsemestre_id + ) + bulletStyle.leading = 12 + bulletStyle.alignment = TA_JUSTIFY + bulletStyle.firstLineIndent = 0 + bulletStyle.leftIndent = indent + bulletStyle.bulletIndent = indent + bulletStyle.bulletFontName = "Times-Roman" + bulletStyle.bulletFontSize = 11 + bulletStyle.spaceBefore = 5 * mm + bulletStyle.spaceAfter = 5 * mm + + objects += [Spacer(0, 5 * mm)] + objects += sco_pdf.make_paras( + """ + Procès-verbal de %s du département %s - Session unique %s + """ + % ( + titre_jury, + sco_preferences.get_preference("DeptName", formsemestre_id) or "(sans nom)", + sem["anneescolaire"], + ), + style, + ) + + objects += sco_pdf.make_paras( + """ + %s + """ + % titre_diplome, + style, + ) + + if showTitle: + objects += sco_pdf.make_paras( + """Semestre: %s""" % sem["titre"], style + ) + if sco_preferences.get_preference("PV_TITLE_WITH_VDI", formsemestre_id): + objects += sco_pdf.make_paras( + """VDI et Code: %s""" % (VDICode or ""), style + ) + + if date_jury: + objects += sco_pdf.make_paras( + """Jury tenu le %s""" % date_jury, style + ) + + objects += sco_pdf.make_paras( + "" + + (sco_preferences.get_preference("PV_INTRO", formsemestre_id) or "") + % { + "Decnum": numeroArrete, + "VDICode": VDICode, + "UnivName": sco_preferences.get_preference("UnivName", formsemestre_id), + "Type": titre_jury, + "Date": date_commission, # deprecated + "date_commission": date_commission, + } + + "", + bulletStyle, + ) + + objects += sco_pdf.make_paras( + """Le jury propose les décisions suivantes :""", style + ) + objects += [Spacer(0, 4 * mm)] + lines, titles, columns_ids = sco_pvjury.pvjury_table( + dpv, + only_diplome=only_diplome, + anonymous=anonymous, + with_paragraph_nom=with_paragraph_nom, + ) + # convert to lists of tuples: + columns_ids = ["etudid"] + columns_ids + lines = [[line.get(x, "") for x in columns_ids] for line in lines] + titles = [titles.get(x, "") for x in columns_ids] + # Make a new cell style and put all cells in paragraphs + cell_style = styles.ParagraphStyle({}) + cell_style.fontSize = sco_preferences.get_preference( + "SCOLAR_FONT_SIZE", formsemestre_id + ) + cell_style.fontName = sco_preferences.get_preference("PV_FONTNAME", formsemestre_id) + cell_style.leading = 1.0 * sco_preferences.get_preference( + "SCOLAR_FONT_SIZE", formsemestre_id + ) # vertical space + LINEWIDTH = 0.5 + table_style = [ + ( + "FONTNAME", + (0, 0), + (-1, 0), + sco_preferences.get_preference("PV_FONTNAME", formsemestre_id), + ), + ("LINEBELOW", (0, 0), (-1, 0), LINEWIDTH, Color(0, 0, 0)), + ("GRID", (0, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ] + titles = ["%s" % x for x in titles] + + def _format_pv_cell(x): + """convert string to paragraph""" + if isinstance(x, str): + return Paragraph(SU(x), cell_style) + else: + return x + + Pt = [[_format_pv_cell(x) for x in line[1:]] for line in ([titles] + lines)] + widths = [6 * cm, 2.8 * cm, 2.8 * cm, None, None, None, None] + if dpv["has_prev"]: + widths[2:2] = [2.8 * cm] + if sco_preferences.get_preference("bul_show_mention", formsemestre_id): + widths += [None] + objects.append(Table(Pt, repeatRows=1, colWidths=widths, style=table_style)) + + # Signature du directeur + objects += sco_pdf.make_paras( + """ + %s, %s""" + % ( + sco_preferences.get_preference("DirectorName", formsemestre_id) or "", + sco_preferences.get_preference("DirectorTitle", formsemestre_id) or "", + ), + style, + ) + + # Légende des codes + codes = list(codes_cursus.CODES_EXPL.keys()) + codes.sort() + objects += sco_pdf.make_paras( + """ + Codes utilisés :""", + style, + ) + L = [] + for code in codes: + L.append((code, codes_cursus.CODES_EXPL[code])) + TableStyle2 = [ + ( + "FONTNAME", + (0, 0), + (-1, 0), + sco_preferences.get_preference("PV_FONTNAME", formsemestre_id), + ), + ("LINEBELOW", (0, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)), + ("LINEABOVE", (0, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)), + ("LINEBEFORE", (0, 0), (0, -1), LINEWIDTH, Color(0, 0, 0)), + ("LINEAFTER", (-1, 0), (-1, -1), LINEWIDTH, Color(0, 0, 0)), + ] + objects.append( + Table( + [[Paragraph(SU(x), cell_style) for x in line] for line in L], + colWidths=(2 * cm, None), + style=TableStyle2, + ) + ) + + return objects diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index 3d8bb3c7..62a3576e 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -1573,8 +1573,7 @@ def formsemestre_graph_cursus( allkeys=False, # unused ): """Graphe suivi cohortes""" - annee_bac = str(annee_bac or "") - annee_admission = str(annee_admission or "") + annee_bac = str(annee_bac) # log("formsemestre_graph_cursus") sem = sco_formsemestre.get_formsemestre(formsemestre_id) if format == "pdf": diff --git a/app/tables/recap.py b/app/tables/recap.py index 0b9be73a..11e94dd0 100644 --- a/app/tables/recap.py +++ b/app/tables/recap.py @@ -63,10 +63,10 @@ class TableRecap(tb.Table): self.include_evaluations = include_evaluations self.mode_jury = mode_jury self.read_only = read_only # utilisé seulement dans sous-classes - parcours = res.formsemestre.formation.get_parcours() - self.barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE - self.barre_valid_ue = parcours.NOTES_BARRE_VALID_UE - self.barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING + cursus = res.formsemestre.formation.get_cursus() + self.barre_moy = cursus.BARRE_MOY - scu.NOTES_TOLERANCE + self.barre_valid_ue = cursus.NOTES_BARRE_VALID_UE + self.barre_warning_ue = cursus.BARRE_UE_DISPLAY_WARNING self.cache_nomcomplet = {} # cache uid : nomcomplet if convert_values: self.fmt_note = scu.fmt_note diff --git a/app/templates/formsemestre_page_title.j2 b/app/templates/formsemestre_page_title.j2 index 95774dae..14c7f6ea 100644 --- a/app/templates/formsemestre_page_title.j2 +++ b/app/templates/formsemestre_page_title.j2 @@ -21,7 +21,7 @@ scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id) }}">{{formsemestre.etuds_inscriptions|length}} inscrits {%-if not formsemestre.etat -%} - {{ scu.icontag("lock_img", border="0", title="Semestre verrouillé")|safe }} diff --git a/app/templates/pn/form_mods.j2 b/app/templates/pn/form_mods.j2 index 21677f40..f88140f1 100644 --- a/app/templates/pn/form_mods.j2 +++ b/app/templates/pn/form_mods.j2 @@ -49,7 +49,7 @@ ({{mod.ue.acronyme}}), {% endif %} - - parcours {{ mod.get_parcours()|map(attribute="code")|join(", ")|default('tronc commun', + - parcours {{ mod.get_cursus()|map(attribute="code")|join(", ")|default('tronc commun', true)|safe }} {% if mod.heures_cours or mod.heures_td or mod.heures_tp %} diff --git a/tests/unit/sco_fake_gen.py b/tests/unit/sco_fake_gen.py index 48712483..b7714368 100644 --- a/tests/unit/sco_fake_gen.py +++ b/tests/unit/sco_fake_gen.py @@ -19,6 +19,7 @@ from app.auth.models import User from app.models import Departement, Formation, FormationModalite, Matiere from app.scodoc import notesdb as ndb from app.scodoc import codes_cursus +from app.scodoc import sco_edit_formation from app.scodoc import sco_edit_matiere from app.scodoc import sco_edit_module from app.scodoc import sco_edit_ue @@ -153,7 +154,7 @@ class ScoFake(object): acronyme="test", titre="Formation test", titre_officiel="Le titre officiel de la formation test", - type_parcours: int = codes_cursus.CursusDUT.TYPE_CURSUS, + type_parcours=codes_cursus.CursusDUT.TYPE_CURSUS, formation_code=None, code_specialite=None, ) -> int: