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(
+ """
+ """
+ % (
+ 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: