From a81b86efffe5618a6de12f22c84c319f2b3fb2e9 Mon Sep 17 00:00:00 2001 From: Emmanuel Viennet Date: Fri, 30 Sep 2022 22:43:39 +0200 Subject: [PATCH] =?UTF-8?q?Modernisation=20code=20d=C3=A9mission/d=C3=A9fa?= =?UTF-8?q?illance...?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/comp/res_common.py | 2030 ++--- app/models/validations.py | 334 +- app/pe/pe_semestretag.py | 1015 +-- app/scodoc/notes_table.py | 2712 +++--- app/scodoc/sco_bulletins.py | 2548 +++--- app/scodoc/sco_cursus_dut.py | 2028 ++--- app/scodoc/sco_etud.py | 2106 ++--- app/scodoc/sco_formsemestre_exterieurs.py | 2 +- app/scodoc/sco_formsemestre_inscriptions.py | 1793 ++-- app/scodoc/sco_formsemestre_validation.py | 2652 +++--- app/scodoc/sco_groups.py | 4 +- app/scodoc/sco_import_etuds.py | 1666 ++-- app/scodoc/sco_inscr_passage.py | 1340 +-- app/scodoc/sco_liste_notes.py | 1842 ++--- app/scodoc/sco_page_etud.py | 1262 +-- app/scodoc/sco_poursuite_dut.py | 470 +- app/scodoc/sco_pvjury.py | 1870 ++--- app/scodoc/sco_report.py | 3248 ++++---- app/scodoc/sco_synchro_etuds.py | 1770 ++-- app/scodoc/sco_trombino_tours.py | 958 +-- app/static/css/scodoc.css | 8186 ++++++++++--------- app/views/scolar.py | 4608 ++++++----- 22 files changed, 22234 insertions(+), 22210 deletions(-) diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 6a4ddf5a..2298842f 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -1,1015 +1,1015 @@ -############################################################################## -# ScoDoc -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# See LICENSE -############################################################################## - -"""Résultats semestre: méthodes communes aux formations classiques et APC -""" - -from collections import Counter -from collections.abc import Generator -from functools import cached_property -import numpy as np -import pandas as pd - -from flask import g, url_for - -from app.auth.models import User -from app.comp import res_sem -from app.comp.res_cache import ResultatsCache -from app.comp.jury import ValidationsSemestre -from app.comp.moy_mod import ModuleImplResults -from app.models import FormSemestre, FormSemestreUECoef -from app.models import Identite -from app.models import ModuleImpl, ModuleImplInscription -from app.models.ues import UniteEns -from app.scodoc.sco_cache import ResultatsSemestreCache -from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM -from app.scodoc import sco_evaluation_db -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc import sco_groups -from app.scodoc import sco_utils as scu - -# Il faut bien distinguer -# - ce qui est caché de façon persistente (via redis): -# ce sont les attributs listés dans `_cached_attrs` -# le stockage et l'invalidation sont gérés dans sco_cache.py -# -# - les valeurs cachées durant le temps d'une requête -# (durée de vie de l'instance de ResultatsSemestre) -# qui sont notamment les attributs décorés par `@cached_property`` -# -class ResultatsSemestre(ResultatsCache): - """Les résultats (notes, ...) d'un formsemestre - Classe commune à toutes les formations (classiques, BUT) - """ - - _cached_attrs = ( - "bonus", - "bonus_ues", - "etud_moy_gen_ranks", - "etud_moy_gen", - "etud_moy_ue", - "modimpl_inscr_df", - "modimpls_results", - "etud_coef_ue_df", - "moyennes_matieres", - ) - - def __init__(self, formsemestre: FormSemestre): - super().__init__(formsemestre, ResultatsSemestreCache) - # BUT ou standard ? (apc == "approche par compétences") - self.is_apc = formsemestre.formation.is_apc() - # Attributs "virtuels", définis dans les sous-classes - self.bonus: pd.Series = None # virtuel - "Bonus sur moy. gen. Series de float, index etudid" - self.bonus_ues: pd.DataFrame = None # virtuel - "DataFrame de float, index etudid, columns: ue.id" - # ResultatsSemestreBUT ou ResultatsSemestreClassic - self.etud_moy_ue = {} - "etud_moy_ue: DataFrame columns UE, rows etudid" - self.etud_moy_gen: pd.Series = None - self.etud_moy_gen_ranks = {} - self.etud_moy_gen_ranks_int = {} - self.moy_gen_rangs_by_group = None # virtual - self.modimpl_inscr_df: pd.DataFrame = None - "Inscriptions: row etudid, col modimlpl_id" - self.modimpls_results: ModuleImplResults = None - "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }" - self.etud_coef_ue_df = None - """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)""" - self.modimpl_coefs_df: pd.DataFrame = None - """Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef.""" - - self.validations = None - self.moyennes_matieres = {} - """Moyennes de matières, si calculées. { matiere_id : Series, index etudid }""" - - def __repr__(self): - return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>" - - def compute(self): - "Charge les notes et inscriptions et calcule toutes les moyennes" - # voir ce qui est chargé / calculé ici et dans les sous-classes - raise NotImplementedError() - - def get_inscriptions_counts(self) -> Counter: - """Nombre d'inscrits, défaillants, démissionnaires. - - Exemple: res.get_inscriptions_counts()[scu.INSCRIT] - - Result: a collections.Counter instance - """ - return Counter(ins.etat for ins in self.formsemestre.inscriptions) - - @cached_property - def etuds(self) -> list[Identite]: - "Liste des inscrits au semestre, avec les démissionnaires et les défaillants" - # nb: si la liste des inscrits change, ResultatsSemestre devient invalide - return self.formsemestre.get_inscrits(include_demdef=True) - - @cached_property - def etud_index(self) -> dict[int, int]: - "dict { etudid : indice dans les inscrits }" - return {e.id: idx for idx, e in enumerate(self.etuds)} - - def etud_ues_ids(self, etudid: int) -> list[int]: - """Liste des UE auxquelles l'etudiant est inscrit, sans bonus - (surchargée en BUT pour prendre en compte les parcours) - """ - # Pour les formations classiques, etudid n'est pas utilisé - # car tous les étudiants sont inscrits à toutes les UE - return [ue.id for ue in self.ues if ue.type != UE_SPORT] - - def etud_ues(self, etudid: int) -> Generator[UniteEns]: - """Liste des UE auxquelles l'étudiant est inscrit.""" - return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid)) - - def etud_ects_tot_sem(self, etudid: int) -> float: - """Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)""" - etud_ues = self.etud_ues(etudid) - return sum([ue.ects or 0 for ue in etud_ues]) if etud_ues else 0.0 - - def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray: - """Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue. - Utile pour stats bottom tableau recap. - Résultat: 1d array of float - """ - # différent en BUT et classique: virtuelle - raise NotImplementedError - - @cached_property - def etuds_dict(self) -> dict[int, Identite]: - """dict { etudid : Identite } inscrits au semestre, - avec les démissionnaires et defs.""" - return {etud.id: etud for etud in self.etuds} - - @cached_property - def ues(self) -> list[UniteEns]: - """Liste des UEs du semestre (avec les UE bonus sport) - (indices des DataFrames). - Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs. - """ - return self.formsemestre.query_ues(with_sport=True).all() - - @cached_property - def ressources(self): - "Liste des ressources du semestre, triées par numéro de module" - return [ - m - for m in self.formsemestre.modimpls_sorted - if m.module.module_type == scu.ModuleType.RESSOURCE - ] - - @cached_property - def saes(self): - "Liste des SAÉs du semestre, triées par numéro de module" - return [ - m - for m in self.formsemestre.modimpls_sorted - if m.module.module_type == scu.ModuleType.SAE - ] - - # --- JURY... - def load_validations(self) -> ValidationsSemestre: - """Load validations, set attribute and return value""" - if not self.validations: - self.validations = res_sem.load_formsemestre_validations(self.formsemestre) - return self.validations - - def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]: - """Liste des UEs du semestre qui doivent être validées - - Rappel: l'étudiant est inscrit à des modimpls et non à des UEs. - - - En BUT: on considère que l'étudiant va (ou non) valider toutes les UEs des modules - du parcours. - - - En classique: toutes les UEs des modimpls auxquels l'étudiant est inscrit sont - susceptibles d'être validées. - - Les UE "bonus" (sport) ne sont jamais "validables". - """ - if self.is_apc: - return list(self.etud_ues(etudid)) - # Formations classiques: - # restreint aux UE auxquelles l'étudiant est inscrit (dans l'un des modimpls) - ues = { - modimpl.module.ue - for modimpl in self.formsemestre.modimpls_sorted - if self.modimpl_inscr_df[modimpl.id][etudid] - } - ues = sorted(list(ues), key=lambda x: x.numero or 0) - return ues - - def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]: - """Liste des modimpl de cette UE auxquels l'étudiant est inscrit. - Utile en formations classiques, surchargée pour le BUT. - Inclus modules bonus le cas échéant. - """ - # Utilisée pour l'affichage ou non de l'UE sur le bulletin - # Méthode surchargée en BUT - modimpls = [ - modimpl - for modimpl in self.formsemestre.modimpls_sorted - if modimpl.module.ue.id == ue.id - and self.modimpl_inscr_df[modimpl.id][etudid] - ] - if not with_bonus: - return [ - modimpl for modimpl in modimpls if modimpl.module.ue.type != UE_SPORT - ] - return modimpls - - @cached_property - def ue_au_dessus(self, seuil=10.0) -> pd.DataFrame: - """DataFrame columns UE, rows etudid, valeurs: bool - Par exemple, pour avoir le nombre d'UE au dessus de 10 pour l'étudiant etudid - nb_ues_ok = sum(res.ue_au_dessus().loc[etudid]) - """ - return self.etud_moy_ue > (seuil - scu.NOTES_TOLERANCE) - - def apply_capitalisation(self): - """Recalcule la moyenne générale pour prendre en compte d'éventuelles - UE capitalisées. - """ - # Supposant qu'il y a peu d'UE capitalisées, - # on recalcule les moyennes gen des etuds ayant des UE capitalisée. - self.load_validations() - ue_capitalisees = self.validations.ue_capitalisees - for etudid in ue_capitalisees.index: - recompute_mg = False - # ue_codes = set(ue_capitalisees.loc[etudid]["ue_code"]) - # for ue_code in ue_codes: - # ue = ue_by_code.get(ue_code) - # if ue is None: - # ue = self.formsemestre.query_ues.filter_by(ue_code=ue_code) - # ue_by_code[ue_code] = ue - - # Quand il y a une capitalisation, vérifie toutes les UEs - sum_notes_ue = 0.0 - sum_coefs_ue = 0.0 - for ue in self.formsemestre.query_ues(): - ue_cap = self.get_etud_ue_status(etudid, ue.id) - if ue_cap is None: - continue - if ue_cap["is_capitalized"]: - recompute_mg = True - coef = ue_cap["coef_ue"] - if not np.isnan(ue_cap["moy"]) and coef: - sum_notes_ue += ue_cap["moy"] * coef - sum_coefs_ue += coef - - if recompute_mg and sum_coefs_ue > 0.0: - # On doit prendre en compte une ou plusieurs UE capitalisées - # et donc recalculer la moyenne générale - self.etud_moy_gen[etudid] = sum_notes_ue / sum_coefs_ue - # Ajoute le bonus sport - if self.bonus is not None and self.bonus[etudid]: - self.etud_moy_gen[etudid] += self.bonus[etudid] - self.etud_moy_gen[etudid] = max( - 0.0, min(self.etud_moy_gen[etudid], 20.0) - ) - - def get_etud_etat(self, etudid: int) -> str: - "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)" - ins = self.formsemestre.etuds_inscriptions.get(etudid, None) - if ins is None: - return "" - return ins.etat - - def _get_etud_ue_cap(self, etudid: int, ue: UniteEns) -> dict: - """Donne les informations sur la capitalisation de l'UE ue pour cet étudiant. - Résultat: - Si pas capitalisée: None - Si capitalisée: un dict, avec les colonnes de validation. - """ - capitalisations = self.validations.ue_capitalisees.loc[etudid] - if isinstance(capitalisations, pd.DataFrame): - ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code] - if ue_cap.empty: - return None - if isinstance(ue_cap, pd.DataFrame): - # si plusieurs fois capitalisée, prend le max - cap_idx = ue_cap["moy_ue"].values.argmax() - ue_cap = ue_cap.iloc[cap_idx] - else: - if capitalisations["ue_code"] == ue.ue_code: - ue_cap = capitalisations - else: - return None - # converti la Series en dict, afin que les np.int64 reviennent en int - # et remplace les NaN (venant des NULL en base) par des None - ue_cap_dict = ue_cap.to_dict() - if ue_cap_dict["formsemestre_id"] is not None and np.isnan( - ue_cap_dict["formsemestre_id"] - ): - ue_cap_dict["formsemestre_id"] = None - if ue_cap_dict["compense_formsemestre_id"] is not None and np.isnan( - ue_cap_dict["compense_formsemestre_id"] - ): - ue_cap_dict["compense_formsemestre_id"] = None - return ue_cap_dict - - def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: - """L'état de l'UE pour cet étudiant. - Result: dict, ou None si l'UE n'est pas dans ce semestre. - """ - ue = UniteEns.query.get(ue_id) # TODO cacher nos UEs ? - if ue.type == UE_SPORT: - return { - "is_capitalized": False, - "was_capitalized": False, - "is_external": False, - "coef_ue": 0.0, - "cur_moy_ue": 0.0, - "moy": 0.0, - "event_date": None, - "ue": ue.to_dict(), - "formsemestre_id": None, - "capitalized_ue_id": None, - "ects_pot": 0.0, - "ects": 0.0, - "ects_ue": ue.ects, - } - if not ue_id in self.etud_moy_ue: - return None - if not self.validations: - self.validations = res_sem.load_formsemestre_validations(self.formsemestre) - cur_moy_ue = self.etud_moy_ue[ue_id][etudid] - moy_ue = cur_moy_ue - is_capitalized = False # si l'UE prise en compte est une UE capitalisée - # s'il y a precedemment une UE capitalisée (pas forcement meilleure): - was_capitalized = False - if etudid in self.validations.ue_capitalisees.index: - ue_cap = self._get_etud_ue_cap(etudid, ue) - if ue_cap and not np.isnan(ue_cap["moy_ue"]): - was_capitalized = True - if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue): - moy_ue = ue_cap["moy_ue"] - is_capitalized = True - - # Coef l'UE dans le semestre courant: - if self.is_apc: - # utilise les ECTS comme coef. - coef_ue = ue.ects - else: - # formations classiques - coef_ue = self.etud_coef_ue_df[ue_id][etudid] - if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante - if self.is_apc: - # Coefs de l'UE capitalisée en formation APC: donné par ses ECTS - ue_capitalized = UniteEns.query.get(ue_cap["ue_id"]) - coef_ue = ue_capitalized.ects - if coef_ue is None: - orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"]) - raise ScoValueError( - f"""L'UE capitalisée {ue_capitalized.acronyme} - du semestre {orig_sem.titre_annee()} - n'a pas d'indication d'ECTS. - Corrigez ou faite corriger le programme - via cette page. - """ - ) - else: - # Coefs de l'UE capitalisée en formation classique: - # va chercher le coef dans le semestre d'origine - coef_ue = ModuleImplInscription.sum_coefs_modimpl_ue( - ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"] - ) - - return { - "is_capitalized": is_capitalized, - "was_capitalized": was_capitalized, - "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external, - "coef_ue": coef_ue, - "ects_pot": ue.ects or 0.0, - "ects": self.validations.decisions_jury_ues.get(etudid, {}) - .get(ue.id, {}) - .get("ects", 0.0) - if self.validations.decisions_jury_ues - else 0.0, - "ects_ue": ue.ects, - "cur_moy_ue": cur_moy_ue, - "moy": moy_ue, - "event_date": ue_cap["event_date"] if is_capitalized else None, - "ue": ue.to_dict(), - "formsemestre_id": ue_cap["formsemestre_id"] if is_capitalized else None, - "capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None, - } - - def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: - "Détermine le coefficient de l'UE pour cet étudiant." - # calcul différent en classique et BUT - raise NotImplementedError() - - def get_etud_ue_cap_coef(self, etudid, ue, ue_cap): # UNUSED in ScoDoc 9 - """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' } - """ - # 1- Coefficient explicitement déclaré dans le semestre courant pour cette UE ? - ue_coef_db = FormSemestreUECoef.query.filter_by( - formsemestre_id=self.formsemestre.id, ue_id=ue.id - ).first() - if ue_coef_db is not None: - return ue_coef_db.coefficient - - # En APC: somme des coefs des modules vers cette UE - # En classique: Capitalisation UE externe: quel coef appliquer ? - # En ScoDoc 7, calculait la somme des coefs dans l'UE du semestre d'origine - # ici si l'étudiant est inscrit dans le semestre courant, - # somme des coefs des modules de l'UE auxquels il est inscrit - return self.compute_etud_ue_coef(etudid, ue) - - # --- TABLEAU RECAP - - def get_table_recap( - self, - convert_values=False, - include_evaluations=False, - mode_jury=False, - allow_html=True, - ): - """Table récap. des résultats. - allow_html: si vri, peut-mettre du HTML dans les valeurs - - Result: tuple avec - - rows: liste de dicts { column_id : value } - - titles: { column_id : title } - - columns_ids: (liste des id de colonnes) - - Si convert_values, transforme les notes en chaines ("12.34"). - Les colonnes générées sont: - etudid - rang : rang indicatif (basé sur moy gen) - moy_gen : moy gen indicative - moy_ue_, ..., les moyennes d'UE - moy_res__, ... les moyennes de ressources dans l'UE - moy_sae__, ... les moyennes de SAE dans l'UE - - On ajoute aussi des attributs: - - pour les lignes: - _css_row_class (inutilisé pour le monent) - __class classe css: - - la moyenne générale a la classe col_moy_gen - - les colonnes SAE ont la classe col_sae - - les colonnes Resources ont la classe col_res - - les colonnes d'UE ont la classe col_ue - - les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_ - __order : clé de tri - """ - if convert_values: - fmt_note = scu.fmt_note - else: - fmt_note = lambda x: x - - parcours = self.formsemestre.formation.get_parcours() - barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE - barre_valid_ue = parcours.NOTES_BARRE_VALID_UE - barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING - NO_NOTE = "-" # contenu des cellules sans notes - rows = [] - # column_id : title - titles = {} - # les titres en footer: les mêmes, mais avec des bulles et liens: - titles_bot = {} - dict_nom_res = {} # cache uid : nomcomplet - - def add_cell( - row: dict, - col_id: str, - title: str, - content: str, - classes: str = "", - idx: int = 100, - ): - "Add a row to our table. classes is a list of css class names" - row[col_id] = content - if classes: - row[f"_{col_id}_class"] = classes + f" c{idx}" - if not col_id in titles: - titles[col_id] = title - titles[f"_{col_id}_col_order"] = idx - if classes: - titles[f"_{col_id}_class"] = classes - return idx + 1 - - etuds_inscriptions = self.formsemestre.etuds_inscriptions - ues = self.formsemestre.query_ues(with_sport=True) # avec bonus - ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT] - modimpl_ids = set() # modimpl effectivement présents dans la table - for etudid in etuds_inscriptions: - idx = 0 # index de la colonne - etud = Identite.query.get(etudid) - row = {"etudid": etudid} - # --- Codes (seront cachés, mais exportés en excel) - idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx) - idx = add_cell( - row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx - ) - # --- Rang - idx = add_cell( - row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx - ) - row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}" - # --- Identité étudiant - idx = add_cell( - row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx - ) - idx = add_cell( - row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx - ) - row["_nom_disp_order"] = etud.sort_key - idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx) - idx = add_cell( - row, "nom_short", "Nom", etud.nom_short, "identite_court", idx - ) - row["_nom_short_order"] = etud.sort_key - row["_nom_short_target"] = url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - formsemestre_id=self.formsemestre.id, - etudid=etudid, - ) - row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"' - row["_nom_disp_target"] = row["_nom_short_target"] - row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"] - - idx = 30 # début des colonnes de notes - # --- Moyenne générale - moy_gen = self.etud_moy_gen.get(etudid, False) - note_class = "" - if moy_gen is False: - moy_gen = NO_NOTE - elif isinstance(moy_gen, float) and moy_gen < barre_moy: - note_class = " moy_ue_warning" # en rouge - idx = add_cell( - row, - "moy_gen", - "Moy", - fmt_note(moy_gen), - "col_moy_gen" + note_class, - idx, - ) - titles_bot["_moy_gen_target_attrs"] = ( - 'title="moyenne indicative"' if self.is_apc else "" - ) - # --- Moyenne d'UE - nb_ues_validables, nb_ues_warning = 0, 0 - for ue in ues_sans_bonus: - ue_status = self.get_etud_ue_status(etudid, ue.id) - if ue_status is not None: - col_id = f"moy_ue_{ue.id}" - val = ue_status["moy"] - note_class = "" - if isinstance(val, float): - if val < barre_moy: - note_class = " moy_inf" - elif val >= barre_valid_ue: - note_class = " moy_ue_valid" - nb_ues_validables += 1 - if val < barre_warning_ue: - note_class = " moy_ue_warning" # notes très basses - nb_ues_warning += 1 - idx = add_cell( - row, - col_id, - ue.acronyme, - fmt_note(val), - "col_ue" + note_class, - idx, - ) - titles_bot[ - f"_{col_id}_target_attrs" - ] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """ - if mode_jury: - # pas d'autre colonnes de résultats - continue - # Bonus (sport) dans cette UE ? - # Le bonus sport appliqué sur cette UE - if (self.bonus_ues is not None) and (ue.id in self.bonus_ues): - val = self.bonus_ues[ue.id][etud.id] or "" - val_fmt = val_fmt_html = fmt_note(val) - if val: - val_fmt_html = f'{val_fmt}' - idx = add_cell( - row, - f"bonus_ue_{ue.id}", - f"Bonus {ue.acronyme}", - val_fmt_html if allow_html else val_fmt, - "col_ue_bonus", - idx, - ) - row[f"_bonus_ue_{ue.id}_xls"] = val_fmt - # Les moyennes des modules (ou ressources et SAÉs) dans cette UE - idx_malus = idx # place pour colonne malus à gauche des modules - idx += 1 - for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False): - if ue_status["is_capitalized"]: - val = "-c-" - else: - modimpl_results = self.modimpls_results.get(modimpl.id) - if modimpl_results: # pas bonus - if self.is_apc: # BUT - moys_vers_ue = modimpl_results.etuds_moy_module.get( - ue.id - ) - val = ( - moys_vers_ue.get(etudid, "?") - if moys_vers_ue is not None - else "" - ) - else: # classique: Series indépendante de l'UE - val = modimpl_results.etuds_moy_module.get( - etudid, "?" - ) - else: - val = "" - - col_id = ( - f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" - ) - val_fmt = val_fmt_html = fmt_note(val) - if convert_values and ( - modimpl.module.module_type == scu.ModuleType.MALUS - ): - val_fmt_html = ( - (scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else "" - ) - idx = add_cell( - row, - col_id, - modimpl.module.code, - val_fmt_html, - # class col_res mod_ue_123 - f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}", - idx, - ) - row[f"_{col_id}_xls"] = val_fmt - if modimpl.module.module_type == scu.ModuleType.MALUS: - titles[f"_{col_id}_col_order"] = idx_malus - titles_bot[f"_{col_id}_target"] = url_for( - "notes.moduleimpl_status", - scodoc_dept=g.scodoc_dept, - moduleimpl_id=modimpl.id, - ) - nom_resp = dict_nom_res.get(modimpl.responsable_id) - if nom_resp is None: - user = User.query.get(modimpl.responsable_id) - nom_resp = user.get_nomcomplet() if user else "" - dict_nom_res[modimpl.responsable_id] = nom_resp - titles_bot[ - f"_{col_id}_target_attrs" - ] = f""" title="{modimpl.module.titre} ({nom_resp})" """ - modimpl_ids.add(modimpl.id) - nb_ues_etud_parcours = len(self.etud_ues_ids(etudid)) - ue_valid_txt = ( - ue_valid_txt_html - ) = f"{nb_ues_validables}/{nb_ues_etud_parcours}" - if nb_ues_warning: - ue_valid_txt_html += " " + scu.EMO_WARNING - add_cell( - row, - "ues_validables", - "UEs", - ue_valid_txt_html, - "col_ue col_ues_validables", - 29, # juste avant moy. gen. - ) - row["_ues_validables_xls"] = ue_valid_txt - if nb_ues_warning: - row["_ues_validables_class"] += " moy_ue_warning" - elif nb_ues_validables < len(ues_sans_bonus): - row["_ues_validables_class"] += " moy_inf" - row["_ues_validables_order"] = nb_ues_validables # pour tri - if mode_jury and self.validations: - if self.is_apc: - # formations BUT: pas de code semestre, concatene ceux des UE - dec_ues = self.validations.decisions_jury_ues.get(etudid) - if dec_ues: - jury_code_sem = ",".join( - [dec_ues[ue_id].get("code", "") for ue_id in dec_ues] - ) - else: - jury_code_sem = "" - else: - # formations classiqes: code semestre - dec_sem = self.validations.decisions_jury.get(etudid) - jury_code_sem = dec_sem["code"] if dec_sem else "" - idx = add_cell( - row, - "jury_code_sem", - "Jury", - jury_code_sem, - "jury_code_sem", - 1000, - ) - idx = add_cell( - row, - "jury_link", - "", - f"""{"saisir" if not jury_code_sem else "modifier"} décision""", - "col_jury_link", - idx, - ) - rows.append(row) - - self.recap_add_partitions(rows, titles) - self._recap_add_admissions(rows, titles) - - # tri par rang croissant - rows.sort(key=lambda e: e["_rang_order"]) - - # INFOS POUR FOOTER - bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note) - if include_evaluations: - self._recap_add_evaluations(rows, titles, bottom_infos) - - # Ajoute style "col_empty" aux colonnes de modules vides - for col_id in titles: - c_class = f"_{col_id}_class" - if "col_empty" in bottom_infos["moy"].get(c_class, ""): - for row in rows: - row[c_class] = row.get(c_class, "") + " col_empty" - titles[c_class] += " col_empty" - for row in bottom_infos.values(): - row[c_class] = row.get(c_class, "") + " col_empty" - - # --- TABLE FOOTER: ECTS, moyennes, min, max... - footer_rows = [] - for (bottom_line, row) in bottom_infos.items(): - # Cases vides à styler: - row["moy_gen"] = row.get("moy_gen", "") - row["_moy_gen_class"] = "col_moy_gen" - # titre de la ligne: - row["prenom"] = row["nom_short"] = ( - row.get("_title", "") or bottom_line.capitalize() - ) - row["_tr_class"] = bottom_line.lower() + ( - (" " + row["_tr_class"]) if "_tr_class" in row else "" - ) - footer_rows.append(row) - titles_bot.update(titles) - footer_rows.append(titles_bot) - column_ids = [title for title in titles if not title.startswith("_")] - column_ids.sort( - key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000) - ) - return (rows, footer_rows, titles, column_ids) - - def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict: - """Les informations à mettre en bas de la table: min, max, moy, ECTS""" - row_min, row_max, row_moy, row_coef, row_ects, row_apo = ( - {"_tr_class": "bottom_info", "_title": "Min."}, - {"_tr_class": "bottom_info"}, - {"_tr_class": "bottom_info"}, - {"_tr_class": "bottom_info"}, - {"_tr_class": "bottom_info"}, - {"_tr_class": "bottom_info", "_title": "Code Apogée"}, - ) - # --- ECTS - for ue in ues: - colid = f"moy_ue_{ue.id}" - row_ects[colid] = ue.ects - row_ects[f"_{colid}_class"] = "col_ue" - # style cases vides pour borders verticales - row_coef[colid] = "" - row_coef[f"_{colid}_class"] = "col_ue" - # row_apo[colid] = ue.code_apogee or "" - row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]) - row_ects["_moy_gen_class"] = "col_moy_gen" - - # --- MIN, MAX, MOY, APO - - row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min()) - row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max()) - row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean()) - for ue in ues: - colid = f"moy_ue_{ue.id}" - row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min()) - row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max()) - row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean()) - row_min[f"_{colid}_class"] = "col_ue" - row_max[f"_{colid}_class"] = "col_ue" - row_moy[f"_{colid}_class"] = "col_ue" - row_apo[colid] = ue.code_apogee or "" - - for modimpl in self.formsemestre.modimpls_sorted: - if modimpl.id in modimpl_ids: - colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" - if self.is_apc: - coef = self.modimpl_coefs_df[modimpl.id][ue.id] - else: - coef = modimpl.module.coefficient or 0 - row_coef[colid] = fmt_note(coef) - notes = self.modimpl_notes(modimpl.id, ue.id) - if np.isnan(notes).all(): - # aucune note valide - row_min[colid] = np.nan - row_max[colid] = np.nan - moy = np.nan - else: - row_min[colid] = fmt_note(np.nanmin(notes)) - row_max[colid] = fmt_note(np.nanmax(notes)) - moy = np.nanmean(notes) - row_moy[colid] = fmt_note(moy) - if np.isnan(moy): - # aucune note dans ce module - row_moy[f"_{colid}_class"] = "col_empty" - row_apo[colid] = modimpl.module.code_apogee or "" - - return { # { key : row } avec key = min, max, moy, coef - "min": row_min, - "max": row_max, - "moy": row_moy, - "coef": row_coef, - "ects": row_ects, - "apo": row_apo, - } - - def _recap_etud_groups_infos( - self, etudid: int, row: dict, titles: dict - ): # XXX non utilisé - """Table recap: ajoute à row les colonnes sur les groupes pour cet etud""" - # dec = self.get_etud_decision_sem(etudid) - # if dec: - # codes_nb[dec["code"]] += 1 - row_class = "" - etud_etat = self.get_etud_etat(etudid) - if etud_etat == DEM: - gr_name = "Dém." - row_class = "dem" - elif etud_etat == DEF: - gr_name = "Déf." - row_class = "def" - else: - # XXX probablement à revoir pour utiliser données cachées, - # via get_etud_groups_in_partition ou autre - group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id) - gr_name = group["group_name"] or "" - row["group"] = gr_name - row["_group_class"] = "group" - if row_class: - row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class]) - titles["group"] = "Gr" - - def _recap_add_admissions(self, rows: list[dict], titles: dict): - """Ajoute les colonnes "admission" - rows est une liste de dict avec une clé "etudid" - Les colonnes ont la classe css "admission" - """ - fields = { - "bac": "Bac", - "specialite": "Spécialité", - "type_admission": "Type Adm.", - "classement": "Rg. Adm.", - } - first = True - for i, cid in enumerate(fields): - titles[f"_{cid}_col_order"] = 10000 + i # tout à droite - if first: - titles[f"_{cid}_class"] = "admission admission_first" - first = False - else: - titles[f"_{cid}_class"] = "admission" - titles.update(fields) - for row in rows: - etud = Identite.query.get(row["etudid"]) - admission = etud.admission.first() - first = True - for cid in fields: - row[cid] = getattr(admission, cid) or "" - if first: - row[f"_{cid}_class"] = "admission admission_first" - first = False - else: - row[f"_{cid}_class"] = "admission" - - def recap_add_partitions(self, rows: list[dict], titles: dict, col_idx: int = None): - """Ajoute les colonnes indiquant les groupes - rows est une liste de dict avec une clé "etudid" - Les colonnes ont la classe css "partition" - """ - partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups( - self.formsemestre.id - ) - first_partition = True - col_order = 10 if col_idx is None else col_idx - for partition in partitions: - cid = f"part_{partition['partition_id']}" - rg_cid = cid + "_rg" # rang dans la partition - titles[cid] = partition["partition_name"] - if first_partition: - klass = "partition" - else: - klass = "partition partition_aux" - titles[f"_{cid}_class"] = klass - titles[f"_{cid}_col_order"] = col_order - titles[f"_{rg_cid}_col_order"] = col_order + 1 - col_order += 2 - if partition["bul_show_rank"]: - titles[rg_cid] = f"Rg {partition['partition_name']}" - titles[f"_{rg_cid}_class"] = "partition_rangs" - partition_etud_groups = partitions_etud_groups[partition["partition_id"]] - for row in rows: - group = None # group (dict) de l'étudiant dans cette partition - # dans NotesTableCompat, à revoir - etud_etat = self.get_etud_etat(row["etudid"]) - if etud_etat == "D": - gr_name = "Dém." - row["_tr_class"] = "dem" - elif etud_etat == DEF: - gr_name = "Déf." - row["_tr_class"] = "def" - else: - group = partition_etud_groups.get(row["etudid"]) - gr_name = group["group_name"] if group else "" - if gr_name: - row[cid] = gr_name - row[f"_{cid}_class"] = klass - # Rangs dans groupe - if ( - partition["bul_show_rank"] - and (group is not None) - and (group["id"] in self.moy_gen_rangs_by_group) - ): - rang = self.moy_gen_rangs_by_group[group["id"]][0] - row[rg_cid] = rang.get(row["etudid"], "") - - first_partition = False - - def _recap_add_evaluations( - self, rows: list[dict], titles: dict, bottom_infos: dict - ): - """Ajoute les colonnes avec les notes aux évaluations - rows est une liste de dict avec une clé "etudid" - Les colonnes ont la classe css "evaluation" - """ - # nouvelle ligne pour description évaluations: - bottom_infos["descr_evaluation"] = { - "_tr_class": "bottom_info", - "_title": "Description évaluation", - } - first_eval = True - index_col = 9000 # à droite - for modimpl in self.formsemestre.modimpls_sorted: - evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl) - eval_index = len(evals) - 1 - inscrits = {i.etudid for i in modimpl.inscriptions} - first_eval_of_mod = True - for e in evals: - cid = f"eval_{e.id}" - titles[ - cid - ] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}' - klass = "evaluation" - if first_eval: - klass += " first" - elif first_eval_of_mod: - klass += " first_of_mod" - titles[f"_{cid}_class"] = klass - first_eval_of_mod = first_eval = False - titles[f"_{cid}_col_order"] = index_col - index_col += 1 - eval_index -= 1 - notes_db = sco_evaluation_db.do_evaluation_get_all_notes( - e.evaluation_id - ) - for row in rows: - etudid = row["etudid"] - if etudid in inscrits: - if etudid in notes_db: - val = notes_db[etudid]["value"] - else: - # Note manquante mais prise en compte immédiate: affiche ATT - val = scu.NOTES_ATTENTE - row[cid] = scu.fmt_note(val) - row[f"_{cid}_class"] = klass + { - "ABS": " abs", - "ATT": " att", - "EXC": " exc", - }.get(row[cid], "") - else: - row[cid] = "ni" - row[f"_{cid}_class"] = klass + " non_inscrit" - - bottom_infos["coef"][cid] = e.coefficient - bottom_infos["min"][cid] = "0" - bottom_infos["max"][cid] = scu.fmt_note(e.note_max) - bottom_infos["descr_evaluation"][cid] = e.description or "" - bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for( - "notes.evaluation_listenotes", - scodoc_dept=g.scodoc_dept, - evaluation_id=e.id, - ) +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Résultats semestre: méthodes communes aux formations classiques et APC +""" + +from collections import Counter +from collections.abc import Generator +from functools import cached_property +import numpy as np +import pandas as pd + +from flask import g, url_for + +from app.auth.models import User +from app.comp import res_sem +from app.comp.res_cache import ResultatsCache +from app.comp.jury import ValidationsSemestre +from app.comp.moy_mod import ModuleImplResults +from app.models import FormSemestre, FormSemestreUECoef +from app.models import Identite +from app.models import ModuleImpl, ModuleImplInscription +from app.models.ues import UniteEns +from app.scodoc.sco_cache import ResultatsSemestreCache +from app.scodoc.sco_codes_parcours import UE_SPORT, DEF, DEM +from app.scodoc import sco_evaluation_db +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_groups +from app.scodoc import sco_utils as scu + +# Il faut bien distinguer +# - ce qui est caché de façon persistente (via redis): +# ce sont les attributs listés dans `_cached_attrs` +# le stockage et l'invalidation sont gérés dans sco_cache.py +# +# - les valeurs cachées durant le temps d'une requête +# (durée de vie de l'instance de ResultatsSemestre) +# qui sont notamment les attributs décorés par `@cached_property`` +# +class ResultatsSemestre(ResultatsCache): + """Les résultats (notes, ...) d'un formsemestre + Classe commune à toutes les formations (classiques, BUT) + """ + + _cached_attrs = ( + "bonus", + "bonus_ues", + "etud_moy_gen_ranks", + "etud_moy_gen", + "etud_moy_ue", + "modimpl_inscr_df", + "modimpls_results", + "etud_coef_ue_df", + "moyennes_matieres", + ) + + def __init__(self, formsemestre: FormSemestre): + super().__init__(formsemestre, ResultatsSemestreCache) + # BUT ou standard ? (apc == "approche par compétences") + self.is_apc = formsemestre.formation.is_apc() + # Attributs "virtuels", définis dans les sous-classes + self.bonus: pd.Series = None # virtuel + "Bonus sur moy. gen. Series de float, index etudid" + self.bonus_ues: pd.DataFrame = None # virtuel + "DataFrame de float, index etudid, columns: ue.id" + # ResultatsSemestreBUT ou ResultatsSemestreClassic + self.etud_moy_ue = {} + "etud_moy_ue: DataFrame columns UE, rows etudid" + self.etud_moy_gen: pd.Series = None + self.etud_moy_gen_ranks = {} + self.etud_moy_gen_ranks_int = {} + self.moy_gen_rangs_by_group = None # virtual + self.modimpl_inscr_df: pd.DataFrame = None + "Inscriptions: row etudid, col modimlpl_id" + self.modimpls_results: ModuleImplResults = None + "Résultats de chaque modimpl: dict { modimpl.id : ModuleImplResults(Classique ou BUT) }" + self.etud_coef_ue_df = None + """coefs d'UE effectifs pour chaque étudiant (pour form. classiques)""" + self.modimpl_coefs_df: pd.DataFrame = None + """Coefs APC: rows = UEs (sans bonus), columns = modimpl, value = coef.""" + + self.validations = None + self.moyennes_matieres = {} + """Moyennes de matières, si calculées. { matiere_id : Series, index etudid }""" + + def __repr__(self): + return f"<{self.__class__.__name__}(formsemestre='{self.formsemestre}')>" + + def compute(self): + "Charge les notes et inscriptions et calcule toutes les moyennes" + # voir ce qui est chargé / calculé ici et dans les sous-classes + raise NotImplementedError() + + def get_inscriptions_counts(self) -> Counter: + """Nombre d'inscrits, défaillants, démissionnaires. + + Exemple: res.get_inscriptions_counts()[scu.INSCRIT] + + Result: a collections.Counter instance + """ + return Counter(ins.etat for ins in self.formsemestre.inscriptions) + + @cached_property + def etuds(self) -> list[Identite]: + "Liste des inscrits au semestre, avec les démissionnaires et les défaillants" + # nb: si la liste des inscrits change, ResultatsSemestre devient invalide + return self.formsemestre.get_inscrits(include_demdef=True) + + @cached_property + def etud_index(self) -> dict[int, int]: + "dict { etudid : indice dans les inscrits }" + return {e.id: idx for idx, e in enumerate(self.etuds)} + + def etud_ues_ids(self, etudid: int) -> list[int]: + """Liste des UE auxquelles l'etudiant est inscrit, sans bonus + (surchargée en BUT pour prendre en compte les parcours) + """ + # Pour les formations classiques, etudid n'est pas utilisé + # car tous les étudiants sont inscrits à toutes les UE + return [ue.id for ue in self.ues if ue.type != UE_SPORT] + + def etud_ues(self, etudid: int) -> Generator[UniteEns]: + """Liste des UE auxquelles l'étudiant est inscrit.""" + return (UniteEns.query.get(ue_id) for ue_id in self.etud_ues_ids(etudid)) + + def etud_ects_tot_sem(self, etudid: int) -> float: + """Le total des ECTS associées à ce semestre (que l'étudiant peut ou non valider)""" + etud_ues = self.etud_ues(etudid) + return sum([ue.ects or 0 for ue in etud_ues]) if etud_ues else 0.0 + + def modimpl_notes(self, modimpl_id: int, ue_id: int) -> np.ndarray: + """Les notes moyennes des étudiants du sem. à ce modimpl dans cette ue. + Utile pour stats bottom tableau recap. + Résultat: 1d array of float + """ + # différent en BUT et classique: virtuelle + raise NotImplementedError + + @cached_property + def etuds_dict(self) -> dict[int, Identite]: + """dict { etudid : Identite } inscrits au semestre, + avec les démissionnaires et defs.""" + return {etud.id: etud for etud in self.etuds} + + @cached_property + def ues(self) -> list[UniteEns]: + """Liste des UEs du semestre (avec les UE bonus sport) + (indices des DataFrames). + Note: un étudiant n'est pas nécessairement inscrit dans toutes ces UEs. + """ + return self.formsemestre.query_ues(with_sport=True).all() + + @cached_property + def ressources(self): + "Liste des ressources du semestre, triées par numéro de module" + return [ + m + for m in self.formsemestre.modimpls_sorted + if m.module.module_type == scu.ModuleType.RESSOURCE + ] + + @cached_property + def saes(self): + "Liste des SAÉs du semestre, triées par numéro de module" + return [ + m + for m in self.formsemestre.modimpls_sorted + if m.module.module_type == scu.ModuleType.SAE + ] + + # --- JURY... + def load_validations(self) -> ValidationsSemestre: + """Load validations, set attribute and return value""" + if not self.validations: + self.validations = res_sem.load_formsemestre_validations(self.formsemestre) + return self.validations + + def get_etud_ue_validables(self, etudid: int) -> list[UniteEns]: + """Liste des UEs du semestre qui doivent être validées + + Rappel: l'étudiant est inscrit à des modimpls et non à des UEs. + + - En BUT: on considère que l'étudiant va (ou non) valider toutes les UEs des modules + du parcours. + + - En classique: toutes les UEs des modimpls auxquels l'étudiant est inscrit sont + susceptibles d'être validées. + + Les UE "bonus" (sport) ne sont jamais "validables". + """ + if self.is_apc: + return list(self.etud_ues(etudid)) + # Formations classiques: + # restreint aux UE auxquelles l'étudiant est inscrit (dans l'un des modimpls) + ues = { + modimpl.module.ue + for modimpl in self.formsemestre.modimpls_sorted + if self.modimpl_inscr_df[modimpl.id][etudid] + } + ues = sorted(list(ues), key=lambda x: x.numero or 0) + return ues + + def modimpls_in_ue(self, ue: UniteEns, etudid, with_bonus=True) -> list[ModuleImpl]: + """Liste des modimpl de cette UE auxquels l'étudiant est inscrit. + Utile en formations classiques, surchargée pour le BUT. + Inclus modules bonus le cas échéant. + """ + # Utilisée pour l'affichage ou non de l'UE sur le bulletin + # Méthode surchargée en BUT + modimpls = [ + modimpl + for modimpl in self.formsemestre.modimpls_sorted + if modimpl.module.ue.id == ue.id + and self.modimpl_inscr_df[modimpl.id][etudid] + ] + if not with_bonus: + return [ + modimpl for modimpl in modimpls if modimpl.module.ue.type != UE_SPORT + ] + return modimpls + + @cached_property + def ue_au_dessus(self, seuil=10.0) -> pd.DataFrame: + """DataFrame columns UE, rows etudid, valeurs: bool + Par exemple, pour avoir le nombre d'UE au dessus de 10 pour l'étudiant etudid + nb_ues_ok = sum(res.ue_au_dessus().loc[etudid]) + """ + return self.etud_moy_ue > (seuil - scu.NOTES_TOLERANCE) + + def apply_capitalisation(self): + """Recalcule la moyenne générale pour prendre en compte d'éventuelles + UE capitalisées. + """ + # Supposant qu'il y a peu d'UE capitalisées, + # on recalcule les moyennes gen des etuds ayant des UE capitalisée. + self.load_validations() + ue_capitalisees = self.validations.ue_capitalisees + for etudid in ue_capitalisees.index: + recompute_mg = False + # ue_codes = set(ue_capitalisees.loc[etudid]["ue_code"]) + # for ue_code in ue_codes: + # ue = ue_by_code.get(ue_code) + # if ue is None: + # ue = self.formsemestre.query_ues.filter_by(ue_code=ue_code) + # ue_by_code[ue_code] = ue + + # Quand il y a une capitalisation, vérifie toutes les UEs + sum_notes_ue = 0.0 + sum_coefs_ue = 0.0 + for ue in self.formsemestre.query_ues(): + ue_cap = self.get_etud_ue_status(etudid, ue.id) + if ue_cap is None: + continue + if ue_cap["is_capitalized"]: + recompute_mg = True + coef = ue_cap["coef_ue"] + if not np.isnan(ue_cap["moy"]) and coef: + sum_notes_ue += ue_cap["moy"] * coef + sum_coefs_ue += coef + + if recompute_mg and sum_coefs_ue > 0.0: + # On doit prendre en compte une ou plusieurs UE capitalisées + # et donc recalculer la moyenne générale + self.etud_moy_gen[etudid] = sum_notes_ue / sum_coefs_ue + # Ajoute le bonus sport + if self.bonus is not None and self.bonus[etudid]: + self.etud_moy_gen[etudid] += self.bonus[etudid] + self.etud_moy_gen[etudid] = max( + 0.0, min(self.etud_moy_gen[etudid], 20.0) + ) + + def get_etud_etat(self, etudid: int) -> str: + "Etat de l'etudiant: 'I', 'D', DEF ou '' (si pas connu dans ce semestre)" + ins = self.formsemestre.etuds_inscriptions.get(etudid, None) + if ins is None: + return "" + return ins.etat + + def _get_etud_ue_cap(self, etudid: int, ue: UniteEns) -> dict: + """Donne les informations sur la capitalisation de l'UE ue pour cet étudiant. + Résultat: + Si pas capitalisée: None + Si capitalisée: un dict, avec les colonnes de validation. + """ + capitalisations = self.validations.ue_capitalisees.loc[etudid] + if isinstance(capitalisations, pd.DataFrame): + ue_cap = capitalisations[capitalisations["ue_code"] == ue.ue_code] + if ue_cap.empty: + return None + if isinstance(ue_cap, pd.DataFrame): + # si plusieurs fois capitalisée, prend le max + cap_idx = ue_cap["moy_ue"].values.argmax() + ue_cap = ue_cap.iloc[cap_idx] + else: + if capitalisations["ue_code"] == ue.ue_code: + ue_cap = capitalisations + else: + return None + # converti la Series en dict, afin que les np.int64 reviennent en int + # et remplace les NaN (venant des NULL en base) par des None + ue_cap_dict = ue_cap.to_dict() + if ue_cap_dict["formsemestre_id"] is not None and np.isnan( + ue_cap_dict["formsemestre_id"] + ): + ue_cap_dict["formsemestre_id"] = None + if ue_cap_dict["compense_formsemestre_id"] is not None and np.isnan( + ue_cap_dict["compense_formsemestre_id"] + ): + ue_cap_dict["compense_formsemestre_id"] = None + return ue_cap_dict + + def get_etud_ue_status(self, etudid: int, ue_id: int) -> dict: + """L'état de l'UE pour cet étudiant. + Result: dict, ou None si l'UE n'est pas dans ce semestre. + """ + ue = UniteEns.query.get(ue_id) # TODO cacher nos UEs ? + if ue.type == UE_SPORT: + return { + "is_capitalized": False, + "was_capitalized": False, + "is_external": False, + "coef_ue": 0.0, + "cur_moy_ue": 0.0, + "moy": 0.0, + "event_date": None, + "ue": ue.to_dict(), + "formsemestre_id": None, + "capitalized_ue_id": None, + "ects_pot": 0.0, + "ects": 0.0, + "ects_ue": ue.ects, + } + if not ue_id in self.etud_moy_ue: + return None + if not self.validations: + self.validations = res_sem.load_formsemestre_validations(self.formsemestre) + cur_moy_ue = self.etud_moy_ue[ue_id][etudid] + moy_ue = cur_moy_ue + is_capitalized = False # si l'UE prise en compte est une UE capitalisée + # s'il y a precedemment une UE capitalisée (pas forcement meilleure): + was_capitalized = False + if etudid in self.validations.ue_capitalisees.index: + ue_cap = self._get_etud_ue_cap(etudid, ue) + if ue_cap and not np.isnan(ue_cap["moy_ue"]): + was_capitalized = True + if ue_cap["moy_ue"] > cur_moy_ue or np.isnan(cur_moy_ue): + moy_ue = ue_cap["moy_ue"] + is_capitalized = True + + # Coef l'UE dans le semestre courant: + if self.is_apc: + # utilise les ECTS comme coef. + coef_ue = ue.ects + else: + # formations classiques + coef_ue = self.etud_coef_ue_df[ue_id][etudid] + if (not coef_ue) and is_capitalized: # étudiant non inscrit dans l'UE courante + if self.is_apc: + # Coefs de l'UE capitalisée en formation APC: donné par ses ECTS + ue_capitalized = UniteEns.query.get(ue_cap["ue_id"]) + coef_ue = ue_capitalized.ects + if coef_ue is None: + orig_sem = FormSemestre.query.get(ue_cap["formsemestre_id"]) + raise ScoValueError( + f"""L'UE capitalisée {ue_capitalized.acronyme} + du semestre {orig_sem.titre_annee()} + n'a pas d'indication d'ECTS. + Corrigez ou faite corriger le programme + via cette page. + """ + ) + else: + # Coefs de l'UE capitalisée en formation classique: + # va chercher le coef dans le semestre d'origine + coef_ue = ModuleImplInscription.sum_coefs_modimpl_ue( + ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"] + ) + + return { + "is_capitalized": is_capitalized, + "was_capitalized": was_capitalized, + "is_external": ue_cap["is_external"] if is_capitalized else ue.is_external, + "coef_ue": coef_ue, + "ects_pot": ue.ects or 0.0, + "ects": self.validations.decisions_jury_ues.get(etudid, {}) + .get(ue.id, {}) + .get("ects", 0.0) + if self.validations.decisions_jury_ues + else 0.0, + "ects_ue": ue.ects, + "cur_moy_ue": cur_moy_ue, + "moy": moy_ue, + "event_date": ue_cap["event_date"] if is_capitalized else None, + "ue": ue.to_dict(), + "formsemestre_id": ue_cap["formsemestre_id"] if is_capitalized else None, + "capitalized_ue_id": ue_cap["ue_id"] if is_capitalized else None, + } + + def compute_etud_ue_coef(self, etudid: int, ue: UniteEns) -> float: + "Détermine le coefficient de l'UE pour cet étudiant." + # calcul différent en classique et BUT + raise NotImplementedError() + + def get_etud_ue_cap_coef(self, etudid, ue, ue_cap): # UNUSED in ScoDoc 9 + """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' } + """ + # 1- Coefficient explicitement déclaré dans le semestre courant pour cette UE ? + ue_coef_db = FormSemestreUECoef.query.filter_by( + formsemestre_id=self.formsemestre.id, ue_id=ue.id + ).first() + if ue_coef_db is not None: + return ue_coef_db.coefficient + + # En APC: somme des coefs des modules vers cette UE + # En classique: Capitalisation UE externe: quel coef appliquer ? + # En ScoDoc 7, calculait la somme des coefs dans l'UE du semestre d'origine + # ici si l'étudiant est inscrit dans le semestre courant, + # somme des coefs des modules de l'UE auxquels il est inscrit + return self.compute_etud_ue_coef(etudid, ue) + + # --- TABLEAU RECAP + + def get_table_recap( + self, + convert_values=False, + include_evaluations=False, + mode_jury=False, + allow_html=True, + ): + """Table récap. des résultats. + allow_html: si vri, peut-mettre du HTML dans les valeurs + + Result: tuple avec + - rows: liste de dicts { column_id : value } + - titles: { column_id : title } + - columns_ids: (liste des id de colonnes) + + Si convert_values, transforme les notes en chaines ("12.34"). + Les colonnes générées sont: + etudid + rang : rang indicatif (basé sur moy gen) + moy_gen : moy gen indicative + moy_ue_, ..., les moyennes d'UE + moy_res__, ... les moyennes de ressources dans l'UE + moy_sae__, ... les moyennes de SAE dans l'UE + + On ajoute aussi des attributs: + - pour les lignes: + _css_row_class (inutilisé pour le monent) + __class classe css: + - la moyenne générale a la classe col_moy_gen + - les colonnes SAE ont la classe col_sae + - les colonnes Resources ont la classe col_res + - les colonnes d'UE ont la classe col_ue + - les colonnes de modules (SAE ou res.) d'une UE ont la classe mod_ue_ + __order : clé de tri + """ + if convert_values: + fmt_note = scu.fmt_note + else: + fmt_note = lambda x: x + + parcours = self.formsemestre.formation.get_parcours() + barre_moy = parcours.BARRE_MOY - scu.NOTES_TOLERANCE + barre_valid_ue = parcours.NOTES_BARRE_VALID_UE + barre_warning_ue = parcours.BARRE_UE_DISPLAY_WARNING + NO_NOTE = "-" # contenu des cellules sans notes + rows = [] + # column_id : title + titles = {} + # les titres en footer: les mêmes, mais avec des bulles et liens: + titles_bot = {} + dict_nom_res = {} # cache uid : nomcomplet + + def add_cell( + row: dict, + col_id: str, + title: str, + content: str, + classes: str = "", + idx: int = 100, + ): + "Add a row to our table. classes is a list of css class names" + row[col_id] = content + if classes: + row[f"_{col_id}_class"] = classes + f" c{idx}" + if not col_id in titles: + titles[col_id] = title + titles[f"_{col_id}_col_order"] = idx + if classes: + titles[f"_{col_id}_class"] = classes + return idx + 1 + + etuds_inscriptions = self.formsemestre.etuds_inscriptions + ues = self.formsemestre.query_ues(with_sport=True) # avec bonus + ues_sans_bonus = [ue for ue in ues if ue.type != UE_SPORT] + modimpl_ids = set() # modimpl effectivement présents dans la table + for etudid in etuds_inscriptions: + idx = 0 # index de la colonne + etud = Identite.query.get(etudid) + row = {"etudid": etudid} + # --- Codes (seront cachés, mais exportés en excel) + idx = add_cell(row, "etudid", "etudid", etudid, "codes", idx) + idx = add_cell( + row, "code_nip", "code_nip", etud.code_nip or "", "codes", idx + ) + # --- Rang + idx = add_cell( + row, "rang", "Rg", self.etud_moy_gen_ranks[etudid], "rang", idx + ) + row["_rang_order"] = f"{self.etud_moy_gen_ranks_int[etudid]:05d}" + # --- Identité étudiant + idx = add_cell( + row, "civilite_str", "Civ.", etud.civilite_str, "identite_detail", idx + ) + idx = add_cell( + row, "nom_disp", "Nom", etud.nom_disp(), "identite_detail", idx + ) + row["_nom_disp_order"] = etud.sort_key + idx = add_cell(row, "prenom", "Prénom", etud.prenom, "identite_detail", idx) + idx = add_cell( + row, "nom_short", "Nom", etud.nom_short, "identite_court", idx + ) + row["_nom_short_order"] = etud.sort_key + row["_nom_short_target"] = url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + formsemestre_id=self.formsemestre.id, + etudid=etudid, + ) + row["_nom_short_target_attrs"] = f'class="etudinfo" id="{etudid}"' + row["_nom_disp_target"] = row["_nom_short_target"] + row["_nom_disp_target_attrs"] = row["_nom_short_target_attrs"] + + idx = 30 # début des colonnes de notes + # --- Moyenne générale + moy_gen = self.etud_moy_gen.get(etudid, False) + note_class = "" + if moy_gen is False: + moy_gen = NO_NOTE + elif isinstance(moy_gen, float) and moy_gen < barre_moy: + note_class = " moy_ue_warning" # en rouge + idx = add_cell( + row, + "moy_gen", + "Moy", + fmt_note(moy_gen), + "col_moy_gen" + note_class, + idx, + ) + titles_bot["_moy_gen_target_attrs"] = ( + 'title="moyenne indicative"' if self.is_apc else "" + ) + # --- Moyenne d'UE + nb_ues_validables, nb_ues_warning = 0, 0 + for ue in ues_sans_bonus: + ue_status = self.get_etud_ue_status(etudid, ue.id) + if ue_status is not None: + col_id = f"moy_ue_{ue.id}" + val = ue_status["moy"] + note_class = "" + if isinstance(val, float): + if val < barre_moy: + note_class = " moy_inf" + elif val >= barre_valid_ue: + note_class = " moy_ue_valid" + nb_ues_validables += 1 + if val < barre_warning_ue: + note_class = " moy_ue_warning" # notes très basses + nb_ues_warning += 1 + idx = add_cell( + row, + col_id, + ue.acronyme, + fmt_note(val), + "col_ue" + note_class, + idx, + ) + titles_bot[ + f"_{col_id}_target_attrs" + ] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """ + if mode_jury: + # pas d'autre colonnes de résultats + continue + # Bonus (sport) dans cette UE ? + # Le bonus sport appliqué sur cette UE + if (self.bonus_ues is not None) and (ue.id in self.bonus_ues): + val = self.bonus_ues[ue.id][etud.id] or "" + val_fmt = val_fmt_html = fmt_note(val) + if val: + val_fmt_html = f'{val_fmt}' + idx = add_cell( + row, + f"bonus_ue_{ue.id}", + f"Bonus {ue.acronyme}", + val_fmt_html if allow_html else val_fmt, + "col_ue_bonus", + idx, + ) + row[f"_bonus_ue_{ue.id}_xls"] = val_fmt + # Les moyennes des modules (ou ressources et SAÉs) dans cette UE + idx_malus = idx # place pour colonne malus à gauche des modules + idx += 1 + for modimpl in self.modimpls_in_ue(ue, etudid, with_bonus=False): + if ue_status["is_capitalized"]: + val = "-c-" + else: + modimpl_results = self.modimpls_results.get(modimpl.id) + if modimpl_results: # pas bonus + if self.is_apc: # BUT + moys_vers_ue = modimpl_results.etuds_moy_module.get( + ue.id + ) + val = ( + moys_vers_ue.get(etudid, "?") + if moys_vers_ue is not None + else "" + ) + else: # classique: Series indépendante de l'UE + val = modimpl_results.etuds_moy_module.get( + etudid, "?" + ) + else: + val = "" + + col_id = ( + f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + ) + val_fmt = val_fmt_html = fmt_note(val) + if convert_values and ( + modimpl.module.module_type == scu.ModuleType.MALUS + ): + val_fmt_html = ( + (scu.EMO_RED_TRIANGLE_DOWN + val_fmt) if val else "" + ) + idx = add_cell( + row, + col_id, + modimpl.module.code, + val_fmt_html, + # class col_res mod_ue_123 + f"col_{modimpl.module.type_abbrv()} mod_ue_{ue.id}", + idx, + ) + row[f"_{col_id}_xls"] = val_fmt + if modimpl.module.module_type == scu.ModuleType.MALUS: + titles[f"_{col_id}_col_order"] = idx_malus + titles_bot[f"_{col_id}_target"] = url_for( + "notes.moduleimpl_status", + scodoc_dept=g.scodoc_dept, + moduleimpl_id=modimpl.id, + ) + nom_resp = dict_nom_res.get(modimpl.responsable_id) + if nom_resp is None: + user = User.query.get(modimpl.responsable_id) + nom_resp = user.get_nomcomplet() if user else "" + dict_nom_res[modimpl.responsable_id] = nom_resp + titles_bot[ + f"_{col_id}_target_attrs" + ] = f""" title="{modimpl.module.titre} ({nom_resp})" """ + modimpl_ids.add(modimpl.id) + nb_ues_etud_parcours = len(self.etud_ues_ids(etudid)) + ue_valid_txt = ( + ue_valid_txt_html + ) = f"{nb_ues_validables}/{nb_ues_etud_parcours}" + if nb_ues_warning: + ue_valid_txt_html += " " + scu.EMO_WARNING + add_cell( + row, + "ues_validables", + "UEs", + ue_valid_txt_html, + "col_ue col_ues_validables", + 29, # juste avant moy. gen. + ) + row["_ues_validables_xls"] = ue_valid_txt + if nb_ues_warning: + row["_ues_validables_class"] += " moy_ue_warning" + elif nb_ues_validables < len(ues_sans_bonus): + row["_ues_validables_class"] += " moy_inf" + row["_ues_validables_order"] = nb_ues_validables # pour tri + if mode_jury and self.validations: + if self.is_apc: + # formations BUT: pas de code semestre, concatene ceux des UE + dec_ues = self.validations.decisions_jury_ues.get(etudid) + if dec_ues: + jury_code_sem = ",".join( + [dec_ues[ue_id].get("code", "") for ue_id in dec_ues] + ) + else: + jury_code_sem = "" + else: + # formations classiqes: code semestre + dec_sem = self.validations.decisions_jury.get(etudid) + jury_code_sem = dec_sem["code"] if dec_sem else "" + idx = add_cell( + row, + "jury_code_sem", + "Jury", + jury_code_sem, + "jury_code_sem", + 1000, + ) + idx = add_cell( + row, + "jury_link", + "", + f"""{"saisir" if not jury_code_sem else "modifier"} décision""", + "col_jury_link", + idx, + ) + rows.append(row) + + self.recap_add_partitions(rows, titles) + self._recap_add_admissions(rows, titles) + + # tri par rang croissant + rows.sort(key=lambda e: e["_rang_order"]) + + # INFOS POUR FOOTER + bottom_infos = self._recap_bottom_infos(ues_sans_bonus, modimpl_ids, fmt_note) + if include_evaluations: + self._recap_add_evaluations(rows, titles, bottom_infos) + + # Ajoute style "col_empty" aux colonnes de modules vides + for col_id in titles: + c_class = f"_{col_id}_class" + if "col_empty" in bottom_infos["moy"].get(c_class, ""): + for row in rows: + row[c_class] = row.get(c_class, "") + " col_empty" + titles[c_class] += " col_empty" + for row in bottom_infos.values(): + row[c_class] = row.get(c_class, "") + " col_empty" + + # --- TABLE FOOTER: ECTS, moyennes, min, max... + footer_rows = [] + for (bottom_line, row) in bottom_infos.items(): + # Cases vides à styler: + row["moy_gen"] = row.get("moy_gen", "") + row["_moy_gen_class"] = "col_moy_gen" + # titre de la ligne: + row["prenom"] = row["nom_short"] = ( + row.get("_title", "") or bottom_line.capitalize() + ) + row["_tr_class"] = bottom_line.lower() + ( + (" " + row["_tr_class"]) if "_tr_class" in row else "" + ) + footer_rows.append(row) + titles_bot.update(titles) + footer_rows.append(titles_bot) + column_ids = [title for title in titles if not title.startswith("_")] + column_ids.sort( + key=lambda col_id: titles.get("_" + col_id + "_col_order", 1000) + ) + return (rows, footer_rows, titles, column_ids) + + def _recap_bottom_infos(self, ues, modimpl_ids: set, fmt_note) -> dict: + """Les informations à mettre en bas de la table: min, max, moy, ECTS""" + row_min, row_max, row_moy, row_coef, row_ects, row_apo = ( + {"_tr_class": "bottom_info", "_title": "Min."}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info"}, + {"_tr_class": "bottom_info", "_title": "Code Apogée"}, + ) + # --- ECTS + for ue in ues: + colid = f"moy_ue_{ue.id}" + row_ects[colid] = ue.ects + row_ects[f"_{colid}_class"] = "col_ue" + # style cases vides pour borders verticales + row_coef[colid] = "" + row_coef[f"_{colid}_class"] = "col_ue" + # row_apo[colid] = ue.code_apogee or "" + row_ects["moy_gen"] = sum([ue.ects or 0 for ue in ues if ue.type != UE_SPORT]) + row_ects["_moy_gen_class"] = "col_moy_gen" + + # --- MIN, MAX, MOY, APO + + row_min["moy_gen"] = fmt_note(self.etud_moy_gen.min()) + row_max["moy_gen"] = fmt_note(self.etud_moy_gen.max()) + row_moy["moy_gen"] = fmt_note(self.etud_moy_gen.mean()) + for ue in ues: + colid = f"moy_ue_{ue.id}" + row_min[colid] = fmt_note(self.etud_moy_ue[ue.id].min()) + row_max[colid] = fmt_note(self.etud_moy_ue[ue.id].max()) + row_moy[colid] = fmt_note(self.etud_moy_ue[ue.id].mean()) + row_min[f"_{colid}_class"] = "col_ue" + row_max[f"_{colid}_class"] = "col_ue" + row_moy[f"_{colid}_class"] = "col_ue" + row_apo[colid] = ue.code_apogee or "" + + for modimpl in self.formsemestre.modimpls_sorted: + if modimpl.id in modimpl_ids: + colid = f"moy_{modimpl.module.type_abbrv()}_{modimpl.id}_{ue.id}" + if self.is_apc: + coef = self.modimpl_coefs_df[modimpl.id][ue.id] + else: + coef = modimpl.module.coefficient or 0 + row_coef[colid] = fmt_note(coef) + notes = self.modimpl_notes(modimpl.id, ue.id) + if np.isnan(notes).all(): + # aucune note valide + row_min[colid] = np.nan + row_max[colid] = np.nan + moy = np.nan + else: + row_min[colid] = fmt_note(np.nanmin(notes)) + row_max[colid] = fmt_note(np.nanmax(notes)) + moy = np.nanmean(notes) + row_moy[colid] = fmt_note(moy) + if np.isnan(moy): + # aucune note dans ce module + row_moy[f"_{colid}_class"] = "col_empty" + row_apo[colid] = modimpl.module.code_apogee or "" + + return { # { key : row } avec key = min, max, moy, coef + "min": row_min, + "max": row_max, + "moy": row_moy, + "coef": row_coef, + "ects": row_ects, + "apo": row_apo, + } + + def _recap_etud_groups_infos( + self, etudid: int, row: dict, titles: dict + ): # XXX non utilisé + """Table recap: ajoute à row les colonnes sur les groupes pour cet etud""" + # dec = self.get_etud_decision_sem(etudid) + # if dec: + # codes_nb[dec["code"]] += 1 + row_class = "" + etud_etat = self.get_etud_etat(etudid) + if etud_etat == DEM: + gr_name = "Dém." + row_class = "dem" + elif etud_etat == DEF: + gr_name = "Déf." + row_class = "def" + else: + # XXX probablement à revoir pour utiliser données cachées, + # via get_etud_groups_in_partition ou autre + group = sco_groups.get_etud_main_group(etudid, self.formsemestre.id) + gr_name = group["group_name"] or "" + row["group"] = gr_name + row["_group_class"] = "group" + if row_class: + row["_tr_class"] = " ".join([row.get("_tr_class", ""), row_class]) + titles["group"] = "Gr" + + def _recap_add_admissions(self, rows: list[dict], titles: dict): + """Ajoute les colonnes "admission" + rows est une liste de dict avec une clé "etudid" + Les colonnes ont la classe css "admission" + """ + fields = { + "bac": "Bac", + "specialite": "Spécialité", + "type_admission": "Type Adm.", + "classement": "Rg. Adm.", + } + first = True + for i, cid in enumerate(fields): + titles[f"_{cid}_col_order"] = 10000 + i # tout à droite + if first: + titles[f"_{cid}_class"] = "admission admission_first" + first = False + else: + titles[f"_{cid}_class"] = "admission" + titles.update(fields) + for row in rows: + etud = Identite.query.get(row["etudid"]) + admission = etud.admission.first() + first = True + for cid in fields: + row[cid] = getattr(admission, cid) or "" + if first: + row[f"_{cid}_class"] = "admission admission_first" + first = False + else: + row[f"_{cid}_class"] = "admission" + + def recap_add_partitions(self, rows: list[dict], titles: dict, col_idx: int = None): + """Ajoute les colonnes indiquant les groupes + rows est une liste de dict avec une clé "etudid" + Les colonnes ont la classe css "partition" + """ + partitions, partitions_etud_groups = sco_groups.get_formsemestre_groups( + self.formsemestre.id + ) + first_partition = True + col_order = 10 if col_idx is None else col_idx + for partition in partitions: + cid = f"part_{partition['partition_id']}" + rg_cid = cid + "_rg" # rang dans la partition + titles[cid] = partition["partition_name"] + if first_partition: + klass = "partition" + else: + klass = "partition partition_aux" + titles[f"_{cid}_class"] = klass + titles[f"_{cid}_col_order"] = col_order + titles[f"_{rg_cid}_col_order"] = col_order + 1 + col_order += 2 + if partition["bul_show_rank"]: + titles[rg_cid] = f"Rg {partition['partition_name']}" + titles[f"_{rg_cid}_class"] = "partition_rangs" + partition_etud_groups = partitions_etud_groups[partition["partition_id"]] + for row in rows: + group = None # group (dict) de l'étudiant dans cette partition + # dans NotesTableCompat, à revoir + etud_etat = self.get_etud_etat(row["etudid"]) + if etud_etat == scu.DEMISSION: + gr_name = "Dém." + row["_tr_class"] = "dem" + elif etud_etat == DEF: + gr_name = "Déf." + row["_tr_class"] = "def" + else: + group = partition_etud_groups.get(row["etudid"]) + gr_name = group["group_name"] if group else "" + if gr_name: + row[cid] = gr_name + row[f"_{cid}_class"] = klass + # Rangs dans groupe + if ( + partition["bul_show_rank"] + and (group is not None) + and (group["id"] in self.moy_gen_rangs_by_group) + ): + rang = self.moy_gen_rangs_by_group[group["id"]][0] + row[rg_cid] = rang.get(row["etudid"], "") + + first_partition = False + + def _recap_add_evaluations( + self, rows: list[dict], titles: dict, bottom_infos: dict + ): + """Ajoute les colonnes avec les notes aux évaluations + rows est une liste de dict avec une clé "etudid" + Les colonnes ont la classe css "evaluation" + """ + # nouvelle ligne pour description évaluations: + bottom_infos["descr_evaluation"] = { + "_tr_class": "bottom_info", + "_title": "Description évaluation", + } + first_eval = True + index_col = 9000 # à droite + for modimpl in self.formsemestre.modimpls_sorted: + evals = self.modimpls_results[modimpl.id].get_evaluations_completes(modimpl) + eval_index = len(evals) - 1 + inscrits = {i.etudid for i in modimpl.inscriptions} + first_eval_of_mod = True + for e in evals: + cid = f"eval_{e.id}" + titles[ + cid + ] = f'{modimpl.module.code} {eval_index} {e.jour.isoformat() if e.jour else ""}' + klass = "evaluation" + if first_eval: + klass += " first" + elif first_eval_of_mod: + klass += " first_of_mod" + titles[f"_{cid}_class"] = klass + first_eval_of_mod = first_eval = False + titles[f"_{cid}_col_order"] = index_col + index_col += 1 + eval_index -= 1 + notes_db = sco_evaluation_db.do_evaluation_get_all_notes( + e.evaluation_id + ) + for row in rows: + etudid = row["etudid"] + if etudid in inscrits: + if etudid in notes_db: + val = notes_db[etudid]["value"] + else: + # Note manquante mais prise en compte immédiate: affiche ATT + val = scu.NOTES_ATTENTE + row[cid] = scu.fmt_note(val) + row[f"_{cid}_class"] = klass + { + "ABS": " abs", + "ATT": " att", + "EXC": " exc", + }.get(row[cid], "") + else: + row[cid] = "ni" + row[f"_{cid}_class"] = klass + " non_inscrit" + + bottom_infos["coef"][cid] = e.coefficient + bottom_infos["min"][cid] = "0" + bottom_infos["max"][cid] = scu.fmt_note(e.note_max) + bottom_infos["descr_evaluation"][cid] = e.description or "" + bottom_infos["descr_evaluation"][f"_{cid}_target"] = url_for( + "notes.evaluation_listenotes", + scodoc_dept=g.scodoc_dept, + evaluation_id=e.id, + ) diff --git a/app/models/validations.py b/app/models/validations.py index c03de65e..fb470b97 100644 --- a/app/models/validations.py +++ b/app/models/validations.py @@ -1,163 +1,171 @@ -# -*- coding: UTF-8 -* - -"""Notes, décisions de jury, évènements scolaires -""" - -from app import db -from app.models import SHORT_STR_LEN -from app.models import CODE_STR_LEN -from app.models.events import Scolog - - -class ScolarFormSemestreValidation(db.Model): - """Décisions de jury""" - - __tablename__ = "scolar_formsemestre_validation" - # Assure unicité de la décision: - __table_args__ = (db.UniqueConstraint("etudid", "formsemestre_id", "ue_id"),) - - id = db.Column(db.Integer, primary_key=True) - formsemestre_validation_id = db.synonym("id") - etudid = db.Column( - db.Integer, - db.ForeignKey("identite.id", ondelete="CASCADE"), - index=True, - ) - formsemestre_id = db.Column( - db.Integer, - db.ForeignKey("notes_formsemestre.id"), - index=True, - ) - ue_id = db.Column( - db.Integer, - db.ForeignKey("notes_ue.id"), - index=True, - ) - code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True) - # NULL pour les UE, True|False pour les semestres: - assidu = db.Column(db.Boolean) - event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) - # NULL sauf si compense un semestre: (pas utilisé pour BUT) - compense_formsemestre_id = db.Column( - db.Integer, - db.ForeignKey("notes_formsemestre.id"), - ) - moy_ue = db.Column(db.Float) - # (normalement NULL) indice du semestre, utile seulement pour - # UE "antérieures" et si la formation définit des UE utilisées - # dans plusieurs semestres (cas R&T IUTV v2) - semestre_id = db.Column(db.Integer) - # Si UE validée dans le cursus d'un autre etablissement - is_external = db.Column( - db.Boolean, default=False, server_default="false", index=True - ) - - ue = db.relationship("UniteEns", lazy="select", uselist=False) - - def __repr__(self): - return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})" - - def to_dict(self) -> dict: - d = dict(self.__dict__) - d.pop("_sa_instance_state", None) - return d - - -class ScolarAutorisationInscription(db.Model): - """Autorisation d'inscription dans un semestre""" - - __tablename__ = "scolar_autorisation_inscription" - id = db.Column(db.Integer, primary_key=True) - autorisation_inscription_id = db.synonym("id") - - etudid = db.Column( - db.Integer, - db.ForeignKey("identite.id", ondelete="CASCADE"), - ) - formation_code = db.Column(db.String(SHORT_STR_LEN), nullable=False) - # Indice du semestre où on peut s'inscrire: - semestre_id = db.Column(db.Integer) - date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) - origin_formsemestre_id = db.Column( - db.Integer, - db.ForeignKey("notes_formsemestre.id"), - ) - - def to_dict(self) -> dict: - d = dict(self.__dict__) - d.pop("_sa_instance_state", None) - return d - - @classmethod - def autorise_etud( - cls, - etudid: int, - formation_code: str, - origin_formsemestre_id: int, - semestre_id: int, - ): - """Enregistre une autorisation, remplace celle émanant du même semestre si elle existe.""" - cls.delete_autorisation_etud(etudid, origin_formsemestre_id) - autorisation = cls( - etudid=etudid, - formation_code=formation_code, - origin_formsemestre_id=origin_formsemestre_id, - semestre_id=semestre_id, - ) - db.session.add(autorisation) - Scolog.logdb("autorise_etud", etudid=etudid, msg=f"passage vers S{semestre_id}") - - @classmethod - def delete_autorisation_etud( - cls, - etudid: int, - origin_formsemestre_id: int, - ): - """Efface les autorisations de cette étudiant venant du sem. origine""" - autorisations = cls.query.filter_by( - etudid=etudid, origin_formsemestre_id=origin_formsemestre_id - ) - for autorisation in autorisations: - db.session.delete(autorisation) - Scolog.logdb( - "autorise_etud", - etudid=etudid, - msg=f"annule passage vers S{autorisation.semestre_id}", - ) - db.session.flush() - - -class ScolarEvent(db.Model): - """Evenement dans le parcours scolaire d'un étudiant""" - - __tablename__ = "scolar_events" - id = db.Column(db.Integer, primary_key=True) - event_id = db.synonym("id") - etudid = db.Column( - db.Integer, - db.ForeignKey("identite.id", ondelete="CASCADE"), - ) - event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) - formsemestre_id = db.Column( - db.Integer, - db.ForeignKey("notes_formsemestre.id"), - ) - ue_id = db.Column( - db.Integer, - db.ForeignKey("notes_ue.id"), - ) - # 'CREATION', 'INSCRIPTION', 'DEMISSION', - # 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM' - # 'ECHEC_SEM' - # 'UTIL_COMPENSATION' - event_type = db.Column(db.String(SHORT_STR_LEN)) - # Semestre compensé par formsemestre_id: - comp_formsemestre_id = db.Column( - db.Integer, - db.ForeignKey("notes_formsemestre.id"), - ) - - def to_dict(self) -> dict: - d = dict(self.__dict__) - d.pop("_sa_instance_state", None) - return d +# -*- coding: UTF-8 -* + +"""Notes, décisions de jury, évènements scolaires +""" + +from app import db +from app.models import SHORT_STR_LEN +from app.models import CODE_STR_LEN +from app.models.events import Scolog + + +class ScolarFormSemestreValidation(db.Model): + """Décisions de jury""" + + __tablename__ = "scolar_formsemestre_validation" + # Assure unicité de la décision: + __table_args__ = (db.UniqueConstraint("etudid", "formsemestre_id", "ue_id"),) + + id = db.Column(db.Integer, primary_key=True) + formsemestre_validation_id = db.synonym("id") + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + index=True, + ) + formsemestre_id = db.Column( + db.Integer, + db.ForeignKey("notes_formsemestre.id"), + index=True, + ) + ue_id = db.Column( + db.Integer, + db.ForeignKey("notes_ue.id"), + index=True, + ) + code = db.Column(db.String(CODE_STR_LEN), nullable=False, index=True) + # NULL pour les UE, True|False pour les semestres: + assidu = db.Column(db.Boolean) + event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + # NULL sauf si compense un semestre: (pas utilisé pour BUT) + compense_formsemestre_id = db.Column( + db.Integer, + db.ForeignKey("notes_formsemestre.id"), + ) + moy_ue = db.Column(db.Float) + # (normalement NULL) indice du semestre, utile seulement pour + # UE "antérieures" et si la formation définit des UE utilisées + # dans plusieurs semestres (cas R&T IUTV v2) + semestre_id = db.Column(db.Integer) + # Si UE validée dans le cursus d'un autre etablissement + is_external = db.Column( + db.Boolean, default=False, server_default="false", index=True + ) + + ue = db.relationship("UniteEns", lazy="select", uselist=False) + + def __repr__(self): + return f"{self.__class__.__name__}({self.formsemestre_id}, {self.etudid}, code={self.code}, ue={self.ue}, moy_ue={self.moy_ue})" + + def to_dict(self) -> dict: + d = dict(self.__dict__) + d.pop("_sa_instance_state", None) + return d + + +class ScolarAutorisationInscription(db.Model): + """Autorisation d'inscription dans un semestre""" + + __tablename__ = "scolar_autorisation_inscription" + id = db.Column(db.Integer, primary_key=True) + autorisation_inscription_id = db.synonym("id") + + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + ) + formation_code = db.Column(db.String(SHORT_STR_LEN), nullable=False) + # Indice du semestre où on peut s'inscrire: + semestre_id = db.Column(db.Integer) + date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + origin_formsemestre_id = db.Column( + db.Integer, + db.ForeignKey("notes_formsemestre.id"), + ) + + def to_dict(self) -> dict: + d = dict(self.__dict__) + d.pop("_sa_instance_state", None) + return d + + @classmethod + def autorise_etud( + cls, + etudid: int, + formation_code: str, + origin_formsemestre_id: int, + semestre_id: int, + ): + """Enregistre une autorisation, remplace celle émanant du même semestre si elle existe.""" + cls.delete_autorisation_etud(etudid, origin_formsemestre_id) + autorisation = cls( + etudid=etudid, + formation_code=formation_code, + origin_formsemestre_id=origin_formsemestre_id, + semestre_id=semestre_id, + ) + db.session.add(autorisation) + Scolog.logdb("autorise_etud", etudid=etudid, msg=f"passage vers S{semestre_id}") + + @classmethod + def delete_autorisation_etud( + cls, + etudid: int, + origin_formsemestre_id: int, + ): + """Efface les autorisations de cette étudiant venant du sem. origine""" + autorisations = cls.query.filter_by( + etudid=etudid, origin_formsemestre_id=origin_formsemestre_id + ) + for autorisation in autorisations: + db.session.delete(autorisation) + Scolog.logdb( + "autorise_etud", + etudid=etudid, + msg=f"annule passage vers S{autorisation.semestre_id}", + ) + db.session.flush() + + +class ScolarEvent(db.Model): + """Evenement dans le parcours scolaire d'un étudiant""" + + __tablename__ = "scolar_events" + id = db.Column(db.Integer, primary_key=True) + event_id = db.synonym("id") + etudid = db.Column( + db.Integer, + db.ForeignKey("identite.id", ondelete="CASCADE"), + ) + event_date = db.Column(db.DateTime(timezone=True), server_default=db.func.now()) + formsemestre_id = db.Column( + db.Integer, + db.ForeignKey("notes_formsemestre.id"), + ) + ue_id = db.Column( + db.Integer, + db.ForeignKey("notes_ue.id"), + ) + # 'CREATION', 'INSCRIPTION', 'DEMISSION', + # 'AUT_RED', 'EXCLUS', 'VALID_UE', 'VALID_SEM' + # 'ECHEC_SEM' + # 'UTIL_COMPENSATION' + event_type = db.Column(db.String(SHORT_STR_LEN)) + # Semestre compensé par formsemestre_id: + comp_formsemestre_id = db.Column( + db.Integer, + db.ForeignKey("notes_formsemestre.id"), + ) + etud = db.relationship("Identite", lazy="select", backref="events", uselist=False) + formsemestre = db.relationship( + "FormSemestre", lazy="select", uselist=False, foreign_keys=[formsemestre_id] + ) + + def to_dict(self) -> dict: + "as a dict" + d = dict(self.__dict__) + d.pop("_sa_instance_state", None) + return d + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.event_type}, {self.event_date.isoformat()}, {self.formsemestre})" diff --git a/app/pe/pe_semestretag.py b/app/pe/pe_semestretag.py index e61ad90d..bed33465 100644 --- a/app/pe/pe_semestretag.py +++ b/app/pe/pe_semestretag.py @@ -1,507 +1,508 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -############################################################################## -# Module "Avis de poursuite d'étude" -# conçu et développé par Cléo Baras (IUT de Grenoble) -############################################################################## - -""" -Created on Fri Sep 9 09:15:05 2016 - -@author: barasc -""" - -from app import log -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre -from app.models.moduleimpls import ModuleImpl - -from app.scodoc import sco_codes_parcours -from app.scodoc import sco_tag_module -from app.pe import pe_tagtable - - -class SemestreTag(pe_tagtable.TableTag): - """Un SemestreTag représente un tableau de notes (basé sur notesTable) - modélisant les résultats des étudiants sous forme de moyennes par tag. - - Attributs récupérés via des NotesTables : - - nt: le tableau de notes du semestre considéré - - nt.inscrlist: étudiants inscrits à ce semestre, par ordre alphabétique (avec demissions) - - nt.identdict: { etudid : ident } - - liste des moduleimpl { ... 'module_id', ...} - - Attributs supplémentaires : - - inscrlist/identdict: étudiants inscrits hors démissionnaires ou défaillants - - _tagdict : Dictionnaire résumant les tags et les modules du semestre auxquels ils sont liés - - - Attributs hérités de TableTag : - - nom : - - resultats: {tag: { etudid: (note_moy, somme_coff), ...} , ...} - - rang - - statistiques - - Redéfinition : - - get_etudids() : les etudids des étudiants non défaillants ni démissionnaires - """ - - DEBUG = True - - # ----------------------------------------------------------------------------- - # Fonctions d'initialisation - # ----------------------------------------------------------------------------- - def __init__(self, notetable, sem): # Initialisation sur la base d'une notetable - """Instantiation d'un objet SemestreTag à partir d'un tableau de note - et des informations sur le semestre pour le dater - """ - pe_tagtable.TableTag.__init__( - self, - nom="S%d %s %s-%s" - % ( - sem["semestre_id"], - "ENEPS" - if "ENEPS" in sem["titre"] - else "UFA" - if "UFA" in sem["titre"] - else "FI", - sem["annee_debut"], - sem["annee_fin"], - ), - ) - - # Les attributs spécifiques - self.nt = notetable - - # Les attributs hérités : la liste des étudiants - self.inscrlist = [ - etud - for etud in self.nt.inscrlist - if self.nt.get_etud_etat(etud["etudid"]) == "I" - ] - self.identdict = { - etudid: ident - for (etudid, ident) in self.nt.identdict.items() - if etudid in self.get_etudids() - } # Liste des étudiants non démissionnaires et non défaillants - - # Les modules pris en compte dans le calcul des moyennes par tag => ceux des UE standards - self.modimpls = [ - modimpl - for modimpl in self.nt.formsemestre.modimpls_sorted - if modimpl.module.ue.type == sco_codes_parcours.UE_STANDARD - ] # la liste des modules (objet modimpl) - self.somme_coeffs = sum( - [ - modimpl.module.coefficient - for modimpl in self.modimpls - if modimpl.module.coefficient is not None - ] - ) - - # ----------------------------------------------------------------------------- - def comp_data_semtag(self): - """Calcule tous les données numériques associées au semtag""" - # Attributs relatifs aux tag pour les modules pris en compte - self.tagdict = ( - self.do_tagdict() - ) # Dictionnaire résumant les tags et les données (normalisées) des modules du semestre auxquels ils sont liés - - # Calcul des moyennes de chaque étudiant puis ajoute la moyenne au sens "DUT" - for tag in self.tagdict: - self.add_moyennesTag(tag, self.comp_MoyennesTag(tag, force=True)) - self.add_moyennesTag("dut", self.get_moyennes_DUT()) - self.taglist = sorted( - list(self.tagdict.keys()) + ["dut"] - ) # actualise la liste des tags - - # ----------------------------------------------------------------------------- - def get_etudids(self): - """Renvoie la liste des etud_id des étudiants inscrits au semestre""" - return [etud["etudid"] for etud in self.inscrlist] - - # ----------------------------------------------------------------------------- - def do_tagdict(self): - """Parcourt les modimpl du semestre (instance des modules d'un programme) et synthétise leurs données sous la - forme d'un dictionnaire reliant les tags saisis dans le programme aux - données des modules qui les concernent, à savoir les modimpl_id, les module_id, le code du module, le coeff, - la pondération fournie avec le tag (par défaut 1 si non indiquée). - { tagname1 : { modimpl_id1 : { 'module_id' : ..., 'coeff' : ..., 'coeff_norm' : ..., 'ponderation' : ..., 'module_code' : ..., 'ue_xxx' : ...}, - modimpl_id2 : .... - }, - tagname2 : ... - } - Renvoie le dictionnaire ainsi construit. - - Rq: choix fait de repérer les modules par rapport à leur modimpl_id (valable uniquement pour un semestre), car - correspond à la majorité des calculs de moyennes pour les étudiants - (seuls ceux qui ont capitalisé des ue auront un régime de calcul différent). - """ - tagdict = {} - - for modimpl in self.modimpls: - modimpl_id = modimpl.id - # liste des tags pour le modimpl concerné: - tags = sco_tag_module.module_tag_list(modimpl.module.id) - - for ( - tag - ) in tags: # tag de la forme "mathématiques", "théorie", "pe:0", "maths:2" - [tagname, ponderation] = sco_tag_module.split_tagname_coeff( - tag - ) # extrait un tagname et un éventuel coefficient de pondération (par defaut: 1) - # tagname = tagname - if tagname not in tagdict: # Ajout d'une clé pour le tag - tagdict[tagname] = {} - - # Ajout du modimpl au tagname considéré - tagdict[tagname][modimpl_id] = { - "module_id": modimpl.module.id, # les données sur le module - "coeff": modimpl.module.coefficient, # le coeff du module dans le semestre - "ponderation": ponderation, # la pondération demandée pour le tag sur le module - "module_code": modimpl.module.code, # le code qui doit se retrouver à l'identique dans des ue capitalisee - "ue_id": modimpl.module.ue.id, # les données sur l'ue - "ue_code": modimpl.module.ue.ue_code, - "ue_acronyme": modimpl.module.ue.acronyme, - } - return tagdict - - # ----------------------------------------------------------------------------- - def comp_MoyennesTag(self, tag, force=False) -> list: - """Calcule et renvoie les "moyennes" de tous les étudiants du SemTag - (non défaillants) à un tag donné, en prenant en compte - tous les modimpl_id concerné par le tag, leur coeff et leur pondération. - Force ou non le calcul de la moyenne lorsque des notes sont manquantes. - - Renvoie les informations sous la forme d'une liste - [ (moy, somme_coeff_normalise, etudid), ...] - """ - lesMoyennes = [] - for etudid in self.get_etudids(): - ( - notes, - coeffs_norm, - ponderations, - ) = self.get_listesNotesEtCoeffsTagEtudiant( - tag, etudid - ) # les notes associées au tag - coeffs = comp_coeff_pond( - coeffs_norm, ponderations - ) # les coeff pondérés par les tags - (moyenne, somme_coeffs) = pe_tagtable.moyenne_ponderee_terme_a_terme( - notes, coeffs, force=force - ) - lesMoyennes += [ - (moyenne, somme_coeffs, etudid) - ] # Un tuple (pour classement résumant les données) - return lesMoyennes - - # ----------------------------------------------------------------------------- - def get_moyennes_DUT(self): - """Lit les moyennes DUT du semestre pour tous les étudiants - et les renvoie au même format que comp_MoyennesTag""" - return [ - (self.nt.etud_moy_gen[etudid], 1.0, etudid) for etudid in self.get_etudids() - ] - - # ----------------------------------------------------------------------------- - def get_noteEtCoeff_modimpl(self, modimpl_id, etudid, profondeur=2): - """Renvoie un couple donnant la note et le coeff normalisé d'un étudiant à un module d'id modimpl_id. - La note et le coeff sont extraits : - 1) soit des données du semestre en normalisant le coefficient par rapport à la somme des coefficients des modules du semestre, - 2) soit des données des UE précédemment capitalisées, en recherchant un module de même CODE que le modimpl_id proposé, - le coefficient normalisé l'étant alors par rapport au total des coefficients du semestre auquel appartient l'ue capitalisée - """ - (note, coeff_norm) = (None, None) - - modimpl = get_moduleimpl(modimpl_id) # Le module considéré - if modimpl == None or profondeur < 0: - return (None, None) - - # Y-a-t-il eu capitalisation d'UE ? - ue_capitalisees = self.get_ue_capitalisees( - etudid - ) # les ue capitalisées des étudiants - ue_capitalisees_id = { - ue_cap["ue_id"] for ue_cap in ue_capitalisees - } # les id des ue capitalisées - - # Si le module ne fait pas partie des UE capitalisées - if modimpl.module.ue.id not in ue_capitalisees_id: - note = self.nt.get_etud_mod_moy(modimpl_id, etudid) # lecture de la note - coeff = modimpl.module.coefficient # le coeff - coeff_norm = ( - coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0 - ) # le coeff normalisé - - # Si le module fait partie d'une UE capitalisée - elif len(ue_capitalisees) > 0: - moy_ue_actuelle = get_moy_ue_from_nt( - self.nt, etudid, modimpl_id - ) # la moyenne actuelle - # A quel semestre correspond l'ue capitalisée et quelles sont ses notes ? - fids_prec = [ - ue_cap["formsemestre_id"] - for ue_cap in ue_capitalisees - if ue_cap["ue_code"] == modimpl.module.ue.ue_code - ] # and ue['semestre_id'] == semestre_id] - if len(fids_prec) > 0: - # => le formsemestre_id du semestre dont vient la capitalisation - fid_prec = fids_prec[0] - # Lecture des notes de ce semestre - # le tableau de note du semestre considéré: - formsemestre_prec = FormSemestre.query.get_or_404(fid_prec) - nt_prec: NotesTableCompat = res_sem.load_formsemestre_results( - formsemestre_prec - ) - - # Y-a-t-il un module équivalent c'est à dire correspondant au même code (le module_id n'étant pas significatif en cas de changement de PPN) - - modimpl_prec = [ - modi - for modi in nt_prec.formsemestre.modimpls_sorted - if modi.module.code == modimpl.module.code - ] - if len(modimpl_prec) > 0: # si une correspondance est trouvée - modprec_id = modimpl_prec[0].id - moy_ue_capitalisee = get_moy_ue_from_nt(nt_prec, etudid, modprec_id) - if ( - moy_ue_capitalisee is None - ) or moy_ue_actuelle >= moy_ue_capitalisee: # on prend la meilleure ue - note = self.nt.get_etud_mod_moy( - modimpl_id, etudid - ) # lecture de la note - coeff = modimpl.module.coefficient # le coeff - coeff_norm = ( - coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0 - ) # le coeff normalisé - else: - semtag_prec = SemestreTag(nt_prec, nt_prec.sem) - (note, coeff_norm) = semtag_prec.get_noteEtCoeff_modimpl( - modprec_id, etudid, profondeur=profondeur - 1 - ) # lecture de la note via le semtag associé au modimpl capitalisé - - # Sinon - pas de notes à prendre en compte - return (note, coeff_norm) - - # ----------------------------------------------------------------------------- - def get_ue_capitalisees(self, etudid) -> list[dict]: - """Renvoie la liste des capitalisation effectivement capitalisées par un étudiant""" - if etudid in self.nt.validations.ue_capitalisees.index: - return self.nt.validations.ue_capitalisees.loc[[etudid]].to_dict("records") - return [] - - # ----------------------------------------------------------------------------- - def get_listesNotesEtCoeffsTagEtudiant(self, tag, etudid): - """Renvoie un triplet (notes, coeffs_norm, ponderations) où notes, coeff_norm et ponderation désignent trois listes - donnant -pour un tag donné- les note, coeff et ponderation de chaque modimpl à prendre en compte dans - le calcul de la moyenne du tag. - Les notes et coeff_norm sont extraits grâce à SemestreTag.get_noteEtCoeff_modimpl (donc dans semestre courant ou UE capitalisée). - Les pondérations sont celles déclarées avec le tag (cf. _tagdict).""" - - notes = [] - coeffs_norm = [] - ponderations = [] - for (moduleimpl_id, modimpl) in self.tagdict[ - tag - ].items(): # pour chaque module du semestre relatif au tag - (note, coeff_norm) = self.get_noteEtCoeff_modimpl(moduleimpl_id, etudid) - if note != None: - notes.append(note) - coeffs_norm.append(coeff_norm) - ponderations.append(modimpl["ponderation"]) - return (notes, coeffs_norm, ponderations) - - # ----------------------------------------------------------------------------- - # Fonctions d'affichage (et d'export csv) des données du semestre en mode debug - # ----------------------------------------------------------------------------- - def str_detail_resultat_d_un_tag(self, tag, etudid=None, delim=";"): - """Renvoie une chaine de caractère décrivant les résultats d'étudiants à un tag : - rappelle les notes obtenues dans les modules à prendre en compte, les moyennes et les rangs calculés. - Si etudid=None, tous les étudiants inscrits dans le semestre sont pris en compte. Sinon seuls les étudiants indiqués sont affichés.""" - # Entete - chaine = delim.join(["%15s" % "nom", "etudid"]) + delim - taglist = self.get_all_tags() - if tag in taglist: - for mod in self.tagdict[tag].values(): - chaine += mod["module_code"] + delim - chaine += ("%1.1f" % mod["ponderation"]) + delim - chaine += "coeff" + delim - chaine += delim.join( - ["moyenne", "rang", "nbinscrit", "somme_coeff", "somme_coeff"] - ) # ligne 1 - chaine += "\n" - - # Différents cas de boucles sur les étudiants (de 1 à plusieurs) - if etudid == None: - lesEtuds = self.get_etudids() - elif isinstance(etudid, str) and etudid in self.get_etudids(): - lesEtuds = [etudid] - elif isinstance(etudid, list): - lesEtuds = [eid for eid in self.get_etudids() if eid in etudid] - else: - lesEtuds = [] - - for etudid in lesEtuds: - descr = ( - "%15s" % self.nt.get_nom_short(etudid)[:15] - + delim - + str(etudid) - + delim - ) - if tag in taglist: - for modimpl_id in self.tagdict[tag]: - (note, coeff) = self.get_noteEtCoeff_modimpl(modimpl_id, etudid) - descr += ( - ( - "%2.2f" % note - if note != None and isinstance(note, float) - else str(note) - ) - + delim - + ( - "%1.5f" % coeff - if coeff != None and isinstance(coeff, float) - else str(coeff) - ) - + delim - + ( - "%1.5f" % (coeff * self.somme_coeffs) - if coeff != None and isinstance(coeff, float) - else "???" # str(coeff * self._sum_coeff_semestre) # voir avec Cléo - ) - + delim - ) - moy = self.get_moy_from_resultats(tag, etudid) - rang = self.get_rang_from_resultats(tag, etudid) - coeff = self.get_coeff_from_resultats(tag, etudid) - tot = ( - coeff * self.somme_coeffs - if coeff != None - and self.somme_coeffs != None - and isinstance(coeff, float) - else None - ) - descr += ( - pe_tagtable.TableTag.str_moytag( - moy, rang, len(self.get_etudids()), delim=delim - ) - + delim - ) - descr += ( - ( - "%1.5f" % coeff - if coeff != None and isinstance(coeff, float) - else str(coeff) - ) - + delim - + ( - "%.2f" % (tot) - if tot != None - else str(coeff) + "*" + str(self.somme_coeffs) - ) - ) - chaine += descr - chaine += "\n" - return chaine - - def str_tagsModulesEtCoeffs(self): - """Renvoie une chaine affichant la liste des tags associés au semestre, les modules qui les concernent et les coeffs de pondération. - Plus concrêtement permet d'afficher le contenu de self._tagdict""" - chaine = "Semestre %s d'id %d" % (self.nom, id(self)) + "\n" - chaine += " -> somme de coeffs: " + str(self.somme_coeffs) + "\n" - taglist = self.get_all_tags() - for tag in taglist: - chaine += " > " + tag + ": " - for (modid, mod) in self.tagdict[tag].items(): - chaine += ( - mod["module_code"] - + " (" - + str(mod["coeff"]) - + "*" - + str(mod["ponderation"]) - + ") " - + str(modid) - + ", " - ) - chaine += "\n" - return chaine - - -# ************************************************************************ -# Fonctions diverses -# ************************************************************************ - -# ********************************************* -def comp_coeff_pond(coeffs, ponderations): - """ - Applique une ponderation (indiquée dans la liste ponderations) à une liste de coefficients : - ex: coeff = [2, 3, 1, None], ponderation = [1, 2, 0, 1] => [2*1, 3*2, 1*0, None] - Les coeff peuvent éventuellement être None auquel cas None est conservé ; - Les pondérations sont des floattants - """ - if ( - coeffs == None - or ponderations == None - or not isinstance(coeffs, list) - or not isinstance(ponderations, list) - or len(coeffs) != len(ponderations) - ): - raise ValueError("Erreur de paramètres dans comp_coeff_pond") - return [ - (None if coeffs[i] == None else coeffs[i] * ponderations[i]) - for i in range(len(coeffs)) - ] - - -# ----------------------------------------------------------------------------- -def get_moduleimpl(modimpl_id) -> dict: - """Renvoie l'objet modimpl dont l'id est modimpl_id""" - modimpl = ModuleImpl.query.get(modimpl_id) - if modimpl: - return modimpl - if SemestreTag.DEBUG: - log( - "SemestreTag.get_moduleimpl( %s ) : le modimpl recherche n'existe pas" - % (modimpl_id) - ) - return None - - -# ********************************************** -def get_moy_ue_from_nt(nt, etudid, modimpl_id) -> float: - """Renvoie la moyenne de l'UE d'un etudid dans laquelle se trouve - le module de modimpl_id - """ - # ré-écrit - modimpl = get_moduleimpl(modimpl_id) # le module - ue_status = nt.get_etud_ue_status(etudid, modimpl.module.ue.id) - if ue_status is None: - return None - return ue_status["moy"] +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +############################################################################## +# Module "Avis de poursuite d'étude" +# conçu et développé par Cléo Baras (IUT de Grenoble) +############################################################################## + +""" +Created on Fri Sep 9 09:15:05 2016 + +@author: barasc +""" + +from app import log +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre +from app.models.moduleimpls import ModuleImpl +from app.pe import pe_tagtable + +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_tag_module +from app.scodoc import sco_utils as scu + + +class SemestreTag(pe_tagtable.TableTag): + """Un SemestreTag représente un tableau de notes (basé sur notesTable) + modélisant les résultats des étudiants sous forme de moyennes par tag. + + Attributs récupérés via des NotesTables : + - nt: le tableau de notes du semestre considéré + - nt.inscrlist: étudiants inscrits à ce semestre, par ordre alphabétique (avec demissions) + - nt.identdict: { etudid : ident } + - liste des moduleimpl { ... 'module_id', ...} + + Attributs supplémentaires : + - inscrlist/identdict: étudiants inscrits hors démissionnaires ou défaillants + - _tagdict : Dictionnaire résumant les tags et les modules du semestre auxquels ils sont liés + + + Attributs hérités de TableTag : + - nom : + - resultats: {tag: { etudid: (note_moy, somme_coff), ...} , ...} + - rang + - statistiques + + Redéfinition : + - get_etudids() : les etudids des étudiants non défaillants ni démissionnaires + """ + + DEBUG = True + + # ----------------------------------------------------------------------------- + # Fonctions d'initialisation + # ----------------------------------------------------------------------------- + def __init__(self, notetable, sem): # Initialisation sur la base d'une notetable + """Instantiation d'un objet SemestreTag à partir d'un tableau de note + et des informations sur le semestre pour le dater + """ + pe_tagtable.TableTag.__init__( + self, + nom="S%d %s %s-%s" + % ( + sem["semestre_id"], + "ENEPS" + if "ENEPS" in sem["titre"] + else "UFA" + if "UFA" in sem["titre"] + else "FI", + sem["annee_debut"], + sem["annee_fin"], + ), + ) + + # Les attributs spécifiques + self.nt = notetable + + # Les attributs hérités : la liste des étudiants + self.inscrlist = [ + etud + for etud in self.nt.inscrlist + if self.nt.get_etud_etat(etud["etudid"]) == scu.INSCRIT + ] + self.identdict = { + etudid: ident + for (etudid, ident) in self.nt.identdict.items() + if etudid in self.get_etudids() + } # Liste des étudiants non démissionnaires et non défaillants + + # Les modules pris en compte dans le calcul des moyennes par tag => ceux des UE standards + self.modimpls = [ + modimpl + for modimpl in self.nt.formsemestre.modimpls_sorted + if modimpl.module.ue.type == sco_codes_parcours.UE_STANDARD + ] # la liste des modules (objet modimpl) + self.somme_coeffs = sum( + [ + modimpl.module.coefficient + for modimpl in self.modimpls + if modimpl.module.coefficient is not None + ] + ) + + # ----------------------------------------------------------------------------- + def comp_data_semtag(self): + """Calcule tous les données numériques associées au semtag""" + # Attributs relatifs aux tag pour les modules pris en compte + self.tagdict = ( + self.do_tagdict() + ) # Dictionnaire résumant les tags et les données (normalisées) des modules du semestre auxquels ils sont liés + + # Calcul des moyennes de chaque étudiant puis ajoute la moyenne au sens "DUT" + for tag in self.tagdict: + self.add_moyennesTag(tag, self.comp_MoyennesTag(tag, force=True)) + self.add_moyennesTag("dut", self.get_moyennes_DUT()) + self.taglist = sorted( + list(self.tagdict.keys()) + ["dut"] + ) # actualise la liste des tags + + # ----------------------------------------------------------------------------- + def get_etudids(self): + """Renvoie la liste des etud_id des étudiants inscrits au semestre""" + return [etud["etudid"] for etud in self.inscrlist] + + # ----------------------------------------------------------------------------- + def do_tagdict(self): + """Parcourt les modimpl du semestre (instance des modules d'un programme) et synthétise leurs données sous la + forme d'un dictionnaire reliant les tags saisis dans le programme aux + données des modules qui les concernent, à savoir les modimpl_id, les module_id, le code du module, le coeff, + la pondération fournie avec le tag (par défaut 1 si non indiquée). + { tagname1 : { modimpl_id1 : { 'module_id' : ..., 'coeff' : ..., 'coeff_norm' : ..., 'ponderation' : ..., 'module_code' : ..., 'ue_xxx' : ...}, + modimpl_id2 : .... + }, + tagname2 : ... + } + Renvoie le dictionnaire ainsi construit. + + Rq: choix fait de repérer les modules par rapport à leur modimpl_id (valable uniquement pour un semestre), car + correspond à la majorité des calculs de moyennes pour les étudiants + (seuls ceux qui ont capitalisé des ue auront un régime de calcul différent). + """ + tagdict = {} + + for modimpl in self.modimpls: + modimpl_id = modimpl.id + # liste des tags pour le modimpl concerné: + tags = sco_tag_module.module_tag_list(modimpl.module.id) + + for ( + tag + ) in tags: # tag de la forme "mathématiques", "théorie", "pe:0", "maths:2" + [tagname, ponderation] = sco_tag_module.split_tagname_coeff( + tag + ) # extrait un tagname et un éventuel coefficient de pondération (par defaut: 1) + # tagname = tagname + if tagname not in tagdict: # Ajout d'une clé pour le tag + tagdict[tagname] = {} + + # Ajout du modimpl au tagname considéré + tagdict[tagname][modimpl_id] = { + "module_id": modimpl.module.id, # les données sur le module + "coeff": modimpl.module.coefficient, # le coeff du module dans le semestre + "ponderation": ponderation, # la pondération demandée pour le tag sur le module + "module_code": modimpl.module.code, # le code qui doit se retrouver à l'identique dans des ue capitalisee + "ue_id": modimpl.module.ue.id, # les données sur l'ue + "ue_code": modimpl.module.ue.ue_code, + "ue_acronyme": modimpl.module.ue.acronyme, + } + return tagdict + + # ----------------------------------------------------------------------------- + def comp_MoyennesTag(self, tag, force=False) -> list: + """Calcule et renvoie les "moyennes" de tous les étudiants du SemTag + (non défaillants) à un tag donné, en prenant en compte + tous les modimpl_id concerné par le tag, leur coeff et leur pondération. + Force ou non le calcul de la moyenne lorsque des notes sont manquantes. + + Renvoie les informations sous la forme d'une liste + [ (moy, somme_coeff_normalise, etudid), ...] + """ + lesMoyennes = [] + for etudid in self.get_etudids(): + ( + notes, + coeffs_norm, + ponderations, + ) = self.get_listesNotesEtCoeffsTagEtudiant( + tag, etudid + ) # les notes associées au tag + coeffs = comp_coeff_pond( + coeffs_norm, ponderations + ) # les coeff pondérés par les tags + (moyenne, somme_coeffs) = pe_tagtable.moyenne_ponderee_terme_a_terme( + notes, coeffs, force=force + ) + lesMoyennes += [ + (moyenne, somme_coeffs, etudid) + ] # Un tuple (pour classement résumant les données) + return lesMoyennes + + # ----------------------------------------------------------------------------- + def get_moyennes_DUT(self): + """Lit les moyennes DUT du semestre pour tous les étudiants + et les renvoie au même format que comp_MoyennesTag""" + return [ + (self.nt.etud_moy_gen[etudid], 1.0, etudid) for etudid in self.get_etudids() + ] + + # ----------------------------------------------------------------------------- + def get_noteEtCoeff_modimpl(self, modimpl_id, etudid, profondeur=2): + """Renvoie un couple donnant la note et le coeff normalisé d'un étudiant à un module d'id modimpl_id. + La note et le coeff sont extraits : + 1) soit des données du semestre en normalisant le coefficient par rapport à la somme des coefficients des modules du semestre, + 2) soit des données des UE précédemment capitalisées, en recherchant un module de même CODE que le modimpl_id proposé, + le coefficient normalisé l'étant alors par rapport au total des coefficients du semestre auquel appartient l'ue capitalisée + """ + (note, coeff_norm) = (None, None) + + modimpl = get_moduleimpl(modimpl_id) # Le module considéré + if modimpl == None or profondeur < 0: + return (None, None) + + # Y-a-t-il eu capitalisation d'UE ? + ue_capitalisees = self.get_ue_capitalisees( + etudid + ) # les ue capitalisées des étudiants + ue_capitalisees_id = { + ue_cap["ue_id"] for ue_cap in ue_capitalisees + } # les id des ue capitalisées + + # Si le module ne fait pas partie des UE capitalisées + if modimpl.module.ue.id not in ue_capitalisees_id: + note = self.nt.get_etud_mod_moy(modimpl_id, etudid) # lecture de la note + coeff = modimpl.module.coefficient # le coeff + coeff_norm = ( + coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0 + ) # le coeff normalisé + + # Si le module fait partie d'une UE capitalisée + elif len(ue_capitalisees) > 0: + moy_ue_actuelle = get_moy_ue_from_nt( + self.nt, etudid, modimpl_id + ) # la moyenne actuelle + # A quel semestre correspond l'ue capitalisée et quelles sont ses notes ? + fids_prec = [ + ue_cap["formsemestre_id"] + for ue_cap in ue_capitalisees + if ue_cap["ue_code"] == modimpl.module.ue.ue_code + ] # and ue['semestre_id'] == semestre_id] + if len(fids_prec) > 0: + # => le formsemestre_id du semestre dont vient la capitalisation + fid_prec = fids_prec[0] + # Lecture des notes de ce semestre + # le tableau de note du semestre considéré: + formsemestre_prec = FormSemestre.query.get_or_404(fid_prec) + nt_prec: NotesTableCompat = res_sem.load_formsemestre_results( + formsemestre_prec + ) + + # Y-a-t-il un module équivalent c'est à dire correspondant au même code (le module_id n'étant pas significatif en cas de changement de PPN) + + modimpl_prec = [ + modi + for modi in nt_prec.formsemestre.modimpls_sorted + if modi.module.code == modimpl.module.code + ] + if len(modimpl_prec) > 0: # si une correspondance est trouvée + modprec_id = modimpl_prec[0].id + moy_ue_capitalisee = get_moy_ue_from_nt(nt_prec, etudid, modprec_id) + if ( + moy_ue_capitalisee is None + ) or moy_ue_actuelle >= moy_ue_capitalisee: # on prend la meilleure ue + note = self.nt.get_etud_mod_moy( + modimpl_id, etudid + ) # lecture de la note + coeff = modimpl.module.coefficient # le coeff + coeff_norm = ( + coeff / self.somme_coeffs if self.somme_coeffs != 0 else 0 + ) # le coeff normalisé + else: + semtag_prec = SemestreTag(nt_prec, nt_prec.sem) + (note, coeff_norm) = semtag_prec.get_noteEtCoeff_modimpl( + modprec_id, etudid, profondeur=profondeur - 1 + ) # lecture de la note via le semtag associé au modimpl capitalisé + + # Sinon - pas de notes à prendre en compte + return (note, coeff_norm) + + # ----------------------------------------------------------------------------- + def get_ue_capitalisees(self, etudid) -> list[dict]: + """Renvoie la liste des capitalisation effectivement capitalisées par un étudiant""" + if etudid in self.nt.validations.ue_capitalisees.index: + return self.nt.validations.ue_capitalisees.loc[[etudid]].to_dict("records") + return [] + + # ----------------------------------------------------------------------------- + def get_listesNotesEtCoeffsTagEtudiant(self, tag, etudid): + """Renvoie un triplet (notes, coeffs_norm, ponderations) où notes, coeff_norm et ponderation désignent trois listes + donnant -pour un tag donné- les note, coeff et ponderation de chaque modimpl à prendre en compte dans + le calcul de la moyenne du tag. + Les notes et coeff_norm sont extraits grâce à SemestreTag.get_noteEtCoeff_modimpl (donc dans semestre courant ou UE capitalisée). + Les pondérations sont celles déclarées avec le tag (cf. _tagdict).""" + + notes = [] + coeffs_norm = [] + ponderations = [] + for (moduleimpl_id, modimpl) in self.tagdict[ + tag + ].items(): # pour chaque module du semestre relatif au tag + (note, coeff_norm) = self.get_noteEtCoeff_modimpl(moduleimpl_id, etudid) + if note != None: + notes.append(note) + coeffs_norm.append(coeff_norm) + ponderations.append(modimpl["ponderation"]) + return (notes, coeffs_norm, ponderations) + + # ----------------------------------------------------------------------------- + # Fonctions d'affichage (et d'export csv) des données du semestre en mode debug + # ----------------------------------------------------------------------------- + def str_detail_resultat_d_un_tag(self, tag, etudid=None, delim=";"): + """Renvoie une chaine de caractère décrivant les résultats d'étudiants à un tag : + rappelle les notes obtenues dans les modules à prendre en compte, les moyennes et les rangs calculés. + Si etudid=None, tous les étudiants inscrits dans le semestre sont pris en compte. Sinon seuls les étudiants indiqués sont affichés.""" + # Entete + chaine = delim.join(["%15s" % "nom", "etudid"]) + delim + taglist = self.get_all_tags() + if tag in taglist: + for mod in self.tagdict[tag].values(): + chaine += mod["module_code"] + delim + chaine += ("%1.1f" % mod["ponderation"]) + delim + chaine += "coeff" + delim + chaine += delim.join( + ["moyenne", "rang", "nbinscrit", "somme_coeff", "somme_coeff"] + ) # ligne 1 + chaine += "\n" + + # Différents cas de boucles sur les étudiants (de 1 à plusieurs) + if etudid == None: + lesEtuds = self.get_etudids() + elif isinstance(etudid, str) and etudid in self.get_etudids(): + lesEtuds = [etudid] + elif isinstance(etudid, list): + lesEtuds = [eid for eid in self.get_etudids() if eid in etudid] + else: + lesEtuds = [] + + for etudid in lesEtuds: + descr = ( + "%15s" % self.nt.get_nom_short(etudid)[:15] + + delim + + str(etudid) + + delim + ) + if tag in taglist: + for modimpl_id in self.tagdict[tag]: + (note, coeff) = self.get_noteEtCoeff_modimpl(modimpl_id, etudid) + descr += ( + ( + "%2.2f" % note + if note != None and isinstance(note, float) + else str(note) + ) + + delim + + ( + "%1.5f" % coeff + if coeff != None and isinstance(coeff, float) + else str(coeff) + ) + + delim + + ( + "%1.5f" % (coeff * self.somme_coeffs) + if coeff != None and isinstance(coeff, float) + else "???" # str(coeff * self._sum_coeff_semestre) # voir avec Cléo + ) + + delim + ) + moy = self.get_moy_from_resultats(tag, etudid) + rang = self.get_rang_from_resultats(tag, etudid) + coeff = self.get_coeff_from_resultats(tag, etudid) + tot = ( + coeff * self.somme_coeffs + if coeff != None + and self.somme_coeffs != None + and isinstance(coeff, float) + else None + ) + descr += ( + pe_tagtable.TableTag.str_moytag( + moy, rang, len(self.get_etudids()), delim=delim + ) + + delim + ) + descr += ( + ( + "%1.5f" % coeff + if coeff != None and isinstance(coeff, float) + else str(coeff) + ) + + delim + + ( + "%.2f" % (tot) + if tot != None + else str(coeff) + "*" + str(self.somme_coeffs) + ) + ) + chaine += descr + chaine += "\n" + return chaine + + def str_tagsModulesEtCoeffs(self): + """Renvoie une chaine affichant la liste des tags associés au semestre, les modules qui les concernent et les coeffs de pondération. + Plus concrêtement permet d'afficher le contenu de self._tagdict""" + chaine = "Semestre %s d'id %d" % (self.nom, id(self)) + "\n" + chaine += " -> somme de coeffs: " + str(self.somme_coeffs) + "\n" + taglist = self.get_all_tags() + for tag in taglist: + chaine += " > " + tag + ": " + for (modid, mod) in self.tagdict[tag].items(): + chaine += ( + mod["module_code"] + + " (" + + str(mod["coeff"]) + + "*" + + str(mod["ponderation"]) + + ") " + + str(modid) + + ", " + ) + chaine += "\n" + return chaine + + +# ************************************************************************ +# Fonctions diverses +# ************************************************************************ + +# ********************************************* +def comp_coeff_pond(coeffs, ponderations): + """ + Applique une ponderation (indiquée dans la liste ponderations) à une liste de coefficients : + ex: coeff = [2, 3, 1, None], ponderation = [1, 2, 0, 1] => [2*1, 3*2, 1*0, None] + Les coeff peuvent éventuellement être None auquel cas None est conservé ; + Les pondérations sont des floattants + """ + if ( + coeffs == None + or ponderations == None + or not isinstance(coeffs, list) + or not isinstance(ponderations, list) + or len(coeffs) != len(ponderations) + ): + raise ValueError("Erreur de paramètres dans comp_coeff_pond") + return [ + (None if coeffs[i] == None else coeffs[i] * ponderations[i]) + for i in range(len(coeffs)) + ] + + +# ----------------------------------------------------------------------------- +def get_moduleimpl(modimpl_id) -> dict: + """Renvoie l'objet modimpl dont l'id est modimpl_id""" + modimpl = ModuleImpl.query.get(modimpl_id) + if modimpl: + return modimpl + if SemestreTag.DEBUG: + log( + "SemestreTag.get_moduleimpl( %s ) : le modimpl recherche n'existe pas" + % (modimpl_id) + ) + return None + + +# ********************************************** +def get_moy_ue_from_nt(nt, etudid, modimpl_id) -> float: + """Renvoie la moyenne de l'UE d'un etudid dans laquelle se trouve + le module de modimpl_id + """ + # ré-écrit + modimpl = get_moduleimpl(modimpl_id) # le module + ue_status = nt.get_etud_ue_status(etudid, modimpl.module.ue.id) + if ue_status is None: + return None + return ue_status["moy"] diff --git a/app/scodoc/notes_table.py b/app/scodoc/notes_table.py index 9f3d6597..a8322f16 100644 --- a/app/scodoc/notes_table.py +++ b/app/scodoc/notes_table.py @@ -1,1356 +1,1356 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""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.sco_codes_parcours import ( - DEF, - UE_SPORT, - ue_is_fondamentale, - ue_is_professionnelle, -) -from app.scodoc import sco_cache -from app.scodoc import sco_codes_parcours -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 = sco_codes_parcours.get_parcours_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 == "I": - return "" - elif etat == "D": - 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"] != "I": - 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"] != "I": - if self.inscrdict[etudid]["etat"] == "D": - 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 sco_codes_parcours.code_ue_validant(code): - ue = self.uedict.get(ue_id, None) - if ue is None: # not in list for this sem ??? (probably an error) - log( - "Warning: %s capitalized an UE %s which is not part of current sem %s" - % (etudid, ue_id, self.formsemestre_id) - ) - ue = sco_edit_ue.ue_list(args={"ue_id": ue_id})[0] - self.uedict[ue_id] = ue # record this UE - if ue_id not in self._uecoef: - cl = formsemestre_uecoef_list( - cnx, - args={ - "formsemestre_id": self.formsemestre_id, - "ue_id": ue_id, - }, - ) - if not cl: - # cas anormal: UE capitalisee, pas dans ce semestre, et sans coef - log("Warning: setting UE coef to zero") - formsemestre_uecoef_create( - cnx, - args={ - "formsemestre_id": self.formsemestre_id, - "ue_id": ue_id, - "coefficient": 0, - }, - ) - - ects = ue["ects"] or 0.0 # 0 if None - - decisions_jury_ues[etudid][ue_id] = { - "code": code, - "ects": ects, # 0. si non UE validée ou si mode de calcul different (?) - "event_date": ndb.DateISOtoDMY(event_date), - } - - self.decisions_jury_ues = decisions_jury_ues - - def get_etud_decision_sem(self, etudid): - """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu. - { 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id } - Si état défaillant, force le code a DEF - """ - if self.get_etud_etat(etudid) == DEF: - return { - "code": DEF, - "assidu": False, - "event_date": "", - "compense_formsemestre_id": None, - } - else: - return self.decisions_jury.get(etudid, None) - - def get_etud_decision_ues(self, etudid): - """Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu. - Ne tient pas compte des UE capitalisées. - { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : } - Ne renvoie aucune decision d'UE pour les défaillants - """ - if self.get_etud_etat(etudid) == DEF: - return {} - else: - return self.decisions_jury_ues.get(etudid, None) - - def sem_has_decisions(self): - """True si au moins une decision de jury dans ce semestre""" - if [x for x in self.decisions_jury_ues.values() if x]: - return True - - return len([x for x in self.decisions_jury_ues.values() if x]) > 0 - - def etud_has_decision(self, etudid): - """True s'il y a une décision de jury pour cet étudiant""" - return self.get_etud_decision_ues(etudid) or self.get_etud_decision_sem(etudid) - - def all_etuds_have_sem_decisions(self): - """True si tous les étudiants du semestre ont une décision de jury. - ne regarde pas les décisions d'UE (todo: à voir ?) - """ - for etudid in self.get_etudids(): - if self.inscrdict[etudid]["etat"] == "D": - continue # skip demissionnaires - if self.get_etud_decision_sem(etudid) is None: - return False - return True - - # Capitalisation des UEs - def comp_ue_capitalisees(self): - """Cherche pour chaque etudiant ses UE capitalisées dans ce semestre. - Calcule l'attribut: - ue_capitalisees = { etudid : - [{ 'moy':, 'event_date' : ,'formsemestre_id' : }, ...] } - """ - self.ue_capitalisees = scu.DictDefault(defaultvalue=[]) - cnx = None - semestre_id = self.sem["semestre_id"] - for etudid in self.get_etudids(): - capital = formsemestre_get_etud_capitalisation( - self.formation["id"], - semestre_id, - ndb.DateDMYtoISO(self.sem["date_debut"]), - etudid, - ) - for ue_cap in capital: - # Si la moyenne d'UE n'avait pas été stockée (anciennes versions de ScoDoc) - # il faut la calculer ici et l'enregistrer - if ue_cap["moy_ue"] is None: - log( - "comp_ue_capitalisees: recomputing UE moy (etudid=%s, ue_id=%s formsemestre_id=%s)" - % (etudid, ue_cap["ue_id"], ue_cap["formsemestre_id"]) - ) - nt_cap = sco_cache.NotesTableCache.get( - ue_cap["formsemestre_id"] - ) # > UE capitalisees par un etud - ue_cap_status = nt_cap.get_etud_ue_status(etudid, ue_cap["ue_id"]) - if ue_cap_status: - moy_ue_cap = ue_cap_status["moy"] - else: - moy_ue_cap = "" - ue_cap["moy_ue"] = moy_ue_cap - if ( - isinstance(moy_ue_cap, float) - and moy_ue_cap >= self.parcours.NOTES_BARRE_VALID_UE - ): - if not cnx: - cnx = ndb.GetDBConnexion() - sco_cursus_dut.do_formsemestre_validate_ue( - cnx, - nt_cap, - ue_cap["formsemestre_id"], - etudid, - ue_cap["ue_id"], - ue_cap["code"], - ) - else: - log( - "*** valid inconsistency: moy_ue_cap=%s (etudid=%s, ue_id=%s formsemestre_id=%s)" - % ( - moy_ue_cap, - etudid, - ue_cap["ue_id"], - ue_cap["formsemestre_id"], - ) - ) - ue_cap["moy"] = ue_cap["moy_ue"] # backward compat (needs refactoring) - self.ue_capitalisees[etudid].append(ue_cap) - if cnx: - cnx.commit() - # log('comp_ue_capitalisees=\n%s' % pprint.pformat(self.ue_capitalisees) ) - - # def comp_etud_sum_coef_modules_ue( etudid, ue_id): - # """Somme des coefficients des modules de l'UE dans lesquels cet étudiant est inscrit - # ou None s'il n'y a aucun module - # """ - # c_list = [ mod['module']['coefficient'] - # for mod in self._modimpls - # if (( mod['module']['ue_id'] == ue_id) - # and self._modmoys[mod['moduleimpl_id']].get(etudid, False) is not False) - # ] - # if not c_list: - # return None - # return sum(c_list) - - def get_etud_ue_cap_coef(self, etudid, ue, ue_cap, cnx=None): - """Calcule le coefficient d'une UE capitalisée, pour cet étudiant, - injectée dans le semestre courant. - - ue : ue du semestre courant - - ue_cap = resultat de formsemestre_get_etud_capitalisation - { 'ue_id' (dans le semestre source), - 'ue_code', 'moy', 'event_date','formsemestre_id' } - """ - # log("get_etud_ue_cap_coef\nformsemestre_id='%s'\netudid='%s'\nue=%s\nue_cap=%s\n" % (self.formsemestre_id, etudid, ue, ue_cap)) - # 1- Coefficient explicitement déclaré dans le semestre courant pour cette UE ? - if ue["ue_id"] not in self._uecoef: - self._uecoef[ue["ue_id"]] = formsemestre_uecoef_list( - cnx, - args={"formsemestre_id": self.formsemestre_id, "ue_id": ue["ue_id"]}, - ) - - if len(self._uecoef[ue["ue_id"]]): - # utilisation du coef manuel - return self._uecoef[ue["ue_id"]][0]["coefficient"] - - # 2- Mode automatique: calcul du coefficient - # Capitalisation depuis un autre semestre ScoDoc ? - coef = None - if ue_cap["formsemestre_id"]: - # Somme des coefs dans l'UE du semestre d'origine (nouveau: 23/01/2016) - coef = comp_etud_sum_coef_modules_ue( - ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"] - ) - if coef != None: - return coef - else: - # Capitalisation UE externe: quel coef appliquer ? - # Si l'étudiant est inscrit dans le semestre courant, - # somme des coefs des modules de l'UE auxquels il est inscrit - c = comp_etud_sum_coef_modules_ue(self.formsemestre_id, etudid, ue["ue_id"]) - if c is not None: # inscrit à au moins un module de cette UE - return c - # arfff: aucun moyen de déterminer le coefficient de façon sûre - log( - "* oups: calcul coef UE impossible\nformsemestre_id='%s'\netudid='%s'\nue=%s\nue_cap=%s" - % (self.formsemestre_id, etudid, ue, ue_cap) - ) - raise ScoValueError( - """

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

-

Il faut saisir le coefficient de cette UE avant de continuer

-
- """ - % ( - ue["acronyme"], - url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid - ), - self.get_nom_long(etudid), - url_for( - "notes.formsemestre_edit_uecoefs", - scodoc_dept=g.scodoc_dept, - formsemestre_id=self.formsemestre_id, - err_ue_id=ue["ue_id"], - ), - ) - ) - - return 0.0 # ? - - def get_etud_ue_status(self, etudid, ue_id): - "Etat de cette UE (note, coef, capitalisation, ...)" - return self._etud_moy_ues[etudid][ue_id] - - def etud_has_notes_attente(self, etudid): - """Vrai si cet etudiant a au moins une note en attente dans ce semestre. - (ne compte que les notes en attente dans des évaluation avec coef. non nul). - """ - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - """SELECT n.* - FROM notes_notes n, notes_evaluation e, notes_moduleimpl m, - notes_moduleimpl_inscription i - WHERE n.etudid = %(etudid)s - and n.value = %(code_attente)s - and n.evaluation_id = e.id - and e.moduleimpl_id = m.id - and m.formsemestre_id = %(formsemestre_id)s - and e.coefficient != 0 - and m.id = i.moduleimpl_id - and i.etudid=%(etudid)s - """, - { - "formsemestre_id": self.formsemestre_id, - "etudid": etudid, - "code_attente": scu.NOTES_ATTENTE, - }, - ) - return len(cursor.fetchall()) > 0 - - def get_evaluations_etats(self): # evaluation_list_in_sem - """[ {...evaluation et son etat...} ]""" - if self._evaluations_etats is None: - self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem( - self.formsemestre_id - ) - - return self._evaluations_etats - - def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]: - """Liste des évaluations de ce module""" - return [ - e - for e in self.get_evaluations_etats() - if e["moduleimpl_id"] == moduleimpl_id - ] - - def apc_recompute_moyennes(self): - """recalcule les moyennes en APC (BUT) - et modifie en place le tableau T. - XXX Raccord provisoire avant refonte de cette classe. - """ - assert self.parcours.APC_SAE - formsemestre = FormSemestre.query.get(self.formsemestre_id) - results = bulletin_but.ResultatsSemestreBUT(formsemestre) - - # Rappel des épisodes précédents: T est une liste de liste - # Colonnes: 0 moy_gen, moy_ue1, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid - ues = self.get_ues_stat_dict() # incluant le(s) UE de sport - for t in self.T: - etudid = t[-1] - if etudid in results.etud_moy_gen: # evite les démissionnaires - t[0] = results.etud_moy_gen[etudid] - for i, ue in enumerate(ues, start=1): - if ue["type"] != UE_SPORT: - # temporaire pour 9.1.29 ! - if ue["id"] in results.etud_moy_ue: - t[i] = results.etud_moy_ue[ue["id"]][etudid] - else: - t[i] = "" - # re-trie selon la nouvelle moyenne générale: - self.T.sort(key=self._row_key) - # Remplace aussi le rang: - self.etud_moy_gen_ranks = results.etud_moy_gen_ranks +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""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.sco_codes_parcours import ( + DEF, + UE_SPORT, + ue_is_fondamentale, + ue_is_professionnelle, +) +from app.scodoc import sco_cache +from app.scodoc import sco_codes_parcours +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 = sco_codes_parcours.get_parcours_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 sco_codes_parcours.code_ue_validant(code): + ue = self.uedict.get(ue_id, None) + if ue is None: # not in list for this sem ??? (probably an error) + log( + "Warning: %s capitalized an UE %s which is not part of current sem %s" + % (etudid, ue_id, self.formsemestre_id) + ) + ue = sco_edit_ue.ue_list(args={"ue_id": ue_id})[0] + self.uedict[ue_id] = ue # record this UE + if ue_id not in self._uecoef: + cl = formsemestre_uecoef_list( + cnx, + args={ + "formsemestre_id": self.formsemestre_id, + "ue_id": ue_id, + }, + ) + if not cl: + # cas anormal: UE capitalisee, pas dans ce semestre, et sans coef + log("Warning: setting UE coef to zero") + formsemestre_uecoef_create( + cnx, + args={ + "formsemestre_id": self.formsemestre_id, + "ue_id": ue_id, + "coefficient": 0, + }, + ) + + ects = ue["ects"] or 0.0 # 0 if None + + decisions_jury_ues[etudid][ue_id] = { + "code": code, + "ects": ects, # 0. si non UE validée ou si mode de calcul different (?) + "event_date": ndb.DateISOtoDMY(event_date), + } + + self.decisions_jury_ues = decisions_jury_ues + + def get_etud_decision_sem(self, etudid): + """Decision du jury prise pour cet etudiant, ou None s'il n'y en pas eu. + { 'code' : None|ATT|..., 'assidu' : 0|1, 'event_date' : , compense_formsemestre_id } + Si état défaillant, force le code a DEF + """ + if self.get_etud_etat(etudid) == DEF: + return { + "code": DEF, + "assidu": False, + "event_date": "", + "compense_formsemestre_id": None, + } + else: + return self.decisions_jury.get(etudid, None) + + def get_etud_decision_ues(self, etudid): + """Decisions du jury pour les UE de cet etudiant, ou None s'il n'y en pas eu. + Ne tient pas compte des UE capitalisées. + { ue_id : { 'code' : ADM|CMP|AJ, 'event_date' : } + Ne renvoie aucune decision d'UE pour les défaillants + """ + if self.get_etud_etat(etudid) == DEF: + return {} + else: + return self.decisions_jury_ues.get(etudid, None) + + def sem_has_decisions(self): + """True si au moins une decision de jury dans ce semestre""" + if [x for x in self.decisions_jury_ues.values() if x]: + return True + + return len([x for x in self.decisions_jury_ues.values() if x]) > 0 + + def etud_has_decision(self, etudid): + """True s'il y a une décision de jury pour cet étudiant""" + return self.get_etud_decision_ues(etudid) or self.get_etud_decision_sem(etudid) + + def all_etuds_have_sem_decisions(self): + """True si tous les étudiants du semestre ont une décision de jury. + ne regarde pas les décisions d'UE (todo: à voir ?) + """ + for etudid in self.get_etudids(): + if self.inscrdict[etudid]["etat"] == "D": + continue # skip demissionnaires + if self.get_etud_decision_sem(etudid) is None: + return False + return True + + # Capitalisation des UEs + def comp_ue_capitalisees(self): + """Cherche pour chaque etudiant ses UE capitalisées dans ce semestre. + Calcule l'attribut: + ue_capitalisees = { etudid : + [{ 'moy':, 'event_date' : ,'formsemestre_id' : }, ...] } + """ + self.ue_capitalisees = scu.DictDefault(defaultvalue=[]) + cnx = None + semestre_id = self.sem["semestre_id"] + for etudid in self.get_etudids(): + capital = formsemestre_get_etud_capitalisation( + self.formation["id"], + semestre_id, + ndb.DateDMYtoISO(self.sem["date_debut"]), + etudid, + ) + for ue_cap in capital: + # Si la moyenne d'UE n'avait pas été stockée (anciennes versions de ScoDoc) + # il faut la calculer ici et l'enregistrer + if ue_cap["moy_ue"] is None: + log( + "comp_ue_capitalisees: recomputing UE moy (etudid=%s, ue_id=%s formsemestre_id=%s)" + % (etudid, ue_cap["ue_id"], ue_cap["formsemestre_id"]) + ) + nt_cap = sco_cache.NotesTableCache.get( + ue_cap["formsemestre_id"] + ) # > UE capitalisees par un etud + ue_cap_status = nt_cap.get_etud_ue_status(etudid, ue_cap["ue_id"]) + if ue_cap_status: + moy_ue_cap = ue_cap_status["moy"] + else: + moy_ue_cap = "" + ue_cap["moy_ue"] = moy_ue_cap + if ( + isinstance(moy_ue_cap, float) + and moy_ue_cap >= self.parcours.NOTES_BARRE_VALID_UE + ): + if not cnx: + cnx = ndb.GetDBConnexion() + sco_cursus_dut.do_formsemestre_validate_ue( + cnx, + nt_cap, + ue_cap["formsemestre_id"], + etudid, + ue_cap["ue_id"], + ue_cap["code"], + ) + else: + log( + "*** valid inconsistency: moy_ue_cap=%s (etudid=%s, ue_id=%s formsemestre_id=%s)" + % ( + moy_ue_cap, + etudid, + ue_cap["ue_id"], + ue_cap["formsemestre_id"], + ) + ) + ue_cap["moy"] = ue_cap["moy_ue"] # backward compat (needs refactoring) + self.ue_capitalisees[etudid].append(ue_cap) + if cnx: + cnx.commit() + # log('comp_ue_capitalisees=\n%s' % pprint.pformat(self.ue_capitalisees) ) + + # def comp_etud_sum_coef_modules_ue( etudid, ue_id): + # """Somme des coefficients des modules de l'UE dans lesquels cet étudiant est inscrit + # ou None s'il n'y a aucun module + # """ + # c_list = [ mod['module']['coefficient'] + # for mod in self._modimpls + # if (( mod['module']['ue_id'] == ue_id) + # and self._modmoys[mod['moduleimpl_id']].get(etudid, False) is not False) + # ] + # if not c_list: + # return None + # return sum(c_list) + + def get_etud_ue_cap_coef(self, etudid, ue, ue_cap, cnx=None): + """Calcule le coefficient d'une UE capitalisée, pour cet étudiant, + injectée dans le semestre courant. + + ue : ue du semestre courant + + ue_cap = resultat de formsemestre_get_etud_capitalisation + { 'ue_id' (dans le semestre source), + 'ue_code', 'moy', 'event_date','formsemestre_id' } + """ + # log("get_etud_ue_cap_coef\nformsemestre_id='%s'\netudid='%s'\nue=%s\nue_cap=%s\n" % (self.formsemestre_id, etudid, ue, ue_cap)) + # 1- Coefficient explicitement déclaré dans le semestre courant pour cette UE ? + if ue["ue_id"] not in self._uecoef: + self._uecoef[ue["ue_id"]] = formsemestre_uecoef_list( + cnx, + args={"formsemestre_id": self.formsemestre_id, "ue_id": ue["ue_id"]}, + ) + + if len(self._uecoef[ue["ue_id"]]): + # utilisation du coef manuel + return self._uecoef[ue["ue_id"]][0]["coefficient"] + + # 2- Mode automatique: calcul du coefficient + # Capitalisation depuis un autre semestre ScoDoc ? + coef = None + if ue_cap["formsemestre_id"]: + # Somme des coefs dans l'UE du semestre d'origine (nouveau: 23/01/2016) + coef = comp_etud_sum_coef_modules_ue( + ue_cap["formsemestre_id"], etudid, ue_cap["ue_id"] + ) + if coef != None: + return coef + else: + # Capitalisation UE externe: quel coef appliquer ? + # Si l'étudiant est inscrit dans le semestre courant, + # somme des coefs des modules de l'UE auxquels il est inscrit + c = comp_etud_sum_coef_modules_ue(self.formsemestre_id, etudid, ue["ue_id"]) + if c is not None: # inscrit à au moins un module de cette UE + return c + # arfff: aucun moyen de déterminer le coefficient de façon sûre + log( + "* oups: calcul coef UE impossible\nformsemestre_id='%s'\netudid='%s'\nue=%s\nue_cap=%s" + % (self.formsemestre_id, etudid, ue, ue_cap) + ) + raise ScoValueError( + """

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

+

Il faut saisir le coefficient de cette UE avant de continuer

+
+ """ + % ( + ue["acronyme"], + url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + ), + self.get_nom_long(etudid), + url_for( + "notes.formsemestre_edit_uecoefs", + scodoc_dept=g.scodoc_dept, + formsemestre_id=self.formsemestre_id, + err_ue_id=ue["ue_id"], + ), + ) + ) + + return 0.0 # ? + + def get_etud_ue_status(self, etudid, ue_id): + "Etat de cette UE (note, coef, capitalisation, ...)" + return self._etud_moy_ues[etudid][ue_id] + + def etud_has_notes_attente(self, etudid): + """Vrai si cet etudiant a au moins une note en attente dans ce semestre. + (ne compte que les notes en attente dans des évaluation avec coef. non nul). + """ + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + """SELECT n.* + FROM notes_notes n, notes_evaluation e, notes_moduleimpl m, + notes_moduleimpl_inscription i + WHERE n.etudid = %(etudid)s + and n.value = %(code_attente)s + and n.evaluation_id = e.id + and e.moduleimpl_id = m.id + and m.formsemestre_id = %(formsemestre_id)s + and e.coefficient != 0 + and m.id = i.moduleimpl_id + and i.etudid=%(etudid)s + """, + { + "formsemestre_id": self.formsemestre_id, + "etudid": etudid, + "code_attente": scu.NOTES_ATTENTE, + }, + ) + return len(cursor.fetchall()) > 0 + + def get_evaluations_etats(self): # evaluation_list_in_sem + """[ {...evaluation et son etat...} ]""" + if self._evaluations_etats is None: + self._evaluations_etats = sco_evaluations.do_evaluation_list_in_sem( + self.formsemestre_id + ) + + return self._evaluations_etats + + def get_mod_evaluation_etat_list(self, moduleimpl_id) -> list[dict]: + """Liste des évaluations de ce module""" + return [ + e + for e in self.get_evaluations_etats() + if e["moduleimpl_id"] == moduleimpl_id + ] + + def apc_recompute_moyennes(self): + """recalcule les moyennes en APC (BUT) + et modifie en place le tableau T. + XXX Raccord provisoire avant refonte de cette classe. + """ + assert self.parcours.APC_SAE + formsemestre = FormSemestre.query.get(self.formsemestre_id) + results = bulletin_but.ResultatsSemestreBUT(formsemestre) + + # Rappel des épisodes précédents: T est une liste de liste + # Colonnes: 0 moy_gen, moy_ue1, ..., moy_ue_n, moy_mod1, ..., moy_mod_n, etudid + ues = self.get_ues_stat_dict() # incluant le(s) UE de sport + for t in self.T: + etudid = t[-1] + if etudid in results.etud_moy_gen: # evite les démissionnaires + t[0] = results.etud_moy_gen[etudid] + for i, ue in enumerate(ues, start=1): + if ue["type"] != UE_SPORT: + # temporaire pour 9.1.29 ! + if ue["id"] in results.etud_moy_ue: + t[i] = results.etud_moy_ue[ue["id"]][etudid] + else: + t[i] = "" + # re-trie selon la nouvelle moyenne générale: + self.T.sort(key=self._row_key) + # Remplace aussi le rang: + self.etud_moy_gen_ranks = results.etud_moy_gen_ranks diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index 726d24a0..ae2cc60d 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -1,1274 +1,1274 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Génération des bulletins de notes - -""" -import email -import time -import numpy as np - -from flask import g, request -from flask import flash, jsonify, render_template, url_for -from flask_login import current_user - -from app import email -from app import log -from app.scodoc.sco_utils import json_error -from app.but import bulletin_but -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre, Identite, ModuleImplInscription -from app.scodoc.sco_permissions import Permission -from app.scodoc.sco_exceptions import AccessDenied, ScoValueError -from app.scodoc import html_sco_header -from app.scodoc import htmlutils -from app.scodoc import sco_abs -from app.scodoc import sco_abs_views -from app.scodoc import sco_bulletins_generator -from app.scodoc import sco_bulletins_json -from app.scodoc import sco_bulletins_pdf -from app.scodoc import sco_bulletins_xml -from app.scodoc import sco_codes_parcours -from app.scodoc import sco_etud -from app.scodoc import sco_evaluation_db -from app.scodoc import sco_formations -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups -from app.scodoc import sco_permissions_check -from app.scodoc import sco_preferences -from app.scodoc import sco_pvjury -from app.scodoc import sco_users -import app.scodoc.sco_utils as scu -from app.scodoc.sco_utils import ModuleType, fmt_note -import app.scodoc.notesdb as ndb - - -def get_formsemestre_bulletin_etud_json( - formsemestre: FormSemestre, - etud: Identite, - force_publishing=False, - version="long", -) -> str: - """Le JSON du bulletin d'un étudiant, quel que soit le type de formation.""" - if formsemestre.formation.is_apc(): - bul = bulletin_but.BulletinBUT(formsemestre) - if not etud.id in bul.res.identdict: - return json_error(404, "get_formsemestre_bulletin_etud_json: invalid etud") - return jsonify( - bul.bulletin_etud( - etud, - formsemestre, - force_publishing=force_publishing, - version=version, - ) - ) - return formsemestre_bulletinetud( - etud, - formsemestre_id=formsemestre.id, - format="json", - version=version, - xml_with_decisions=True, - force_publishing=force_publishing, - ) - - -# ------------- -def make_context_dict(formsemestre: FormSemestre, etud: dict) -> dict: - """Construit dictionnaire avec valeurs pour substitution des textes - (preferences bul_pdf_*) - """ - C = formsemestre.get_infos_dict() - C["responsable"] = formsemestre.responsables_str() - C["anneesem"] = C["annee"] # backward compat - C.update(etud) - # copie preferences - for name in sco_preferences.get_base_preferences().prefs_name: - C[name] = sco_preferences.get_preference(name, formsemestre.id) - - # ajoute groupes et group_0, group_1, ... - sco_groups.etud_add_group_infos(etud, formsemestre.id) - C["groupes"] = etud["groupes"] - n = 0 - for partition_id in etud["partitions"]: - C["group_%d" % n] = etud["partitions"][partition_id]["group_name"] - n += 1 - - # ajoute date courante - t = time.localtime() - C["date_dmy"] = time.strftime("%d/%m/%Y", t) - C["date_iso"] = time.strftime("%Y-%m-%d", t) - - return C - - -def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): - """Collecte informations pour bulletin de notes - Retourne un dictionnaire (avec valeur par défaut chaine vide). - Le contenu du dictionnaire dépend des options (rangs, ...) - et de la version choisie (short, long, selectedevals). - - Cette fonction est utilisée pour les bulletins CLASSIQUES (DUT, ...) - en HTML et PDF, mais pas ceux en XML. - """ - from app.scodoc import sco_abs - - if not version in scu.BULLETINS_VERSIONS: - raise ValueError("invalid version code !") - - prefs = sco_preferences.SemPreferences(formsemestre_id) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - if not nt.get_etud_etat(etudid): - raise ScoValueError("Étudiant non inscrit à ce semestre") - I = scu.DictDefault(defaultvalue="") - I["etudid"] = etudid - I["formsemestre_id"] = formsemestre_id - I["sem"] = formsemestre.get_infos_dict() - I["server_name"] = request.url_root - - # Formation et parcours - formation_dict = None - if I["sem"]["formation_id"]: - formation_dicts = sco_formations.formation_list( - args={"formation_id": I["sem"]["formation_id"]} - ) - if formation_dicts: - formation_dict = formation_dicts[0] - if formation_dict is None: # what's the fuck ? - formation_dict = { - "acronyme": "?", - "code_specialite": "", - "dept_id": 1, - "formation_code": "?", - "formation_id": -1, - "id": -1, - "referentiel_competence_id": None, - "titre": "?", - "titre_officiel": "?", - "type_parcours": 0, - "version": 0, - } - I["formation"] = formation_dict - I["parcours"] = sco_codes_parcours.get_parcours_from_code( - I["formation"]["type_parcours"] - ) - # Infos sur l'etudiant - I["etud"] = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - I["descr_situation"] = I["etud"]["inscriptionstr"] - if I["etud"]["inscription_formsemestre_id"]: - I[ - "descr_situation_html" - ] = f"""{I["descr_situation"]}""" - else: - I["descr_situation_html"] = I["descr_situation"] - # Groupes: - partitions = sco_groups.get_partitions_list(formsemestre_id, with_default=False) - partitions_etud_groups = {} # { partition_id : { etudid : group } } - for partition in partitions: - pid = partition["partition_id"] - partitions_etud_groups[pid] = sco_groups.get_etud_groups_in_partition(pid) - # --- Absences - I["nbabs"], I["nbabsjust"] = sco_abs.get_abs_count(etudid, nt.sem) - - # --- Decision Jury - infos, dpv = etud_descr_situation_semestre( - etudid, - formsemestre_id, - format="html", - show_date_inscr=prefs["bul_show_date_inscr"], - show_decisions=prefs["bul_show_decision"], - show_uevalid=prefs["bul_show_uevalid"], - show_mention=prefs["bul_show_mention"], - ) - - I.update(infos) - - I["etud_etat_html"] = _get_etud_etat_html( - formsemestre.etuds_inscriptions[etudid].etat - ) - I["etud_etat"] = nt.get_etud_etat(etudid) - I["filigranne"] = sco_bulletins_pdf.get_filigranne( - I["etud_etat"], prefs, decision_sem=I["decision_sem"] - ) - I["demission"] = "" - if I["etud_etat"] == scu.DEMISSION: - I["demission"] = "(Démission)" - elif I["etud_etat"] == sco_codes_parcours.DEF: - I["demission"] = "(Défaillant)" - - # --- Appreciations - I.update(get_appreciations_list(formsemestre_id, etudid)) - - # --- Notes - ues = nt.get_ues_stat_dict() - modimpls = nt.get_modimpls_dict() - moy_gen = nt.get_etud_moy_gen(etudid) - I["nb_inscrits"] = len(nt.etud_moy_gen_ranks) - I["moy_gen"] = scu.fmt_note(moy_gen) - I["moy_min"] = scu.fmt_note(nt.moy_min) - I["moy_max"] = scu.fmt_note(nt.moy_max) - I["mention"] = "" - if dpv: - decision_sem = dpv["decisions"][0]["decision_sem"] - if decision_sem and sco_codes_parcours.code_semestre_validant( - decision_sem["code"] - ): - I["mention"] = scu.get_mention(moy_gen) - - if dpv and dpv["decisions"][0]: - I["sum_ects"] = dpv["decisions"][0]["sum_ects"] - I["sum_ects_capitalises"] = dpv["decisions"][0]["sum_ects_capitalises"] - else: - I["sum_ects"] = 0 - I["sum_ects_capitalises"] = 0 - I["moy_moy"] = scu.fmt_note(nt.moy_moy) # moyenne des moyennes generales - if (not isinstance(moy_gen, str)) and (not isinstance(nt.moy_moy, str)): - I["moy_gen_bargraph_html"] = " " + htmlutils.horizontal_bargraph( - moy_gen * 5, nt.moy_moy * 5 - ) - else: - I["moy_gen_bargraph_html"] = "" - - if prefs["bul_show_rangs"]: - rang = str(nt.get_etud_rang(etudid)) - else: - rang = "" - - rang_gr, ninscrits_gr, gr_name = get_etud_rangs_groups( - etudid, partitions, partitions_etud_groups, nt - ) - - if nt.get_moduleimpls_attente(): - # n'affiche pas le rang sur le bulletin s'il y a des - # notes en attente dans ce semestre - rang = scu.RANG_ATTENTE_STR - rang_gr = scu.DictDefault(defaultvalue=scu.RANG_ATTENTE_STR) - inscriptions_counts = nt.get_inscriptions_counts() - I["rang"] = rang - I["rang_gr"] = rang_gr - I["gr_name"] = gr_name - I["ninscrits_gr"] = ninscrits_gr - I["nbetuds"] = len(nt.etud_moy_gen_ranks) - I["nb_demissions"] = inscriptions_counts[scu.DEMISSION] - I["nb_defaillants"] = inscriptions_counts[scu.DEF] - if prefs["bul_show_rangs"]: - I["rang_nt"] = "%s / %d" % ( - rang, - inscriptions_counts[scu.INSCRIT], - ) - I["rang_txt"] = "Rang " + I["rang_nt"] - else: - I["rang_nt"], I["rang_txt"] = "", "" - I["note_max"] = 20.0 # notes toujours sur 20 - I["bonus_sport_culture"] = nt.bonus[etudid] if nt.bonus is not None else 0.0 - # Liste les UE / modules /evals - I["ues"] = [] - I["matieres_modules"] = {} - I["matieres_modules_capitalized"] = {} - for ue in ues: - u = ue.copy() - ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) - if ( - ModuleImplInscription.nb_inscriptions_dans_ue( - formsemestre_id, etudid, ue["ue_id"] - ) - == 0 - ) and not ue_status["is_capitalized"]: - # saute les UE où l'on est pas inscrit et n'avons pas de capitalisation - continue - - u["ue_status"] = ue_status # { 'moy', 'coef_ue', ...} - if ue["type"] != sco_codes_parcours.UE_SPORT: - u["cur_moy_ue_txt"] = scu.fmt_note(ue_status["cur_moy_ue"]) - else: - if nt.bonus is not None: - x = scu.fmt_note(nt.bonus[etudid], keep_numeric=True) - else: - x = "" - if isinstance(x, str): - if nt.bonus_ues is None: - u["cur_moy_ue_txt"] = "pas de bonus" - else: - u["cur_moy_ue_txt"] = "bonus appliqué sur les UEs" - else: - u["cur_moy_ue_txt"] = f"bonus de {fmt_note(x)} points" - if nt.bonus_ues is not None: - u["cur_moy_ue_txt"] += " (+ues)" - u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"]) - if ue_status["coef_ue"] != None: - u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"]) - else: - u["coef_ue_txt"] = "-" - - if ( - dpv - and dpv["decisions"][0]["decisions_ue"] - and ue["ue_id"] in dpv["decisions"][0]["decisions_ue"] - ): - u["ects"] = dpv["decisions"][0]["decisions_ue"][ue["ue_id"]]["ects"] - if ue["type"] == sco_codes_parcours.UE_ELECTIVE: - u["ects"] = ( - "%g+" % u["ects"] - ) # ajoute un "+" pour indiquer ECTS d'une UE élective - else: - if ue_status["is_capitalized"]: - u["ects"] = ue_status["ue"].get("ects", "-") - else: - u["ects"] = "-" - modules, ue_attente = _ue_mod_bulletin( - etudid, formsemestre_id, ue["ue_id"], modimpls, nt, version - ) - # - u["modules"] = modules # detail des modules de l'UE (dans le semestre courant) - # auparavant on filtrait les modules sans notes - # si ue_status['cur_moy_ue'] != 'NA' alors u['modules'] = [] (pas de moyenne => pas de modules) - - u[ - "modules_capitalized" - ] = [] # modules de l'UE capitalisée (liste vide si pas capitalisée) - if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None: - sem_origin = FormSemestre.query.get(ue_status["formsemestre_id"]) - u[ - "ue_descr_txt" - ] = f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}' - u["ue_descr_html"] = ( - f"""{u["ue_descr_txt"]} - """ - if sem_origin - else "" - ) - if ue_status["moy"] != "NA": - # détail des modules de l'UE capitalisée - formsemestre_cap = FormSemestre.query.get(ue_status["formsemestre_id"]) - nt_cap: NotesTableCompat = res_sem.load_formsemestre_results( - formsemestre_cap - ) - - u["modules_capitalized"], _ = _ue_mod_bulletin( - etudid, - formsemestre_id, - ue_status["capitalized_ue_id"], - nt_cap.get_modimpls_dict(), - nt_cap, - version, - ) - I["matieres_modules_capitalized"].update( - _sort_mod_by_matiere(u["modules_capitalized"], nt_cap, etudid) - ) - else: - if prefs["bul_show_ue_rangs"] and ue["type"] != sco_codes_parcours.UE_SPORT: - if ue_attente or nt.ue_rangs[ue["ue_id"]][0] is None: - u["ue_descr_txt"] = "%s/%s" % ( - scu.RANG_ATTENTE_STR, - nt.ue_rangs[ue["ue_id"]][1], - ) - else: - u["ue_descr_txt"] = "%s/%s" % ( - nt.ue_rangs[ue["ue_id"]][0][etudid], - nt.ue_rangs[ue["ue_id"]][1], - ) - u["ue_descr_html"] = u["ue_descr_txt"] - else: - u["ue_descr_txt"] = u["ue_descr_html"] = "" - - if ue_status["is_capitalized"] or modules: - I["ues"].append(u) # ne montre pas les UE si non inscrit - - # Accès par matieres - # En #sco92, pas d'information - I["matieres_modules"].update(_sort_mod_by_matiere(modules, nt, etudid)) - - # - C = make_context_dict(formsemestre, I["etud"]) - C.update(I) - # - # log( 'C = \n%s\n' % pprint.pformat(C) ) # tres pratique pour voir toutes les infos dispo - return C - - -def get_appreciations_list(formsemestre_id: int, etudid: int) -> dict: - """Appréciations pour cet étudiant dans ce semestre""" - cnx = ndb.GetDBConnexion() - apprecs = sco_etud.appreciations_list( - cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id} - ) - d = { - "appreciations_list": apprecs, - "appreciations_txt": [x["date"] + ": " + x["comment"] for x in apprecs], - } - # deprecated / keep it for backward compat in templates: - d["appreciations"] = d["appreciations_txt"] - return d - - -def _get_etud_etat_html(etat: str) -> str: - """chaine html représentant l'état (backward compat sco7)""" - if etat == scu.INSCRIT: # "I" - return "" - elif etat == scu.DEMISSION: # "D" - return ' (DEMISSIONNAIRE) ' - elif etat == scu.DEF: # "DEF" - return ' (DEFAILLANT) ' - else: - return f' ({etat}) ' - - -def _sort_mod_by_matiere(modlist, nt, etudid): - matmod = {} # { matiere_id : [] } - for mod in modlist: - matiere_id = mod["module"]["matiere_id"] - if matiere_id not in matmod: - moy = nt.get_etud_mat_moy(matiere_id, etudid) - matmod[matiere_id] = { - "titre": mod["mat"]["titre"], - "modules": mod, - "moy": moy, - "moy_txt": scu.fmt_note(moy), - } - return matmod - - -def _ue_mod_bulletin( - etudid, formsemestre_id, ue_id, modimpls, nt: NotesTableCompat, version -): - """Infos sur les modules (et évaluations) dans une UE - (ajoute les informations aux modimpls) - Result: liste de modules de l'UE avec les infos dans chacun (seulement ceux où l'étudiant est inscrit). - """ - bul_show_mod_rangs = sco_preferences.get_preference( - "bul_show_mod_rangs", formsemestre_id - ) - bul_show_abs_modules = sco_preferences.get_preference( - "bul_show_abs_modules", formsemestre_id - ) - if bul_show_abs_modules: - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - debut_sem = ndb.DateDMYtoISO(sem["date_debut"]) - fin_sem = ndb.DateDMYtoISO(sem["date_fin"]) - - ue_modimpls = [mod for mod in modimpls if mod["module"]["ue_id"] == ue_id] - mods = [] # result - ue_attente = False # true si une eval en attente dans cette UE - for modimpl in ue_modimpls: - mod_attente = False - mod = modimpl.copy() - mod_moy = nt.get_etud_mod_moy( - modimpl["moduleimpl_id"], etudid - ) # peut etre 'NI' - is_malus = mod["module"]["module_type"] == ModuleType.MALUS - if bul_show_abs_modules: - nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem) - mod_abs = [nbabs, nbabsjust] - mod["mod_abs_txt"] = scu.fmt_abs(mod_abs) - else: - mod["mod_abs_txt"] = "" - - mod["mod_moy_txt"] = scu.fmt_note(mod_moy) - if mod["mod_moy_txt"][:2] == "NA": - mod["mod_moy_txt"] = "-" - if is_malus: - if isinstance(mod_moy, str): - mod["mod_moy_txt"] = "-" - mod["mod_coef_txt"] = "-" - elif mod_moy > 0: - mod["mod_moy_txt"] = scu.fmt_note(mod_moy) - mod["mod_coef_txt"] = "Malus" - elif mod_moy < 0: - mod["mod_moy_txt"] = scu.fmt_note(-mod_moy) - mod["mod_coef_txt"] = "Bonus" - else: - mod["mod_moy_txt"] = "-" - mod["mod_coef_txt"] = "-" - else: - mod["mod_coef_txt"] = scu.fmt_coef(modimpl["module"]["coefficient"]) - if mod["mod_moy_txt"] != "NI": # ne montre pas les modules 'non inscrit' - mods.append(mod) - if is_malus: # n'affiche pas les statistiques sur les modules malus - mod["stats"] = { - "moy": "", - "max": "", - "min": "", - "nb_notes": "", - "nb_missing": "", - "nb_valid_evals": "", - } - else: - mod["stats"] = nt.get_mod_stats(modimpl["moduleimpl_id"]) - mod["mod_descr_txt"] = "Module %s, coef. %s (%s)" % ( - modimpl["module"]["titre"], - scu.fmt_coef(modimpl["module"]["coefficient"]), - sco_users.user_info(modimpl["responsable_id"])["nomcomplet"], - ) - link_mod = ( - '' - % (modimpl["moduleimpl_id"], mod["mod_descr_txt"]) - ) - if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id): - mod["code"] = modimpl["module"]["code"] - mod["code_html"] = link_mod + (mod["code"] or "") + "" - else: - mod["code"] = mod["code_html"] = "" - mod["name"] = ( - modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or "" - ) - mod["name_html"] = link_mod + mod["name"] + "" - - mod_descr = "Module %s, coef. %s (%s)" % ( - modimpl["module"]["titre"], - scu.fmt_coef(modimpl["module"]["coefficient"]), - sco_users.user_info(modimpl["responsable_id"])["nomcomplet"], - ) - link_mod = ( - '' - % (modimpl["moduleimpl_id"], mod_descr) - ) - if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id): - mod["code_txt"] = modimpl["module"]["code"] or "" - mod["code_html"] = link_mod + mod["code_txt"] + "" - else: - mod["code_txt"] = "" - mod["code_html"] = "" - # Evaluations: notes de chaque eval - evals = nt.get_evals_in_mod(modimpl["moduleimpl_id"]) - mod["evaluations"] = [] - for e in evals: - e = e.copy() - if e["visibulletin"] or version == "long": - # affiche "bonus" quand les points de malus sont négatifs - if is_malus: - val = e["notes"].get(etudid, {"value": "NP"})[ - "value" - ] # NA si etud demissionnaire - if val == "NP" or val > 0: - e["name"] = "Points de malus sur cette UE" - else: - e["name"] = "Points de bonus sur cette UE" - else: - e["name"] = e["description"] or f"le {e['jour']}" - e["target_html"] = url_for( - "notes.evaluation_listenotes", - scodoc_dept=g.scodoc_dept, - evaluation_id=e["evaluation_id"], - format="html", - tf_submitted=1, - ) - e[ - "name_html" - ] = f"""{e['name']}""" - val = e["notes"].get(etudid, {"value": "NP"})["value"] - # val est NP si etud demissionnaire - if val == "NP": - e["note_txt"] = "nd" - e["note_html"] = 'nd' - e["coef_txt"] = scu.fmt_coef(e["coefficient"]) - else: - # (-0.15) s'affiche "bonus de 0.15" - if is_malus: - val = abs(val) - e["note_txt"] = scu.fmt_note(val, note_max=e["note_max"]) - e["note_html"] = e["note_txt"] - if is_malus: - e["coef_txt"] = "" - else: - e["coef_txt"] = scu.fmt_coef(e["coefficient"]) - if e["evaluation_type"] == scu.EVALUATION_RATTRAPAGE: - e["coef_txt"] = "rat." - elif e["evaluation_type"] == scu.EVALUATION_SESSION2: - e["coef_txt"] = "Ses. 2" - if e["etat"]["evalattente"]: - mod_attente = True # une eval en attente dans ce module - if ((not is_malus) or (val != "NP")) and ( - ( - e["evaluation_type"] == scu.EVALUATION_NORMALE - or not np.isnan(val) - ) - ): - # ne liste pas les eval malus sans notes - # ni les rattrapages et sessions 2 si pas de note - mod["evaluations"].append(e) - - # Evaluations incomplètes ou futures: - mod["evaluations_incompletes"] = [] - if sco_preferences.get_preference("bul_show_all_evals", formsemestre_id): - complete_eval_ids = set([e["evaluation_id"] for e in evals]) - all_evals = sco_evaluation_db.do_evaluation_list( - args={"moduleimpl_id": modimpl["moduleimpl_id"]} - ) - all_evals.reverse() # plus ancienne d'abord - for e in all_evals: - if e["evaluation_id"] not in complete_eval_ids: - e = e.copy() - mod["evaluations_incompletes"].append(e) - e["name"] = (e["description"] or "") + " (%s)" % e["jour"] - e["target_html"] = url_for( - "notes.evaluation_listenotes", - scodoc_dept=g.scodoc_dept, - evaluation_id=e["evaluation_id"], - tf_submitted=1, - format="html", - ) - e["name_html"] = '%s' % ( - e["target_html"], - e["name"], - ) - e["note_txt"] = e["note_html"] = "" - e["coef_txt"] = scu.fmt_coef(e["coefficient"]) - # Classement - if ( - bul_show_mod_rangs - and (nt.mod_rangs is not None) - and mod["mod_moy_txt"] != "-" - and not is_malus - ): - rg = nt.mod_rangs[modimpl["moduleimpl_id"]] - if rg[0] is None: - mod["mod_rang_txt"] = "" - else: - if mod_attente: # nt.get_moduleimpls_attente(): - mod["mod_rang"] = scu.RANG_ATTENTE_STR - else: - mod["mod_rang"] = rg[0][etudid] - mod["mod_eff"] = rg[1] # effectif dans ce module - mod["mod_rang_txt"] = "%s/%s" % (mod["mod_rang"], mod["mod_eff"]) - else: - mod["mod_rang_txt"] = "" - if mod_attente: - ue_attente = True - return mods, ue_attente - - -def get_etud_rangs_groups( - etudid: int, partitions, partitions_etud_groups, nt: NotesTableCompat -): - """Ramene rang et nb inscrits dans chaque partition""" - rang_gr, ninscrits_gr, gr_name = {}, {}, {} - for partition in partitions: - if partition["partition_name"] != None: - partition_id = partition["partition_id"] - - if etudid in partitions_etud_groups[partition_id]: - group = partitions_etud_groups[partition_id][etudid] - - ( - rang_gr[partition_id], - ninscrits_gr[partition_id], - ) = nt.get_etud_rang_group(etudid, group["group_id"]) - gr_name[partition_id] = group["group_name"] - else: # etudiant non present dans cette partition - rang_gr[partition_id], ninscrits_gr[partition_id] = "", "" - gr_name[partition_id] = "" - - return rang_gr, ninscrits_gr, gr_name - - -def etud_descr_situation_semestre( - etudid, - formsemestre_id, - ne="", - format="html", # currently unused - show_decisions=True, - show_uevalid=True, - show_date_inscr=True, - show_mention=False, -): - """Dict décrivant la situation de l'étudiant dans ce semestre. - Si format == 'html', peut inclure du balisage html (actuellement inutilisé) - - situation : chaine résumant en français la situation de l'étudiant. - Par ex. "Inscrit le 31/12/1999. Décision jury: Validé. ..." - - date_inscription : (vide si show_date_inscr est faux) - date_demission : (vide si pas demission ou si show_date_inscr est faux) - descr_inscription : "Inscrit" ou "Pas inscrit[e]" - descr_demission : "Démission le 01/02/2000" ou vide si pas de démission - descr_defaillance : "Défaillant" ou vide si non défaillant. - decision_jury : "Validé", "Ajourné", ... (code semestre) - descr_decision_jury : "Décision jury: Validé" (une phrase) - decision_sem : - decisions_ue : noms (acronymes) des UE validées, séparées par des virgules. - descr_decisions_ue : ' UE acquises: UE1, UE2', ou vide si pas de dec. ou si pas show_uevalid - descr_mention : 'Mention Bien', ou vide si pas de mention ou si pas show_mention - """ - # Fonction utilisée par tous les bulletins (APC ou classiques) - cnx = ndb.GetDBConnexion() - infos = scu.DictDefault(defaultvalue="") - - # --- Situation et décisions jury - # démission/inscription ? - events = sco_etud.scolar_events_list( - cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id} - ) - date_inscr = None - date_dem = None - date_def = None - for event in events: - event_type = event["event_type"] - if event_type == "INSCRIPTION": - if date_inscr: - # plusieurs inscriptions ??? - # date_inscr += ', ' + event['event_date'] + ' (!)' - # il y a eu une erreur qui a laissé un event 'inscription' - # on l'efface: - log( - f"etud_descr_situation_semestre: removing duplicate INSCRIPTION event for etudid={etudid} !" - ) - sco_etud.scolar_events_delete(cnx, event["event_id"]) - else: - date_inscr = event["event_date"] - elif event_type == "DEMISSION": - # assert date_dem == None, 'plusieurs démissions !' - if date_dem: # cela ne peut pas arriver sauf bug (signale a Evry 2013?) - log( - f"etud_descr_situation_semestre: removing duplicate DEMISSION event for etudid={etudid} !" - ) - sco_etud.scolar_events_delete(cnx, event["event_id"]) - else: - date_dem = event["event_date"] - elif event_type == "DEFAILLANCE": - if date_def: - log( - f"etud_descr_situation_semestre: removing duplicate DEFAILLANCE event for etudid={etudid} !" - ) - sco_etud.scolar_events_delete(cnx, event["event_id"]) - else: - date_def = event["event_date"] - if show_date_inscr: - if not date_inscr: - infos["date_inscription"] = "" - infos["descr_inscription"] = f"Pas inscrit{ne}" - else: - infos["date_inscription"] = date_inscr - infos["descr_inscription"] = f"Inscrit{ne} le {date_inscr}" - else: - infos["date_inscription"] = "" - infos["descr_inscription"] = "" - - infos["descr_defaillance"] = "" - - # Décision: valeurs par defaut vides: - infos["decision_jury"] = infos["descr_decision_jury"] = "" - infos["decision_sem"] = "" - infos["decisions_ue"] = infos["descr_decisions_ue"] = "" - infos["descr_decisions_niveaux"] = infos["descr_decisions_rcue"] = "" - infos["descr_decision_annee"] = "" - - if date_dem: - infos["descr_demission"] = f"Démission le {date_dem}." - infos["date_demission"] = date_dem - infos["decision_jury"] = infos["descr_decision_jury"] = "Démission" - infos["situation"] = ". ".join( - [x for x in [infos["descr_inscription"], infos["descr_demission"]] if x] - ) - return infos, None # ne donne pas les dec. de jury pour les demissionnaires - if date_def: - infos["descr_defaillance"] = f"Défaillant{ne}" - infos["date_defaillance"] = date_def - infos["descr_decision_jury"] = f"Défaillant{ne}" - - dpv = sco_pvjury.dict_pvjury(formsemestre_id, etudids=[etudid]) - if dpv: - infos["decision_sem"] = dpv["decisions"][0]["decision_sem"] - - if not show_decisions: - return infos, dpv - - # Décisions de jury: - pv = dpv["decisions"][0] - descr_dec = "" - if pv["decision_sem_descr"]: - infos["decision_jury"] = pv["decision_sem_descr"] - infos["descr_decision_jury"] = "Décision jury: " + pv["decision_sem_descr"] - descr_dec = infos["descr_decision_jury"] - else: - infos["descr_decision_jury"] = "" - infos["decision_jury"] = "" - - if pv["decisions_ue_descr"] and show_uevalid: - infos["decisions_ue"] = pv["decisions_ue_descr"] - infos["descr_decisions_ue"] = " UE acquises: " + pv["decisions_ue_descr"] - else: - infos["decisions_ue"] = "" - infos["descr_decisions_ue"] = "" - - infos["mention"] = pv["mention"] - if pv["mention"] and show_mention: - descr_mention = f"Mention {pv['mention']}" - else: - descr_mention = "" - - # Décisions APC / BUT - if pv.get("decision_annee", {}): - infos["descr_decision_annee"] = "Décision année: " + pv.get( - "decision_annee", {} - ).get("code", "") - else: - infos["descr_decision_annee"] = "" - - infos["descr_decisions_rcue"] = pv.get("descr_decisions_rcue", "") - infos["descr_decisions_niveaux"] = pv.get("descr_decisions_niveaux", "") - - descr_autorisations = "" - if not pv["validation_parcours"]: # parcours non terminé - if pv["autorisations_descr"]: - descr_autorisations = ( - f"Autorisé à s'inscrire en {pv['autorisations_descr']}." - ) - else: - descr_dec += " Diplôme obtenu." - _format_situation_fields( - infos, - [ - "descr_inscription", - "descr_defaillance", - "descr_decisions_ue", - "descr_decision_annee", - ], - [descr_dec, descr_mention, descr_autorisations], - ) - - return infos, dpv - - -def _format_situation_fields( - infos, field_names: list[str], extra_values: list[str] -) -> None: - """Réuni les champs pour former le paragraphe "situation", et ajoute la pontuation aux champs.""" - infos["situation"] = ". ".join( - x - for x in [infos.get(field_name, "") for field_name in field_names] - + [field for field in extra_values if field] - if x - ) - for field_name in field_names: - field = infos.get(field_name, "") - if field and not field.endswith("."): - infos[field_name] += "." - - -# ------ Page bulletin -def formsemestre_bulletinetud( - etud: Identite = None, - formsemestre_id=None, - format=None, - version="long", - xml_with_decisions=False, - force_publishing=False, # force publication meme si semestre non publie sur "portail" - prefer_mail_perso=False, -): - """Page bulletin de notes pour - - HTML des formations classiques (non BUT) - - le format "oldjson" (les "json" sont générés à part, voir get_formsemestre_bulletin_etud_json) - - les formats PDF, XML et mail pdf (toutes formations) - - Note: le format XML n'est plus maintenu et pour les BUT ne contient pas - toutes les informations. Privilégier le format JSON. - - Paramètres: - - version: pour les formations classqiues, versions short/selectedevals/long - - xml_with_decisions: inclue ou non les - - force_publishing: renvoie le bulletin même si semestre non publie sur "portail" - - prefer_mail_perso: pour pdfmail, utilise adresse mail perso en priorité. - - """ - format = format or "html" - formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) - if not formsemestre: - raise ScoValueError(f"semestre {formsemestre_id} inconnu !") - - bulletin = do_formsemestre_bulletinetud( - formsemestre, - etud.id, - format=format, - version=version, - xml_with_decisions=xml_with_decisions, - force_publishing=force_publishing, - prefer_mail_perso=prefer_mail_perso, - )[0] - if format not in {"html", "pdfmail"}: - filename = scu.bul_filename(formsemestre, etud, format) - return scu.send_file(bulletin, filename, mime=scu.get_mime_suffix(format)[0]) - elif format == "pdfmail": - return "" - H = [ - _formsemestre_bulletinetud_header_html(etud, formsemestre, format, version), - bulletin, - render_template( - "bul_foot.html", - appreciations=None, # déjà affichées - css_class="bul_classic_foot", - etud=etud, - formsemestre=formsemestre, - inscription_courante=etud.inscription_courante(), - inscription_str=etud.inscription_descr()["inscription_str"], - ), - html_sco_header.sco_footer(), - ] - - return "".join(H) - - -def can_send_bulletin_by_mail(formsemestre_id): - """True if current user is allowed to send a bulletin by mail""" - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - return ( - sco_preferences.get_preference("bul_mail_allowed_for_all", formsemestre_id) - or current_user.has_permission(Permission.ScoImplement) - or current_user.id in sem["responsables"] - ) - - -def do_formsemestre_bulletinetud( - formsemestre: FormSemestre, - etudid: int, - version="long", # short, long, selectedevals - format=None, - xml_with_decisions=False, # force décisions dans XML - force_publishing=False, # force publication meme si semestre non publié sur "portail" - prefer_mail_perso=False, # mails envoyés sur adresse perso si non vide -): - """Génère le bulletin au format demandé. - Utilisé pour: - - HTML des formations classiques (non BUT) - - le format "oldjson" (les json sont générés à part, voir get_formsemestre_bulletin_etud_json) - - les formats PDF, XML et mail pdf (toutes formations) - - Résultat: (bul, filigranne) - où bul est str ou bytes au format demandé (html, pdf, pdfmail, pdfpart, xml, json) - et filigranne est un message à placer en "filigranne" (eg "Provisoire"). - """ - format = format or "html" - if format == "xml": - bul = sco_bulletins_xml.make_xml_formsemestre_bulletinetud( - formsemestre.id, - etudid, - xml_with_decisions=xml_with_decisions, - force_publishing=force_publishing, - version=version, - ) - - return bul, "" - - elif format == "json": # utilisé pour classic et "oldjson" - bul = sco_bulletins_json.make_json_formsemestre_bulletinetud( - formsemestre.id, - etudid, - xml_with_decisions=xml_with_decisions, - force_publishing=force_publishing, - version=version, - ) - return bul, "" - - if formsemestre.formation.is_apc(): - etudiant = Identite.query.get(etudid) - r = bulletin_but.BulletinBUT(formsemestre) - infos = r.bulletin_etud_complet(etudiant, version=version) - else: - infos = formsemestre_bulletinetud_dict(formsemestre.id, etudid) - etud = infos["etud"] - - if format == "html": - htm, _ = sco_bulletins_generator.make_formsemestre_bulletinetud( - infos, version=version, format="html" - ) - return htm, infos["filigranne"] - - elif format == "pdf" or format == "pdfpart": - bul, filename = sco_bulletins_generator.make_formsemestre_bulletinetud( - infos, - version=version, - format="pdf", - stand_alone=(format != "pdfpart"), - ) - if format == "pdf": - return ( - scu.sendPDFFile(bul, filename), - infos["filigranne"], - ) # unused ret. value - else: - return bul, infos["filigranne"] - - elif format == "pdfmail": - # format pdfmail: envoie le pdf par mail a l'etud, et affiche le html - # check permission - if not can_send_bulletin_by_mail(formsemestre.id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - - pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletinetud( - infos, version=version, format="pdf" - ) - - if prefer_mail_perso: - recipient_addr = etud.get("emailperso", "") or etud.get("email", "") - else: - recipient_addr = etud.get("email", "") or etud.get("emailperso", "") - - if not recipient_addr: - flash(f"{etud['nomprenom']} n'a pas d'adresse e-mail !") - return False, infos["filigranne"] - else: - mail_bulletin(formsemestre.id, infos, pdfdata, filename, recipient_addr) - flash(f"mail envoyé à {recipient_addr}") - - return True, infos["filigranne"] - - raise ValueError("do_formsemestre_bulletinetud: invalid format (%s)" % format) - - -def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr): - """Send bulletin by email to etud - If bul_mail_list_abs pref is true, put list of absences in mail body (text). - """ - etud = infos["etud"] - webmaster = sco_preferences.get_preference("bul_mail_contact_addr", formsemestre_id) - dept = scu.unescape_html( - sco_preferences.get_preference("DeptName", formsemestre_id) - ) - copy_addr = sco_preferences.get_preference("email_copy_bulletins", formsemestre_id) - intro_mail = sco_preferences.get_preference("bul_intro_mail", formsemestre_id) - - if intro_mail: - try: - hea = intro_mail % { - "nomprenom": etud["nomprenom"], - "dept": dept, - "webmaster": webmaster, - } - except KeyError as e: - raise ScoValueError( - "format 'Message d'accompagnement' (bul_intro_mail) invalide, revoir les réglages dans les préférences" - ) from e - else: - hea = "" - - if sco_preferences.get_preference("bul_mail_list_abs"): - hea += "\n\n" + sco_abs_views.ListeAbsEtud( - etud["etudid"], with_evals=False, format="text" - ) - - subject = f"""Relevé de notes de {etud["nomprenom"]}""" - recipients = [recipient_addr] - sender = sco_preferences.get_preference("email_from_addr", formsemestre_id) - if copy_addr: - bcc = copy_addr.strip() - else: - bcc = "" - - # Attach pdf - log(f"""mail bulletin a {recipient_addr}""") - email.send_email( - subject, - sender, - recipients, - bcc=[bcc], - text_body=hea, - attachments=[ - {"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata} - ], - ) - - -def make_menu_autres_operations( - formsemestre: FormSemestre, etud: Identite, endpoint: str, version: str -) -> str: - etud_email = etud.get_first_email() or "" - etud_perso = etud.get_first_email("emailperso") or "" - menu_items = [ - { - "title": "Réglages bulletins", - "endpoint": "notes.formsemestre_edit_options", - "args": { - "formsemestre_id": formsemestre.id, - # "target_url": url_for( - # "notes.formsemestre_bulletinetud", - # scodoc_dept=g.scodoc_dept, - # formsemestre_id=formsemestre_id, - # etudid=etudid, - # ), - }, - "enabled": formsemestre.can_be_edited_by(current_user), - }, - { - "title": 'Version papier (pdf, format "%s")' - % sco_bulletins_generator.bulletin_get_class_name_displayed( - formsemestre.id - ), - "endpoint": endpoint, - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - "version": version, - "format": "pdf", - }, - }, - { - "title": f"Envoi par mail à {etud_email}", - "endpoint": endpoint, - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - "version": version, - "format": "pdfmail", - }, - # possible slt si on a un mail... - "enabled": etud_email and can_send_bulletin_by_mail(formsemestre.id), - }, - { - "title": f"Envoi par mail à {etud_perso} (adr. personnelle)", - "endpoint": endpoint, - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - "version": version, - "format": "pdfmail", - "prefer_mail_perso": 1, - }, - # possible slt si on a un mail... - "enabled": etud_perso and can_send_bulletin_by_mail(formsemestre.id), - }, - { - "title": "Version json", - "endpoint": endpoint, - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - "version": version, - "format": "json", - }, - }, - { - "title": "Version XML", - "endpoint": endpoint, - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - "version": version, - "format": "xml", - }, - }, - { - "title": "Ajouter une appréciation", - "endpoint": "notes.appreciation_add_form", - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - }, - "enabled": ( - formsemestre.can_be_edited_by(current_user) - or current_user.has_permission(Permission.ScoEtudInscrit) - ), - }, - { - "title": "Enregistrer un semestre effectué ailleurs", - "endpoint": "notes.formsemestre_ext_create_form", - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - }, - "enabled": current_user.has_permission(Permission.ScoImplement), - }, - { - "title": "Enregistrer une validation d'UE antérieure", - "endpoint": "notes.formsemestre_validate_previous_ue", - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), - }, - { - "title": "Enregistrer note d'une UE externe", - "endpoint": "notes.external_ue_create_form", - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), - }, - { - "title": "Entrer décisions jury", - "endpoint": "notes.formsemestre_validation_etud_form", - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - }, - "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), - }, - { - "title": "Éditer PV jury", - "endpoint": "notes.formsemestre_pvjury_pdf", - "args": { - "formsemestre_id": formsemestre.id, - "etudid": etud.id, - }, - "enabled": True, - }, - ] - return htmlutils.make_menu("Autres opérations", menu_items, alone=True) - - -def _formsemestre_bulletinetud_header_html( - etud, - formsemestre: FormSemestre, - format=None, - version=None, -): - H = [ - html_sco_header.sco_header( - page_title=f"Bulletin de {etud.nomprenom}", - javascripts=[ - "js/bulletin.js", - "libjs/d3.v3.min.js", - "js/radar_bulletin.js", - ], - cssstyles=["css/radar_bulletin.css"], - ), - render_template( - "bul_head.html", - etud=etud, - format=format, - formsemestre=formsemestre, - menu_autres_operations=make_menu_autres_operations( - etud=etud, - formsemestre=formsemestre, - endpoint="notes.formsemestre_bulletinetud", - version=version, - ), - scu=scu, - time=time, - version=version, - ), - ] - return "\n".join(H) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Génération des bulletins de notes + +""" +import email +import time +import numpy as np + +from flask import g, request +from flask import flash, jsonify, render_template, url_for +from flask_login import current_user + +from app import email +from app import log +from app.scodoc.sco_utils import json_error +from app.but import bulletin_but +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre, Identite, ModuleImplInscription +from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_exceptions import AccessDenied, ScoValueError +from app.scodoc import html_sco_header +from app.scodoc import htmlutils +from app.scodoc import sco_abs +from app.scodoc import sco_abs_views +from app.scodoc import sco_bulletins_generator +from app.scodoc import sco_bulletins_json +from app.scodoc import sco_bulletins_pdf +from app.scodoc import sco_bulletins_xml +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_etud +from app.scodoc import sco_evaluation_db +from app.scodoc import sco_formations +from app.scodoc import sco_formsemestre +from app.scodoc import sco_groups +from app.scodoc import sco_permissions_check +from app.scodoc import sco_preferences +from app.scodoc import sco_pvjury +from app.scodoc import sco_users +import app.scodoc.sco_utils as scu +from app.scodoc.sco_utils import ModuleType, fmt_note +import app.scodoc.notesdb as ndb + + +def get_formsemestre_bulletin_etud_json( + formsemestre: FormSemestre, + etud: Identite, + force_publishing=False, + version="long", +) -> str: + """Le JSON du bulletin d'un étudiant, quel que soit le type de formation.""" + if formsemestre.formation.is_apc(): + bul = bulletin_but.BulletinBUT(formsemestre) + if not etud.id in bul.res.identdict: + return json_error(404, "get_formsemestre_bulletin_etud_json: invalid etud") + return jsonify( + bul.bulletin_etud( + etud, + formsemestre, + force_publishing=force_publishing, + version=version, + ) + ) + return formsemestre_bulletinetud( + etud, + formsemestre_id=formsemestre.id, + format="json", + version=version, + xml_with_decisions=True, + force_publishing=force_publishing, + ) + + +# ------------- +def make_context_dict(formsemestre: FormSemestre, etud: dict) -> dict: + """Construit dictionnaire avec valeurs pour substitution des textes + (preferences bul_pdf_*) + """ + C = formsemestre.get_infos_dict() + C["responsable"] = formsemestre.responsables_str() + C["anneesem"] = C["annee"] # backward compat + C.update(etud) + # copie preferences + for name in sco_preferences.get_base_preferences().prefs_name: + C[name] = sco_preferences.get_preference(name, formsemestre.id) + + # ajoute groupes et group_0, group_1, ... + sco_groups.etud_add_group_infos(etud, formsemestre.id) + C["groupes"] = etud["groupes"] + n = 0 + for partition_id in etud["partitions"]: + C["group_%d" % n] = etud["partitions"][partition_id]["group_name"] + n += 1 + + # ajoute date courante + t = time.localtime() + C["date_dmy"] = time.strftime("%d/%m/%Y", t) + C["date_iso"] = time.strftime("%Y-%m-%d", t) + + return C + + +def formsemestre_bulletinetud_dict(formsemestre_id, etudid, version="long"): + """Collecte informations pour bulletin de notes + Retourne un dictionnaire (avec valeur par défaut chaine vide). + Le contenu du dictionnaire dépend des options (rangs, ...) + et de la version choisie (short, long, selectedevals). + + Cette fonction est utilisée pour les bulletins CLASSIQUES (DUT, ...) + en HTML et PDF, mais pas ceux en XML. + """ + from app.scodoc import sco_abs + + if not version in scu.BULLETINS_VERSIONS: + raise ValueError("invalid version code !") + + prefs = sco_preferences.SemPreferences(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + if not nt.get_etud_etat(etudid): + raise ScoValueError("Étudiant non inscrit à ce semestre") + I = scu.DictDefault(defaultvalue="") + I["etudid"] = etudid + I["formsemestre_id"] = formsemestre_id + I["sem"] = formsemestre.get_infos_dict() + I["server_name"] = request.url_root + + # Formation et parcours + formation_dict = None + if I["sem"]["formation_id"]: + formation_dicts = sco_formations.formation_list( + args={"formation_id": I["sem"]["formation_id"]} + ) + if formation_dicts: + formation_dict = formation_dicts[0] + if formation_dict is None: # what's the fuck ? + formation_dict = { + "acronyme": "?", + "code_specialite": "", + "dept_id": 1, + "formation_code": "?", + "formation_id": -1, + "id": -1, + "referentiel_competence_id": None, + "titre": "?", + "titre_officiel": "?", + "type_parcours": 0, + "version": 0, + } + I["formation"] = formation_dict + I["parcours"] = sco_codes_parcours.get_parcours_from_code( + I["formation"]["type_parcours"] + ) + # Infos sur l'etudiant + I["etud"] = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + I["descr_situation"] = I["etud"]["inscriptionstr"] + if I["etud"]["inscription_formsemestre_id"]: + I[ + "descr_situation_html" + ] = f"""{I["descr_situation"]}""" + else: + I["descr_situation_html"] = I["descr_situation"] + # Groupes: + partitions = sco_groups.get_partitions_list(formsemestre_id, with_default=False) + partitions_etud_groups = {} # { partition_id : { etudid : group } } + for partition in partitions: + pid = partition["partition_id"] + partitions_etud_groups[pid] = sco_groups.get_etud_groups_in_partition(pid) + # --- Absences + I["nbabs"], I["nbabsjust"] = sco_abs.get_abs_count(etudid, nt.sem) + + # --- Decision Jury + infos, dpv = etud_descr_situation_semestre( + etudid, + formsemestre_id, + format="html", + show_date_inscr=prefs["bul_show_date_inscr"], + show_decisions=prefs["bul_show_decision"], + show_uevalid=prefs["bul_show_uevalid"], + show_mention=prefs["bul_show_mention"], + ) + + I.update(infos) + + I["etud_etat_html"] = _get_etud_etat_html( + formsemestre.etuds_inscriptions[etudid].etat + ) + I["etud_etat"] = nt.get_etud_etat(etudid) + I["filigranne"] = sco_bulletins_pdf.get_filigranne( + I["etud_etat"], prefs, decision_sem=I["decision_sem"] + ) + I["demission"] = "" + if I["etud_etat"] == scu.DEMISSION: + I["demission"] = "(Démission)" + elif I["etud_etat"] == sco_codes_parcours.DEF: + I["demission"] = "(Défaillant)" + + # --- Appreciations + I.update(get_appreciations_list(formsemestre_id, etudid)) + + # --- Notes + ues = nt.get_ues_stat_dict() + modimpls = nt.get_modimpls_dict() + moy_gen = nt.get_etud_moy_gen(etudid) + I["nb_inscrits"] = len(nt.etud_moy_gen_ranks) + I["moy_gen"] = scu.fmt_note(moy_gen) + I["moy_min"] = scu.fmt_note(nt.moy_min) + I["moy_max"] = scu.fmt_note(nt.moy_max) + I["mention"] = "" + if dpv: + decision_sem = dpv["decisions"][0]["decision_sem"] + if decision_sem and sco_codes_parcours.code_semestre_validant( + decision_sem["code"] + ): + I["mention"] = scu.get_mention(moy_gen) + + if dpv and dpv["decisions"][0]: + I["sum_ects"] = dpv["decisions"][0]["sum_ects"] + I["sum_ects_capitalises"] = dpv["decisions"][0]["sum_ects_capitalises"] + else: + I["sum_ects"] = 0 + I["sum_ects_capitalises"] = 0 + I["moy_moy"] = scu.fmt_note(nt.moy_moy) # moyenne des moyennes generales + if (not isinstance(moy_gen, str)) and (not isinstance(nt.moy_moy, str)): + I["moy_gen_bargraph_html"] = " " + htmlutils.horizontal_bargraph( + moy_gen * 5, nt.moy_moy * 5 + ) + else: + I["moy_gen_bargraph_html"] = "" + + if prefs["bul_show_rangs"]: + rang = str(nt.get_etud_rang(etudid)) + else: + rang = "" + + rang_gr, ninscrits_gr, gr_name = get_etud_rangs_groups( + etudid, partitions, partitions_etud_groups, nt + ) + + if nt.get_moduleimpls_attente(): + # n'affiche pas le rang sur le bulletin s'il y a des + # notes en attente dans ce semestre + rang = scu.RANG_ATTENTE_STR + rang_gr = scu.DictDefault(defaultvalue=scu.RANG_ATTENTE_STR) + inscriptions_counts = nt.get_inscriptions_counts() + I["rang"] = rang + I["rang_gr"] = rang_gr + I["gr_name"] = gr_name + I["ninscrits_gr"] = ninscrits_gr + I["nbetuds"] = len(nt.etud_moy_gen_ranks) + I["nb_demissions"] = inscriptions_counts[scu.DEMISSION] + I["nb_defaillants"] = inscriptions_counts[scu.DEF] + if prefs["bul_show_rangs"]: + I["rang_nt"] = "%s / %d" % ( + rang, + inscriptions_counts[scu.INSCRIT], + ) + I["rang_txt"] = "Rang " + I["rang_nt"] + else: + I["rang_nt"], I["rang_txt"] = "", "" + I["note_max"] = 20.0 # notes toujours sur 20 + I["bonus_sport_culture"] = nt.bonus[etudid] if nt.bonus is not None else 0.0 + # Liste les UE / modules /evals + I["ues"] = [] + I["matieres_modules"] = {} + I["matieres_modules_capitalized"] = {} + for ue in ues: + u = ue.copy() + ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) + if ( + ModuleImplInscription.nb_inscriptions_dans_ue( + formsemestre_id, etudid, ue["ue_id"] + ) + == 0 + ) and not ue_status["is_capitalized"]: + # saute les UE où l'on est pas inscrit et n'avons pas de capitalisation + continue + + u["ue_status"] = ue_status # { 'moy', 'coef_ue', ...} + if ue["type"] != sco_codes_parcours.UE_SPORT: + u["cur_moy_ue_txt"] = scu.fmt_note(ue_status["cur_moy_ue"]) + else: + if nt.bonus is not None: + x = scu.fmt_note(nt.bonus[etudid], keep_numeric=True) + else: + x = "" + if isinstance(x, str): + if nt.bonus_ues is None: + u["cur_moy_ue_txt"] = "pas de bonus" + else: + u["cur_moy_ue_txt"] = "bonus appliqué sur les UEs" + else: + u["cur_moy_ue_txt"] = f"bonus de {fmt_note(x)} points" + if nt.bonus_ues is not None: + u["cur_moy_ue_txt"] += " (+ues)" + u["moy_ue_txt"] = scu.fmt_note(ue_status["moy"]) + if ue_status["coef_ue"] != None: + u["coef_ue_txt"] = scu.fmt_coef(ue_status["coef_ue"]) + else: + u["coef_ue_txt"] = "-" + + if ( + dpv + and dpv["decisions"][0]["decisions_ue"] + and ue["ue_id"] in dpv["decisions"][0]["decisions_ue"] + ): + u["ects"] = dpv["decisions"][0]["decisions_ue"][ue["ue_id"]]["ects"] + if ue["type"] == sco_codes_parcours.UE_ELECTIVE: + u["ects"] = ( + "%g+" % u["ects"] + ) # ajoute un "+" pour indiquer ECTS d'une UE élective + else: + if ue_status["is_capitalized"]: + u["ects"] = ue_status["ue"].get("ects", "-") + else: + u["ects"] = "-" + modules, ue_attente = _ue_mod_bulletin( + etudid, formsemestre_id, ue["ue_id"], modimpls, nt, version + ) + # + u["modules"] = modules # detail des modules de l'UE (dans le semestre courant) + # auparavant on filtrait les modules sans notes + # si ue_status['cur_moy_ue'] != 'NA' alors u['modules'] = [] (pas de moyenne => pas de modules) + + u[ + "modules_capitalized" + ] = [] # modules de l'UE capitalisée (liste vide si pas capitalisée) + if ue_status["is_capitalized"] and ue_status["formsemestre_id"] is not None: + sem_origin = FormSemestre.query.get(ue_status["formsemestre_id"]) + u[ + "ue_descr_txt" + ] = f'capitalisée le {ndb.DateISOtoDMY(ue_status["event_date"])}' + u["ue_descr_html"] = ( + f"""{u["ue_descr_txt"]} + """ + if sem_origin + else "" + ) + if ue_status["moy"] != "NA": + # détail des modules de l'UE capitalisée + formsemestre_cap = FormSemestre.query.get(ue_status["formsemestre_id"]) + nt_cap: NotesTableCompat = res_sem.load_formsemestre_results( + formsemestre_cap + ) + + u["modules_capitalized"], _ = _ue_mod_bulletin( + etudid, + formsemestre_id, + ue_status["capitalized_ue_id"], + nt_cap.get_modimpls_dict(), + nt_cap, + version, + ) + I["matieres_modules_capitalized"].update( + _sort_mod_by_matiere(u["modules_capitalized"], nt_cap, etudid) + ) + else: + if prefs["bul_show_ue_rangs"] and ue["type"] != sco_codes_parcours.UE_SPORT: + if ue_attente or nt.ue_rangs[ue["ue_id"]][0] is None: + u["ue_descr_txt"] = "%s/%s" % ( + scu.RANG_ATTENTE_STR, + nt.ue_rangs[ue["ue_id"]][1], + ) + else: + u["ue_descr_txt"] = "%s/%s" % ( + nt.ue_rangs[ue["ue_id"]][0][etudid], + nt.ue_rangs[ue["ue_id"]][1], + ) + u["ue_descr_html"] = u["ue_descr_txt"] + else: + u["ue_descr_txt"] = u["ue_descr_html"] = "" + + if ue_status["is_capitalized"] or modules: + I["ues"].append(u) # ne montre pas les UE si non inscrit + + # Accès par matieres + # En #sco92, pas d'information + I["matieres_modules"].update(_sort_mod_by_matiere(modules, nt, etudid)) + + # + C = make_context_dict(formsemestre, I["etud"]) + C.update(I) + # + # log( 'C = \n%s\n' % pprint.pformat(C) ) # tres pratique pour voir toutes les infos dispo + return C + + +def get_appreciations_list(formsemestre_id: int, etudid: int) -> dict: + """Appréciations pour cet étudiant dans ce semestre""" + cnx = ndb.GetDBConnexion() + apprecs = sco_etud.appreciations_list( + cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id} + ) + d = { + "appreciations_list": apprecs, + "appreciations_txt": [x["date"] + ": " + x["comment"] for x in apprecs], + } + # deprecated / keep it for backward compat in templates: + d["appreciations"] = d["appreciations_txt"] + return d + + +def _get_etud_etat_html(etat: str) -> str: + """chaine html représentant l'état (backward compat sco7)""" + if etat == scu.INSCRIT: + return "" + elif etat == scu.DEMISSION: + return ' (DEMISSIONNAIRE) ' + elif etat == scu.DEF: + return ' (DEFAILLANT) ' + else: + return f' ({etat}) ' + + +def _sort_mod_by_matiere(modlist, nt, etudid): + matmod = {} # { matiere_id : [] } + for mod in modlist: + matiere_id = mod["module"]["matiere_id"] + if matiere_id not in matmod: + moy = nt.get_etud_mat_moy(matiere_id, etudid) + matmod[matiere_id] = { + "titre": mod["mat"]["titre"], + "modules": mod, + "moy": moy, + "moy_txt": scu.fmt_note(moy), + } + return matmod + + +def _ue_mod_bulletin( + etudid, formsemestre_id, ue_id, modimpls, nt: NotesTableCompat, version +): + """Infos sur les modules (et évaluations) dans une UE + (ajoute les informations aux modimpls) + Result: liste de modules de l'UE avec les infos dans chacun (seulement ceux où l'étudiant est inscrit). + """ + bul_show_mod_rangs = sco_preferences.get_preference( + "bul_show_mod_rangs", formsemestre_id + ) + bul_show_abs_modules = sco_preferences.get_preference( + "bul_show_abs_modules", formsemestre_id + ) + if bul_show_abs_modules: + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + debut_sem = ndb.DateDMYtoISO(sem["date_debut"]) + fin_sem = ndb.DateDMYtoISO(sem["date_fin"]) + + ue_modimpls = [mod for mod in modimpls if mod["module"]["ue_id"] == ue_id] + mods = [] # result + ue_attente = False # true si une eval en attente dans cette UE + for modimpl in ue_modimpls: + mod_attente = False + mod = modimpl.copy() + mod_moy = nt.get_etud_mod_moy( + modimpl["moduleimpl_id"], etudid + ) # peut etre 'NI' + is_malus = mod["module"]["module_type"] == ModuleType.MALUS + if bul_show_abs_modules: + nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem) + mod_abs = [nbabs, nbabsjust] + mod["mod_abs_txt"] = scu.fmt_abs(mod_abs) + else: + mod["mod_abs_txt"] = "" + + mod["mod_moy_txt"] = scu.fmt_note(mod_moy) + if mod["mod_moy_txt"][:2] == "NA": + mod["mod_moy_txt"] = "-" + if is_malus: + if isinstance(mod_moy, str): + mod["mod_moy_txt"] = "-" + mod["mod_coef_txt"] = "-" + elif mod_moy > 0: + mod["mod_moy_txt"] = scu.fmt_note(mod_moy) + mod["mod_coef_txt"] = "Malus" + elif mod_moy < 0: + mod["mod_moy_txt"] = scu.fmt_note(-mod_moy) + mod["mod_coef_txt"] = "Bonus" + else: + mod["mod_moy_txt"] = "-" + mod["mod_coef_txt"] = "-" + else: + mod["mod_coef_txt"] = scu.fmt_coef(modimpl["module"]["coefficient"]) + if mod["mod_moy_txt"] != "NI": # ne montre pas les modules 'non inscrit' + mods.append(mod) + if is_malus: # n'affiche pas les statistiques sur les modules malus + mod["stats"] = { + "moy": "", + "max": "", + "min": "", + "nb_notes": "", + "nb_missing": "", + "nb_valid_evals": "", + } + else: + mod["stats"] = nt.get_mod_stats(modimpl["moduleimpl_id"]) + mod["mod_descr_txt"] = "Module %s, coef. %s (%s)" % ( + modimpl["module"]["titre"], + scu.fmt_coef(modimpl["module"]["coefficient"]), + sco_users.user_info(modimpl["responsable_id"])["nomcomplet"], + ) + link_mod = ( + '' + % (modimpl["moduleimpl_id"], mod["mod_descr_txt"]) + ) + if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id): + mod["code"] = modimpl["module"]["code"] + mod["code_html"] = link_mod + (mod["code"] or "") + "" + else: + mod["code"] = mod["code_html"] = "" + mod["name"] = ( + modimpl["module"]["abbrev"] or modimpl["module"]["titre"] or "" + ) + mod["name_html"] = link_mod + mod["name"] + "" + + mod_descr = "Module %s, coef. %s (%s)" % ( + modimpl["module"]["titre"], + scu.fmt_coef(modimpl["module"]["coefficient"]), + sco_users.user_info(modimpl["responsable_id"])["nomcomplet"], + ) + link_mod = ( + '' + % (modimpl["moduleimpl_id"], mod_descr) + ) + if sco_preferences.get_preference("bul_show_codemodules", formsemestre_id): + mod["code_txt"] = modimpl["module"]["code"] or "" + mod["code_html"] = link_mod + mod["code_txt"] + "" + else: + mod["code_txt"] = "" + mod["code_html"] = "" + # Evaluations: notes de chaque eval + evals = nt.get_evals_in_mod(modimpl["moduleimpl_id"]) + mod["evaluations"] = [] + for e in evals: + e = e.copy() + if e["visibulletin"] or version == "long": + # affiche "bonus" quand les points de malus sont négatifs + if is_malus: + val = e["notes"].get(etudid, {"value": "NP"})[ + "value" + ] # NA si etud demissionnaire + if val == "NP" or val > 0: + e["name"] = "Points de malus sur cette UE" + else: + e["name"] = "Points de bonus sur cette UE" + else: + e["name"] = e["description"] or f"le {e['jour']}" + e["target_html"] = url_for( + "notes.evaluation_listenotes", + scodoc_dept=g.scodoc_dept, + evaluation_id=e["evaluation_id"], + format="html", + tf_submitted=1, + ) + e[ + "name_html" + ] = f"""{e['name']}""" + val = e["notes"].get(etudid, {"value": "NP"})["value"] + # val est NP si etud demissionnaire + if val == "NP": + e["note_txt"] = "nd" + e["note_html"] = 'nd' + e["coef_txt"] = scu.fmt_coef(e["coefficient"]) + else: + # (-0.15) s'affiche "bonus de 0.15" + if is_malus: + val = abs(val) + e["note_txt"] = scu.fmt_note(val, note_max=e["note_max"]) + e["note_html"] = e["note_txt"] + if is_malus: + e["coef_txt"] = "" + else: + e["coef_txt"] = scu.fmt_coef(e["coefficient"]) + if e["evaluation_type"] == scu.EVALUATION_RATTRAPAGE: + e["coef_txt"] = "rat." + elif e["evaluation_type"] == scu.EVALUATION_SESSION2: + e["coef_txt"] = "Ses. 2" + if e["etat"]["evalattente"]: + mod_attente = True # une eval en attente dans ce module + if ((not is_malus) or (val != "NP")) and ( + ( + e["evaluation_type"] == scu.EVALUATION_NORMALE + or not np.isnan(val) + ) + ): + # ne liste pas les eval malus sans notes + # ni les rattrapages et sessions 2 si pas de note + mod["evaluations"].append(e) + + # Evaluations incomplètes ou futures: + mod["evaluations_incompletes"] = [] + if sco_preferences.get_preference("bul_show_all_evals", formsemestre_id): + complete_eval_ids = set([e["evaluation_id"] for e in evals]) + all_evals = sco_evaluation_db.do_evaluation_list( + args={"moduleimpl_id": modimpl["moduleimpl_id"]} + ) + all_evals.reverse() # plus ancienne d'abord + for e in all_evals: + if e["evaluation_id"] not in complete_eval_ids: + e = e.copy() + mod["evaluations_incompletes"].append(e) + e["name"] = (e["description"] or "") + " (%s)" % e["jour"] + e["target_html"] = url_for( + "notes.evaluation_listenotes", + scodoc_dept=g.scodoc_dept, + evaluation_id=e["evaluation_id"], + tf_submitted=1, + format="html", + ) + e["name_html"] = '%s' % ( + e["target_html"], + e["name"], + ) + e["note_txt"] = e["note_html"] = "" + e["coef_txt"] = scu.fmt_coef(e["coefficient"]) + # Classement + if ( + bul_show_mod_rangs + and (nt.mod_rangs is not None) + and mod["mod_moy_txt"] != "-" + and not is_malus + ): + rg = nt.mod_rangs[modimpl["moduleimpl_id"]] + if rg[0] is None: + mod["mod_rang_txt"] = "" + else: + if mod_attente: # nt.get_moduleimpls_attente(): + mod["mod_rang"] = scu.RANG_ATTENTE_STR + else: + mod["mod_rang"] = rg[0][etudid] + mod["mod_eff"] = rg[1] # effectif dans ce module + mod["mod_rang_txt"] = "%s/%s" % (mod["mod_rang"], mod["mod_eff"]) + else: + mod["mod_rang_txt"] = "" + if mod_attente: + ue_attente = True + return mods, ue_attente + + +def get_etud_rangs_groups( + etudid: int, partitions, partitions_etud_groups, nt: NotesTableCompat +): + """Ramene rang et nb inscrits dans chaque partition""" + rang_gr, ninscrits_gr, gr_name = {}, {}, {} + for partition in partitions: + if partition["partition_name"] != None: + partition_id = partition["partition_id"] + + if etudid in partitions_etud_groups[partition_id]: + group = partitions_etud_groups[partition_id][etudid] + + ( + rang_gr[partition_id], + ninscrits_gr[partition_id], + ) = nt.get_etud_rang_group(etudid, group["group_id"]) + gr_name[partition_id] = group["group_name"] + else: # etudiant non present dans cette partition + rang_gr[partition_id], ninscrits_gr[partition_id] = "", "" + gr_name[partition_id] = "" + + return rang_gr, ninscrits_gr, gr_name + + +def etud_descr_situation_semestre( + etudid, + formsemestre_id, + ne="", + format="html", # currently unused + show_decisions=True, + show_uevalid=True, + show_date_inscr=True, + show_mention=False, +): + """Dict décrivant la situation de l'étudiant dans ce semestre. + Si format == 'html', peut inclure du balisage html (actuellement inutilisé) + + situation : chaine résumant en français la situation de l'étudiant. + Par ex. "Inscrit le 31/12/1999. Décision jury: Validé. ..." + + date_inscription : (vide si show_date_inscr est faux) + date_demission : (vide si pas demission ou si show_date_inscr est faux) + descr_inscription : "Inscrit" ou "Pas inscrit[e]" + descr_demission : "Démission le 01/02/2000" ou vide si pas de démission + descr_defaillance : "Défaillant" ou vide si non défaillant. + decision_jury : "Validé", "Ajourné", ... (code semestre) + descr_decision_jury : "Décision jury: Validé" (une phrase) + decision_sem : + decisions_ue : noms (acronymes) des UE validées, séparées par des virgules. + descr_decisions_ue : ' UE acquises: UE1, UE2', ou vide si pas de dec. ou si pas show_uevalid + descr_mention : 'Mention Bien', ou vide si pas de mention ou si pas show_mention + """ + # Fonction utilisée par tous les bulletins (APC ou classiques) + cnx = ndb.GetDBConnexion() + infos = scu.DictDefault(defaultvalue="") + + # --- Situation et décisions jury + # démission/inscription ? + events = sco_etud.scolar_events_list( + cnx, args={"etudid": etudid, "formsemestre_id": formsemestre_id} + ) + date_inscr = None + date_dem = None + date_def = None + for event in events: + event_type = event["event_type"] + if event_type == "INSCRIPTION": + if date_inscr: + # plusieurs inscriptions ??? + # date_inscr += ', ' + event['event_date'] + ' (!)' + # il y a eu une erreur qui a laissé un event 'inscription' + # on l'efface: + log( + f"etud_descr_situation_semestre: removing duplicate INSCRIPTION event for etudid={etudid} !" + ) + sco_etud.scolar_events_delete(cnx, event["event_id"]) + else: + date_inscr = event["event_date"] + elif event_type == "DEMISSION": + # assert date_dem == None, 'plusieurs démissions !' + if date_dem: # cela ne peut pas arriver sauf bug (signale a Evry 2013?) + log( + f"etud_descr_situation_semestre: removing duplicate DEMISSION event for etudid={etudid} !" + ) + sco_etud.scolar_events_delete(cnx, event["event_id"]) + else: + date_dem = event["event_date"] + elif event_type == "DEFAILLANCE": + if date_def: + log( + f"etud_descr_situation_semestre: removing duplicate DEFAILLANCE event for etudid={etudid} !" + ) + sco_etud.scolar_events_delete(cnx, event["event_id"]) + else: + date_def = event["event_date"] + if show_date_inscr: + if not date_inscr: + infos["date_inscription"] = "" + infos["descr_inscription"] = f"Pas inscrit{ne}" + else: + infos["date_inscription"] = date_inscr + infos["descr_inscription"] = f"Inscrit{ne} le {date_inscr}" + else: + infos["date_inscription"] = "" + infos["descr_inscription"] = "" + + infos["descr_defaillance"] = "" + + # Décision: valeurs par defaut vides: + infos["decision_jury"] = infos["descr_decision_jury"] = "" + infos["decision_sem"] = "" + infos["decisions_ue"] = infos["descr_decisions_ue"] = "" + infos["descr_decisions_niveaux"] = infos["descr_decisions_rcue"] = "" + infos["descr_decision_annee"] = "" + + if date_dem: + infos["descr_demission"] = f"Démission le {date_dem}." + infos["date_demission"] = date_dem + infos["decision_jury"] = infos["descr_decision_jury"] = "Démission" + infos["situation"] = ". ".join( + [x for x in [infos["descr_inscription"], infos["descr_demission"]] if x] + ) + return infos, None # ne donne pas les dec. de jury pour les demissionnaires + if date_def: + infos["descr_defaillance"] = f"Défaillant{ne}" + infos["date_defaillance"] = date_def + infos["descr_decision_jury"] = f"Défaillant{ne}" + + dpv = sco_pvjury.dict_pvjury(formsemestre_id, etudids=[etudid]) + if dpv: + infos["decision_sem"] = dpv["decisions"][0]["decision_sem"] + + if not show_decisions: + return infos, dpv + + # Décisions de jury: + pv = dpv["decisions"][0] + descr_dec = "" + if pv["decision_sem_descr"]: + infos["decision_jury"] = pv["decision_sem_descr"] + infos["descr_decision_jury"] = "Décision jury: " + pv["decision_sem_descr"] + descr_dec = infos["descr_decision_jury"] + else: + infos["descr_decision_jury"] = "" + infos["decision_jury"] = "" + + if pv["decisions_ue_descr"] and show_uevalid: + infos["decisions_ue"] = pv["decisions_ue_descr"] + infos["descr_decisions_ue"] = " UE acquises: " + pv["decisions_ue_descr"] + else: + infos["decisions_ue"] = "" + infos["descr_decisions_ue"] = "" + + infos["mention"] = pv["mention"] + if pv["mention"] and show_mention: + descr_mention = f"Mention {pv['mention']}" + else: + descr_mention = "" + + # Décisions APC / BUT + if pv.get("decision_annee", {}): + infos["descr_decision_annee"] = "Décision année: " + pv.get( + "decision_annee", {} + ).get("code", "") + else: + infos["descr_decision_annee"] = "" + + infos["descr_decisions_rcue"] = pv.get("descr_decisions_rcue", "") + infos["descr_decisions_niveaux"] = pv.get("descr_decisions_niveaux", "") + + descr_autorisations = "" + if not pv["validation_parcours"]: # parcours non terminé + if pv["autorisations_descr"]: + descr_autorisations = ( + f"Autorisé à s'inscrire en {pv['autorisations_descr']}." + ) + else: + descr_dec += " Diplôme obtenu." + _format_situation_fields( + infos, + [ + "descr_inscription", + "descr_defaillance", + "descr_decisions_ue", + "descr_decision_annee", + ], + [descr_dec, descr_mention, descr_autorisations], + ) + + return infos, dpv + + +def _format_situation_fields( + infos, field_names: list[str], extra_values: list[str] +) -> None: + """Réuni les champs pour former le paragraphe "situation", et ajoute la pontuation aux champs.""" + infos["situation"] = ". ".join( + x + for x in [infos.get(field_name, "") for field_name in field_names] + + [field for field in extra_values if field] + if x + ) + for field_name in field_names: + field = infos.get(field_name, "") + if field and not field.endswith("."): + infos[field_name] += "." + + +# ------ Page bulletin +def formsemestre_bulletinetud( + etud: Identite = None, + formsemestre_id=None, + format=None, + version="long", + xml_with_decisions=False, + force_publishing=False, # force publication meme si semestre non publie sur "portail" + prefer_mail_perso=False, +): + """Page bulletin de notes pour + - HTML des formations classiques (non BUT) + - le format "oldjson" (les "json" sont générés à part, voir get_formsemestre_bulletin_etud_json) + - les formats PDF, XML et mail pdf (toutes formations) + + Note: le format XML n'est plus maintenu et pour les BUT ne contient pas + toutes les informations. Privilégier le format JSON. + + Paramètres: + - version: pour les formations classqiues, versions short/selectedevals/long + - xml_with_decisions: inclue ou non les + - force_publishing: renvoie le bulletin même si semestre non publie sur "portail" + - prefer_mail_perso: pour pdfmail, utilise adresse mail perso en priorité. + + """ + format = format or "html" + formsemestre: FormSemestre = FormSemestre.query.get(formsemestre_id) + if not formsemestre: + raise ScoValueError(f"semestre {formsemestre_id} inconnu !") + + bulletin = do_formsemestre_bulletinetud( + formsemestre, + etud.id, + format=format, + version=version, + xml_with_decisions=xml_with_decisions, + force_publishing=force_publishing, + prefer_mail_perso=prefer_mail_perso, + )[0] + if format not in {"html", "pdfmail"}: + filename = scu.bul_filename(formsemestre, etud, format) + return scu.send_file(bulletin, filename, mime=scu.get_mime_suffix(format)[0]) + elif format == "pdfmail": + return "" + H = [ + _formsemestre_bulletinetud_header_html(etud, formsemestre, format, version), + bulletin, + render_template( + "bul_foot.html", + appreciations=None, # déjà affichées + css_class="bul_classic_foot", + etud=etud, + formsemestre=formsemestre, + inscription_courante=etud.inscription_courante(), + inscription_str=etud.inscription_descr()["inscription_str"], + ), + html_sco_header.sco_footer(), + ] + + return "".join(H) + + +def can_send_bulletin_by_mail(formsemestre_id): + """True if current user is allowed to send a bulletin by mail""" + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + return ( + sco_preferences.get_preference("bul_mail_allowed_for_all", formsemestre_id) + or current_user.has_permission(Permission.ScoImplement) + or current_user.id in sem["responsables"] + ) + + +def do_formsemestre_bulletinetud( + formsemestre: FormSemestre, + etudid: int, + version="long", # short, long, selectedevals + format=None, + xml_with_decisions=False, # force décisions dans XML + force_publishing=False, # force publication meme si semestre non publié sur "portail" + prefer_mail_perso=False, # mails envoyés sur adresse perso si non vide +): + """Génère le bulletin au format demandé. + Utilisé pour: + - HTML des formations classiques (non BUT) + - le format "oldjson" (les json sont générés à part, voir get_formsemestre_bulletin_etud_json) + - les formats PDF, XML et mail pdf (toutes formations) + + Résultat: (bul, filigranne) + où bul est str ou bytes au format demandé (html, pdf, pdfmail, pdfpart, xml, json) + et filigranne est un message à placer en "filigranne" (eg "Provisoire"). + """ + format = format or "html" + if format == "xml": + bul = sco_bulletins_xml.make_xml_formsemestre_bulletinetud( + formsemestre.id, + etudid, + xml_with_decisions=xml_with_decisions, + force_publishing=force_publishing, + version=version, + ) + + return bul, "" + + elif format == "json": # utilisé pour classic et "oldjson" + bul = sco_bulletins_json.make_json_formsemestre_bulletinetud( + formsemestre.id, + etudid, + xml_with_decisions=xml_with_decisions, + force_publishing=force_publishing, + version=version, + ) + return bul, "" + + if formsemestre.formation.is_apc(): + etudiant = Identite.query.get(etudid) + r = bulletin_but.BulletinBUT(formsemestre) + infos = r.bulletin_etud_complet(etudiant, version=version) + else: + infos = formsemestre_bulletinetud_dict(formsemestre.id, etudid) + etud = infos["etud"] + + if format == "html": + htm, _ = sco_bulletins_generator.make_formsemestre_bulletinetud( + infos, version=version, format="html" + ) + return htm, infos["filigranne"] + + elif format == "pdf" or format == "pdfpart": + bul, filename = sco_bulletins_generator.make_formsemestre_bulletinetud( + infos, + version=version, + format="pdf", + stand_alone=(format != "pdfpart"), + ) + if format == "pdf": + return ( + scu.sendPDFFile(bul, filename), + infos["filigranne"], + ) # unused ret. value + else: + return bul, infos["filigranne"] + + elif format == "pdfmail": + # format pdfmail: envoie le pdf par mail a l'etud, et affiche le html + # check permission + if not can_send_bulletin_by_mail(formsemestre.id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + + pdfdata, filename = sco_bulletins_generator.make_formsemestre_bulletinetud( + infos, version=version, format="pdf" + ) + + if prefer_mail_perso: + recipient_addr = etud.get("emailperso", "") or etud.get("email", "") + else: + recipient_addr = etud.get("email", "") or etud.get("emailperso", "") + + if not recipient_addr: + flash(f"{etud['nomprenom']} n'a pas d'adresse e-mail !") + return False, infos["filigranne"] + else: + mail_bulletin(formsemestre.id, infos, pdfdata, filename, recipient_addr) + flash(f"mail envoyé à {recipient_addr}") + + return True, infos["filigranne"] + + raise ValueError("do_formsemestre_bulletinetud: invalid format (%s)" % format) + + +def mail_bulletin(formsemestre_id, infos, pdfdata, filename, recipient_addr): + """Send bulletin by email to etud + If bul_mail_list_abs pref is true, put list of absences in mail body (text). + """ + etud = infos["etud"] + webmaster = sco_preferences.get_preference("bul_mail_contact_addr", formsemestre_id) + dept = scu.unescape_html( + sco_preferences.get_preference("DeptName", formsemestre_id) + ) + copy_addr = sco_preferences.get_preference("email_copy_bulletins", formsemestre_id) + intro_mail = sco_preferences.get_preference("bul_intro_mail", formsemestre_id) + + if intro_mail: + try: + hea = intro_mail % { + "nomprenom": etud["nomprenom"], + "dept": dept, + "webmaster": webmaster, + } + except KeyError as e: + raise ScoValueError( + "format 'Message d'accompagnement' (bul_intro_mail) invalide, revoir les réglages dans les préférences" + ) from e + else: + hea = "" + + if sco_preferences.get_preference("bul_mail_list_abs"): + hea += "\n\n" + sco_abs_views.ListeAbsEtud( + etud["etudid"], with_evals=False, format="text" + ) + + subject = f"""Relevé de notes de {etud["nomprenom"]}""" + recipients = [recipient_addr] + sender = sco_preferences.get_preference("email_from_addr", formsemestre_id) + if copy_addr: + bcc = copy_addr.strip() + else: + bcc = "" + + # Attach pdf + log(f"""mail bulletin a {recipient_addr}""") + email.send_email( + subject, + sender, + recipients, + bcc=[bcc], + text_body=hea, + attachments=[ + {"filename": filename, "mimetype": scu.PDF_MIMETYPE, "data": pdfdata} + ], + ) + + +def make_menu_autres_operations( + formsemestre: FormSemestre, etud: Identite, endpoint: str, version: str +) -> str: + etud_email = etud.get_first_email() or "" + etud_perso = etud.get_first_email("emailperso") or "" + menu_items = [ + { + "title": "Réglages bulletins", + "endpoint": "notes.formsemestre_edit_options", + "args": { + "formsemestre_id": formsemestre.id, + # "target_url": url_for( + # "notes.formsemestre_bulletinetud", + # scodoc_dept=g.scodoc_dept, + # formsemestre_id=formsemestre_id, + # etudid=etudid, + # ), + }, + "enabled": formsemestre.can_be_edited_by(current_user), + }, + { + "title": 'Version papier (pdf, format "%s")' + % sco_bulletins_generator.bulletin_get_class_name_displayed( + formsemestre.id + ), + "endpoint": endpoint, + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + "version": version, + "format": "pdf", + }, + }, + { + "title": f"Envoi par mail à {etud_email}", + "endpoint": endpoint, + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + "version": version, + "format": "pdfmail", + }, + # possible slt si on a un mail... + "enabled": etud_email and can_send_bulletin_by_mail(formsemestre.id), + }, + { + "title": f"Envoi par mail à {etud_perso} (adr. personnelle)", + "endpoint": endpoint, + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + "version": version, + "format": "pdfmail", + "prefer_mail_perso": 1, + }, + # possible slt si on a un mail... + "enabled": etud_perso and can_send_bulletin_by_mail(formsemestre.id), + }, + { + "title": "Version json", + "endpoint": endpoint, + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + "version": version, + "format": "json", + }, + }, + { + "title": "Version XML", + "endpoint": endpoint, + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + "version": version, + "format": "xml", + }, + }, + { + "title": "Ajouter une appréciation", + "endpoint": "notes.appreciation_add_form", + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + }, + "enabled": ( + formsemestre.can_be_edited_by(current_user) + or current_user.has_permission(Permission.ScoEtudInscrit) + ), + }, + { + "title": "Enregistrer un semestre effectué ailleurs", + "endpoint": "notes.formsemestre_ext_create_form", + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + }, + "enabled": current_user.has_permission(Permission.ScoImplement), + }, + { + "title": "Enregistrer une validation d'UE antérieure", + "endpoint": "notes.formsemestre_validate_previous_ue", + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + }, + "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), + }, + { + "title": "Enregistrer note d'une UE externe", + "endpoint": "notes.external_ue_create_form", + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + }, + "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), + }, + { + "title": "Entrer décisions jury", + "endpoint": "notes.formsemestre_validation_etud_form", + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + }, + "enabled": sco_permissions_check.can_validate_sem(formsemestre.id), + }, + { + "title": "Éditer PV jury", + "endpoint": "notes.formsemestre_pvjury_pdf", + "args": { + "formsemestre_id": formsemestre.id, + "etudid": etud.id, + }, + "enabled": True, + }, + ] + return htmlutils.make_menu("Autres opérations", menu_items, alone=True) + + +def _formsemestre_bulletinetud_header_html( + etud, + formsemestre: FormSemestre, + format=None, + version=None, +): + H = [ + html_sco_header.sco_header( + page_title=f"Bulletin de {etud.nomprenom}", + javascripts=[ + "js/bulletin.js", + "libjs/d3.v3.min.js", + "js/radar_bulletin.js", + ], + cssstyles=["css/radar_bulletin.css"], + ), + render_template( + "bul_head.html", + etud=etud, + format=format, + formsemestre=formsemestre, + menu_autres_operations=make_menu_autres_operations( + etud=etud, + formsemestre=formsemestre, + endpoint="notes.formsemestre_bulletinetud", + version=version, + ), + scu=scu, + time=time, + version=version, + ), + ] + return "\n".join(H) diff --git a/app/scodoc/sco_cursus_dut.py b/app/scodoc/sco_cursus_dut.py index ca2ecc8d..32d4796b 100644 --- a/app/scodoc/sco_cursus_dut.py +++ b/app/scodoc/sco_cursus_dut.py @@ -1,1014 +1,1014 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Semestres: gestion parcours DUT (Arreté du 13 août 2005) -""" - -from app import db -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre, UniteEns, ScolarAutorisationInscription - -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app import log -from app.scodoc.scolog import logdb -from app.scodoc import sco_cache, sco_etud -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formations -from app.scodoc.sco_codes_parcours import ( - CMP, - ADC, - ADJ, - ADM, - AJ, - ATT, - NO_SEMESTRE_ID, - BUG, - NEXT, - NEXT2, - NEXT_OR_NEXT2, - REO, - REDOANNEE, - REDOSEM, - RA_OR_NEXT, - RA_OR_RS, - RS_OR_NEXT, - CODES_SEM_VALIDES, - NOTES_BARRE_GEN_COMPENSATION, - code_semestre_attente, - code_semestre_validant, -) -from app.scodoc.dutrules import DUTRules # regles generees a partir du CSV -from app.scodoc.sco_exceptions import ScoValueError - - -class DecisionSem(object): - "Decision prenable pour un semestre" - - def __init__( - self, - code_etat=None, - code_etat_ues={}, # { ue_id : code } - new_code_prev="", - explication="", # aide pour le jury - formsemestre_id_utilise_pour_compenser=None, # None si code != ADC - devenir=None, # code devenir - assiduite=True, - rule_id=None, # id regle correspondante - ): - self.code_etat = code_etat - self.code_etat_ues = code_etat_ues - self.new_code_prev = new_code_prev - self.explication = explication - self.formsemestre_id_utilise_pour_compenser = ( - formsemestre_id_utilise_pour_compenser - ) - self.devenir = devenir - self.assiduite = assiduite - self.rule_id = rule_id - # code unique (string) utilise pour la gestion du formulaire - self.codechoice = ( - "C" # prefix pour éviter que Flask le considère comme int - + str( - hash( - ( - code_etat, - new_code_prev, - formsemestre_id_utilise_pour_compenser, - devenir, - assiduite, - ) - ) - ) - ) - - -class SituationEtudCursus: - "Semestre dans un cursus" - pass - - -class SituationEtudCursusClassic(SituationEtudCursus): - "Semestre dans un parcours" - - def __init__(self, etud: dict, formsemestre_id: int, nt: NotesTableCompat): - """ - etud: dict filled by fill_etuds_info() - """ - self.etud = etud - self.etudid = etud["etudid"] - self.formsemestre_id = formsemestre_id - self.sem = sco_formsemestre.get_formsemestre(formsemestre_id) - self.nt: NotesTableCompat = nt - self.formation = self.nt.formsemestre.formation - self.parcours = self.nt.parcours - # Ce semestre est-il le dernier de la formation ? (e.g. semestre 4 du DUT) - # pour le DUT, le dernier est toujours S4. - # Ici: terminal si semestre == NB_SEM ou bien semestre_id==-1 - # (licences et autres formations en 1 seule session)) - self.semestre_non_terminal = self.sem["semestre_id"] != self.parcours.NB_SEM - if self.sem["semestre_id"] == NO_SEMESTRE_ID: - self.semestre_non_terminal = False - # Liste des semestres du parcours de cet étudiant: - self._comp_semestres() - # Determine le semestre "precedent" - self.prev_formsemestre_id = self._search_prev() - # Verifie barres - self._comp_barres() - # Verifie compensation - if self.prev and self.sem["gestion_compensation"]: - self.can_compensate_with_prev = self.prev["can_compensate"] - else: - self.can_compensate_with_prev = False - - def get_possible_choices(self, assiduite=True): - """Donne la liste des décisions possibles en jury (hors décisions manuelles) - (liste d'instances de DecisionSem) - assiduite = True si pas de probleme d'assiduité - """ - choices = [] - if self.prev_decision: - prev_code_etat = self.prev_decision["code"] - else: - prev_code_etat = None - - state = ( - prev_code_etat, - assiduite, - self.barre_moy_ok, - self.barres_ue_ok, - self.can_compensate_with_prev, - self.semestre_non_terminal, - ) - # log('get_possible_choices: state=%s' % str(state) ) - for rule in DUTRules: - # Saute codes non autorisés dans ce parcours (eg ATT en LP) - if rule.conclusion[0] in self.parcours.UNUSED_CODES: - continue - # Saute regles REDOSEM si pas de semestres decales: - if (not self.sem["gestion_semestrielle"]) and rule.conclusion[ - 3 - ] == "REDOSEM": - continue - if rule.match(state): - if rule.conclusion[0] == ADC: - # dans les regles on ne peut compenser qu'avec le PRECEDENT: - fiduc = self.prev_formsemestre_id - assert fiduc - else: - fiduc = None - # Detection d'incoherences (regles BUG) - if rule.conclusion[5] == BUG: - log("get_possible_choices: inconsistency: state=%s" % str(state)) - # - # valid_semestre = code_semestre_validant(rule.conclusion[0]) - choices.append( - DecisionSem( - code_etat=rule.conclusion[0], - new_code_prev=rule.conclusion[2], - devenir=rule.conclusion[3], - formsemestre_id_utilise_pour_compenser=fiduc, - explication=rule.conclusion[5], - assiduite=assiduite, - rule_id=rule.rule_id, - ) - ) - return choices - - def explique_devenir(self, devenir): - "Phrase d'explication pour le code devenir" - if not devenir: - return "" - s = self.sem["semestre_id"] # numero semestre courant - if s < 0: # formation sans semestres (eg licence) - next_s = 1 - else: - next_s = self._get_next_semestre_id() - # log('s=%s next=%s' % (s, next_s)) - SA = self.parcours.SESSION_ABBRV # 'S' ou 'A' - if self.semestre_non_terminal and not self.all_other_validated(): - passage = "Passe en %s%s" % (SA, next_s) - else: - passage = "Formation terminée" - if devenir == NEXT: - return passage - elif devenir == REO: - return "Réorienté" - elif devenir == REDOANNEE: - return "Redouble année (recommence %s%s)" % (SA, (s - 1)) - elif devenir == REDOSEM: - return "Redouble semestre (recommence en %s%s)" % (SA, s) - elif devenir == RA_OR_NEXT: - return passage + ", ou redouble année (en %s%s)" % (SA, (s - 1)) - elif devenir == RA_OR_RS: - return "Redouble semestre %s%s, ou redouble année (en %s%s)" % ( - SA, - s, - SA, - s - 1, - ) - elif devenir == RS_OR_NEXT: - return passage + ", ou semestre %s%s" % (SA, s) - elif devenir == NEXT_OR_NEXT2: - return passage + ", ou en semestre %s%s" % ( - SA, - s + 2, - ) # coherent avec get_next_semestre_ids - elif devenir == NEXT2: - return "Passe en %s%s" % (SA, s + 2) - else: - log("explique_devenir: code devenir inconnu: %s" % devenir) - return "Code devenir inconnu !" - - def all_other_validated(self): - "True si tous les autres semestres de cette formation sont validés" - return self._sems_validated(exclude_current=True) - - def sem_idx_is_validated(self, semestre_id): - "True si le semestre d'indice indiqué est validé dans ce parcours" - return self._sem_list_validated(set([semestre_id])) - - def parcours_validated(self): - "True si parcours validé (diplôme obtenu, donc)." - return self._sems_validated() - - def _sems_validated(self, exclude_current=False): - "True si semestres du parcours validés" - if self.sem["semestre_id"] == NO_SEMESTRE_ID: - # mono-semestre: juste celui ci - decision = self.nt.get_etud_decision_sem(self.etudid) - return decision and code_semestre_validant(decision["code"]) - else: - to_validate = set( - range(1, self.parcours.NB_SEM + 1) - ) # ensemble des indices à valider - if exclude_current and self.sem["semestre_id"] in to_validate: - to_validate.remove(self.sem["semestre_id"]) - return self._sem_list_validated(to_validate) - - def can_jump_to_next2(self): - """True si l'étudiant peut passer directement en Sn+2 (eg de S2 en S4). - Il faut donc que tous les semestres 1...n-1 soient validés et que n+1 soit en attente. - (et que le sem courant n soit validé, ce qui n'est pas testé ici) - """ - n = self.sem["semestre_id"] - if not self.sem["gestion_semestrielle"]: - return False # pas de semestre décalés - if n == NO_SEMESTRE_ID or n > self.parcours.NB_SEM - 2: - return False # n+2 en dehors du parcours - if self._sem_list_validated(set(range(1, n))): - # antérieurs validé, teste suivant - n1 = n + 1 - for sem in self.get_semestres(): - if ( - sem["semestre_id"] == n1 - and sem["formation_code"] == self.formation.formation_code - ): - formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results( - formsemestre - ) - decision = nt.get_etud_decision_sem(self.etudid) - if decision and ( - code_semestre_validant(decision["code"]) - or code_semestre_attente(decision["code"]) - ): - return True - return False - - def _sem_list_validated(self, sem_idx_set): - """True si les semestres dont les indices sont donnés en argument (modifié) - sont validés. En sortie, sem_idx_set contient ceux qui n'ont pas été validés.""" - for sem in self.get_semestres(): - if sem["formation_code"] == self.formation.formation_code: - formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - decision = nt.get_etud_decision_sem(self.etudid) - if decision and code_semestre_validant(decision["code"]): - # validé - sem_idx_set.discard(sem["semestre_id"]) - - return not sem_idx_set - - def _comp_semestres(self): - # etud['sems'] est trie par date decroissante (voir fill_etuds_info) - if not "sems" in self.etud: - self.etud["sems"] = sco_etud.etud_inscriptions_infos( - self.etud["etudid"], self.etud["ne"] - )["sems"] - sems = self.etud["sems"][:] # copy - sems.reverse() - # Nb max d'UE et acronymes - ue_acros = {} # acronyme ue : 1 - nb_max_ue = 0 - for sem in sems: - formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - ues = nt.get_ues_stat_dict(filter_sport=True) - for ue in ues: - ue_acros[ue["acronyme"]] = 1 - nb_ue = len(ues) - if nb_ue > nb_max_ue: - nb_max_ue = nb_ue - # add formation_code to each sem: - sem["formation_code"] = sco_formations.formation_list( - args={"formation_id": sem["formation_id"]} - )[0]["formation_code"] - # si sem peut servir à compenser le semestre courant, positionne - # can_compensate - sem["can_compensate"] = self.check_compensation_dut(sem, nt) - - self.ue_acros = list(ue_acros.keys()) - self.ue_acros.sort() - self.nb_max_ue = nb_max_ue - self.sems = sems - - def get_semestres(self): - """Liste des semestres dans lesquels a été inscrit - l'étudiant (quelle que soit la formation), le plus ancien en tête""" - return self.sems - - def get_parcours_descr(self, filter_futur=False): - """Description brève du parcours: "S1, S2, ..." - Si filter_futur, ne mentionne pas les semestres qui sont après le semestre courant. - """ - cur_begin_date = self.sem["dateord"] - p = [] - for s in self.sems: - if s["ins"]["etat"] == "D": - dem = " (dem.)" - else: - dem = "" - if filter_futur and s["dateord"] > cur_begin_date: - continue # skip semestres demarrant apres le courant - SA = self.parcours.SESSION_ABBRV # 'S' ou 'A' - if s["semestre_id"] < 0: - SA = "A" # force, cas des DUT annuels par exemple - p.append("%s%d%s" % (SA, -s["semestre_id"], dem)) - else: - p.append("%s%d%s" % (SA, s["semestre_id"], dem)) - return ", ".join(p) - - def get_parcours_decisions(self): - """Decisions de jury de chacun des semestres du parcours, - du S1 au NB_SEM+1, ou mono-semestre. - Returns: { semestre_id : code } - """ - r = {} - if self.sem["semestre_id"] == NO_SEMESTRE_ID: - indices = [NO_SEMESTRE_ID] - else: - indices = list(range(1, self.parcours.NB_SEM + 1)) - for i in indices: - # cherche dans les semestres de l'étudiant, en partant du plus récent - sem = None - for asem in reversed(self.get_semestres()): - if asem["semestre_id"] == i: - sem = asem - break - if not sem: - code = "" # non inscrit à ce semestre - else: - formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - decision = nt.get_etud_decision_sem(self.etudid) - if decision: - code = decision["code"] - else: - code = "-" - r[i] = code - return r - - def _comp_barres(self): - "calcule barres_ue_ok et barre_moy_ok: barre moy. gen. et barres UE" - self.barres_ue_ok, self.barres_ue_diag = self.nt.etud_check_conditions_ues( - self.etudid - ) - self.moy_gen = self.nt.get_etud_moy_gen(self.etudid) - self.barre_moy_ok = (isinstance(self.moy_gen, float)) and ( - self.moy_gen >= (self.parcours.BARRE_MOY - scu.NOTES_TOLERANCE) - ) - # conserve etat UEs - ue_ids = [x["ue_id"] for x in self.nt.get_ues_stat_dict(filter_sport=True)] - self.ues_status = {} # ue_id : status - for ue_id in ue_ids: - self.ues_status[ue_id] = self.nt.get_etud_ue_status(self.etudid, ue_id) - - def could_be_compensated(self): - "true si ce semestre pourrait etre compensé par un autre (e.g. barres UE > 8)" - return self.barres_ue_ok - - def _search_prev(self): - """Recherche semestre 'precedent'. - return prev_formsemestre_id - """ - self.prev = None - self.prev_decision = None - if len(self.sems) < 2: - return None - # Cherche sem courant dans la liste triee par date_debut - cur = None - icur = -1 - for cur in self.sems: - icur += 1 - if cur["formsemestre_id"] == self.formsemestre_id: - break - if not cur or cur["formsemestre_id"] != self.formsemestre_id: - log( - f"*** SituationEtudCursus: search_prev: cur not found (formsemestre_id={self.formsemestre_id}, etudid={self.etudid})" - ) - return None # pas de semestre courant !!! - # Cherche semestre antérieur de même formation (code) et semestre_id precedent - # - # i = icur - 1 # part du courant, remonte vers le passé - i = len(self.sems) - 1 # par du dernier, remonte vers le passé - prev = None - while i >= 0: - if ( - self.sems[i]["formation_code"] == self.formation.formation_code - and self.sems[i]["semestre_id"] == cur["semestre_id"] - 1 - ): - prev = self.sems[i] - break - i -= 1 - if not prev: - return None # pas de precedent trouvé - self.prev = prev - # Verifications basiques: - # ? - # Code etat du semestre precedent: - formsemestre = FormSemestre.query.get_or_404(prev["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - self.prev_decision = nt.get_etud_decision_sem(self.etudid) - self.prev_moy_gen = nt.get_etud_moy_gen(self.etudid) - self.prev_barres_ue_ok = nt.etud_check_conditions_ues(self.etudid)[0] - return self.prev["formsemestre_id"] - - def get_next_semestre_ids(self, devenir): - """Liste des numeros de semestres autorises avec ce devenir - Ne vérifie pas que le devenir est possible (doit être fait avant), - juste que le rang du semestre est dans le parcours [1..NB_SEM] - """ - s = self.sem["semestre_id"] - if devenir == NEXT: - ids = [self._get_next_semestre_id()] - elif devenir == REDOANNEE: - ids = [s - 1] - elif devenir == REDOSEM: - ids = [s] - elif devenir == RA_OR_NEXT: - ids = [s - 1, self._get_next_semestre_id()] - elif devenir == RA_OR_RS: - ids = [s - 1, s] - elif devenir == RS_OR_NEXT: - ids = [s, self._get_next_semestre_id()] - elif devenir == NEXT_OR_NEXT2: - ids = [ - self._get_next_semestre_id(), - s + 2, - ] # cohérent avec explique_devenir() - elif devenir == NEXT2: - ids = [s + 2] - else: - ids = [] # reoriente ou autre: pas de next ! - # clip [1..NB_SEM] - r = [] - for idx in ids: - if idx > 0 and idx <= self.parcours.NB_SEM: - r.append(idx) - return r - - def _get_next_semestre_id(self): - """Indice du semestre suivant non validé. - S'il n'y en a pas, ramène NB_SEM+1 - """ - s = self.sem["semestre_id"] - if s >= self.parcours.NB_SEM: - return self.parcours.NB_SEM + 1 - validated = True - while validated and (s < self.parcours.NB_SEM): - s = s + 1 - # semestre s validé ? - validated = False - for sem in self.sems: - if ( - sem["formation_code"] == self.formation.formation_code - and sem["semestre_id"] == s - ): - formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results( - formsemestre - ) - decision = nt.get_etud_decision_sem(self.etudid) - if decision and code_semestre_validant(decision["code"]): - validated = True - return s - - def valide_decision(self, decision): - """Enregistre la decision (instance de DecisionSem) - Enregistre codes semestre et UE, et autorisations inscription. - """ - cnx = ndb.GetDBConnexion() - # -- check - if decision.code_etat in self.parcours.UNUSED_CODES: - raise ScoValueError("code decision invalide dans ce parcours") - # - if decision.code_etat == ADC: - fsid = decision.formsemestre_id_utilise_pour_compenser - if fsid: - ok = False - for sem in self.sems: - if sem["formsemestre_id"] == fsid and sem["can_compensate"]: - ok = True - break - if not ok: - raise ScoValueError("valide_decision: compensation impossible") - # -- supprime decision precedente et enregistre decision - to_invalidate = [] - if self.nt.get_etud_decision_sem(self.etudid): - to_invalidate = formsemestre_update_validation_sem( - cnx, - self.formsemestre_id, - self.etudid, - decision.code_etat, - decision.assiduite, - decision.formsemestre_id_utilise_pour_compenser, - ) - else: - formsemestre_validate_sem( - cnx, - self.formsemestre_id, - self.etudid, - decision.code_etat, - decision.assiduite, - decision.formsemestre_id_utilise_pour_compenser, - ) - logdb( - cnx, - method="validate_sem", - etudid=self.etudid, - commit=False, - msg="formsemestre_id=%s code=%s" - % (self.formsemestre_id, decision.code_etat), - ) - # -- decisions UEs - formsemestre_validate_ues( - self.formsemestre_id, - self.etudid, - decision.code_etat, - decision.assiduite, - ) - # -- modification du code du semestre precedent - if self.prev and decision.new_code_prev: - if decision.new_code_prev == ADC: - # ne compense le prec. qu'avec le sem. courant - fsid = self.formsemestre_id - else: - fsid = None - to_invalidate += formsemestre_update_validation_sem( - cnx, - self.prev["formsemestre_id"], - self.etudid, - decision.new_code_prev, - assidu=True, - formsemestre_id_utilise_pour_compenser=fsid, - ) - logdb( - cnx, - method="validate_sem", - etudid=self.etudid, - commit=False, - msg="formsemestre_id=%s code=%s" - % (self.prev["formsemestre_id"], decision.new_code_prev), - ) - # modifs des codes d'UE (pourraient passer de ADM a CMP, meme sans modif des notes) - formsemestre_validate_ues( - self.prev["formsemestre_id"], - self.etudid, - decision.new_code_prev, - decision.assiduite, # attention: en toute rigueur il faudrait utiliser une indication de l'assiduite au sem. precedent, que nous n'avons pas... - ) - - sco_cache.invalidate_formsemestre( - formsemestre_id=self.prev["formsemestre_id"] - ) # > modif decisions jury (sem, UE) - - try: - # -- Supprime autorisations venant de ce formsemestre - autorisations = ScolarAutorisationInscription.query.filter_by( - etudid=self.etudid, origin_formsemestre_id=self.formsemestre_id - ) - for autorisation in autorisations: - db.session.delete(autorisation) - db.session.flush() - # -- Enregistre autorisations inscription - next_semestre_ids = self.get_next_semestre_ids(decision.devenir) - for next_semestre_id in next_semestre_ids: - autorisation = ScolarAutorisationInscription( - etudid=self.etudid, - formation_code=self.formation.formation_code, - semestre_id=next_semestre_id, - origin_formsemestre_id=self.formsemestre_id, - ) - db.session.add(autorisation) - db.session.commit() - except: - cnx.session.rollback() - raise - sco_cache.invalidate_formsemestre( - formsemestre_id=self.formsemestre_id - ) # > modif decisions jury et autorisations inscription - if decision.formsemestre_id_utilise_pour_compenser: - # inval aussi le semestre utilisé pour compenser: - sco_cache.invalidate_formsemestre( - formsemestre_id=decision.formsemestre_id_utilise_pour_compenser, - ) # > modif decision jury - for formsemestre_id in to_invalidate: - sco_cache.invalidate_formsemestre( - formsemestre_id=formsemestre_id - ) # > modif decision jury - - def check_compensation_dut(self, semc: dict, ntc: NotesTableCompat): - """Compensations DUT - Vérifie si le semestre sem peut se compenser en utilisant semc - - semc non utilisé par un autre semestre - - decision du jury prise ADM ou ADJ ou ATT ou ADC - - barres UE (moy ue > 8) dans sem et semc - - moyenne des moy_gen > 10 - Return boolean - """ - # -- deja utilise ? - decc = ntc.get_etud_decision_sem(self.etudid) - if ( - decc - and decc["compense_formsemestre_id"] - and decc["compense_formsemestre_id"] != self.sem["formsemestre_id"] - ): - return False - # -- semestres consecutifs ? - if abs(self.sem["semestre_id"] - semc["semestre_id"]) != 1: - return False - # -- decision jury: - if decc and not decc["code"] in (ADM, ADJ, ATT, ADC): - return False - # -- barres UE et moyenne des moyennes: - moy_gen = self.nt.get_etud_moy_gen(self.etudid) - moy_genc = ntc.get_etud_moy_gen(self.etudid) - try: - moy_moy = (moy_gen + moy_genc) / 2 - except: # un des semestres sans aucune note ! - return False - - if ( - self.nt.etud_check_conditions_ues(self.etudid)[0] - and ntc.etud_check_conditions_ues(self.etudid)[0] - and moy_moy >= NOTES_BARRE_GEN_COMPENSATION - ): - return True - else: - return False - - -class SituationEtudCursusECTS(SituationEtudCursusClassic): - """Gestion parcours basés sur ECTS""" - - def __init__(self, etud, formsemestre_id, nt): - SituationEtudCursusClassic.__init__(self, etud, formsemestre_id, nt) - - def could_be_compensated(self): - return False # jamais de compensations dans ce parcours - - def get_possible_choices(self, assiduite=True): - """Listes de décisions "recommandées" (hors décisions manuelles) - - Dans ce type de parcours, on n'utilise que ADM, AJ, et ADJ (?). - """ - etud_ects_infos = self.nt.get_etud_ects_pot(self.etudid) - if ( - etud_ects_infos["ects_pot"] >= self.parcours.ECTS_BARRE_VALID_YEAR - and etud_ects_infos["ects_pot"] >= self.parcours.ECTS_FONDAMENTAUX_PER_YEAR - ): - choices = [ - DecisionSem( - code_etat=ADM, - new_code_prev=None, - devenir=NEXT, - formsemestre_id_utilise_pour_compenser=None, - explication="Semestre validé", - assiduite=assiduite, - rule_id="1000", - ) - ] - else: - choices = [ - DecisionSem( - code_etat=AJ, - new_code_prev=None, - devenir=NEXT, - formsemestre_id_utilise_pour_compenser=None, - explication="Semestre non validé", - assiduite=assiduite, - rule_id="1001", - ) - ] - return choices - - -# ------------------------------------------------------------------------------------------- - - -def int_or_null(s): - if s == "": - return None - else: - return int(s) - - -_scolar_formsemestre_validation_editor = ndb.EditableTable( - "scolar_formsemestre_validation", - "formsemestre_validation_id", - ( - "formsemestre_validation_id", - "etudid", - "formsemestre_id", - "ue_id", - "code", - "assidu", - "event_date", - "compense_formsemestre_id", - "moy_ue", - "semestre_id", - "is_external", - ), - output_formators={ - "event_date": ndb.DateISOtoDMY, - }, - input_formators={ - "event_date": ndb.DateDMYtoISO, - "assidu": bool, - "is_external": bool, - }, -) - -scolar_formsemestre_validation_create = _scolar_formsemestre_validation_editor.create -scolar_formsemestre_validation_list = _scolar_formsemestre_validation_editor.list -scolar_formsemestre_validation_delete = _scolar_formsemestre_validation_editor.delete -scolar_formsemestre_validation_edit = _scolar_formsemestre_validation_editor.edit - - -def formsemestre_validate_sem( - cnx, - formsemestre_id, - etudid, - code, - assidu=True, - formsemestre_id_utilise_pour_compenser=None, -): - "Ajoute ou change validation semestre" - args = {"formsemestre_id": formsemestre_id, "etudid": etudid} - # delete existing - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - try: - cursor.execute( - """delete from scolar_formsemestre_validation - where etudid = %(etudid)s and formsemestre_id=%(formsemestre_id)s and ue_id is null""", - args, - ) - # insert - args["code"] = code - args["assidu"] = assidu - log("formsemestre_validate_sem: %s" % args) - scolar_formsemestre_validation_create(cnx, args) - # marque sem. utilise pour compenser: - if formsemestre_id_utilise_pour_compenser: - assert code == ADC - args2 = { - "formsemestre_id": formsemestre_id_utilise_pour_compenser, - "compense_formsemestre_id": formsemestre_id, - "etudid": etudid, - } - cursor.execute( - """update scolar_formsemestre_validation - set compense_formsemestre_id=%(compense_formsemestre_id)s - where etudid = %(etudid)s and formsemestre_id=%(formsemestre_id)s - and ue_id is null""", - args2, - ) - except: - cnx.rollback() - raise - - -def formsemestre_update_validation_sem( - cnx, - formsemestre_id, - etudid, - code, - assidu=True, - formsemestre_id_utilise_pour_compenser=None, -): - "Update validation semestre" - args = { - "formsemestre_id": formsemestre_id, - "etudid": etudid, - "code": code, - "assidu": assidu, - } - log("formsemestre_update_validation_sem: %s" % args) - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - to_invalidate = [] - - # enleve compensations si necessaire - # recupere les semestres auparavant utilisés pour invalider les caches - # correspondants: - cursor.execute( - """select formsemestre_id from scolar_formsemestre_validation - where compense_formsemestre_id=%(formsemestre_id)s and etudid = %(etudid)s""", - args, - ) - to_invalidate = [x[0] for x in cursor.fetchall()] - # suppress: - cursor.execute( - """update scolar_formsemestre_validation set compense_formsemestre_id=NULL - where compense_formsemestre_id=%(formsemestre_id)s and etudid = %(etudid)s""", - args, - ) - if formsemestre_id_utilise_pour_compenser: - assert code == ADC - # marque sem. utilise pour compenser: - args2 = { - "formsemestre_id": formsemestre_id_utilise_pour_compenser, - "compense_formsemestre_id": formsemestre_id, - "etudid": etudid, - } - cursor.execute( - """update scolar_formsemestre_validation - set compense_formsemestre_id=%(compense_formsemestre_id)s - where etudid = %(etudid)s and formsemestre_id=%(formsemestre_id)s - and ue_id is null""", - args2, - ) - - cursor.execute( - """update scolar_formsemestre_validation - set code = %(code)s, event_date=DEFAULT, assidu=%(assidu)s - where etudid = %(etudid)s and formsemestre_id=%(formsemestre_id)s - and ue_id is null""", - args, - ) - return to_invalidate - - -def formsemestre_validate_ues(formsemestre_id, etudid, code_etat_sem, assiduite): - """Enregistre codes UE, selon état semestre. - Les codes UE sont toujours calculés ici, et non passés en paramètres - car ils ne dépendent que de la note d'UE et de la validation ou non du semestre. - Les UE des semestres NON ASSIDUS ne sont jamais validées (code AJ). - """ - valid_semestre = CODES_SEM_VALIDES.get(code_etat_sem, False) - cnx = ndb.GetDBConnexion() - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - ue_ids = [x["ue_id"] for x in nt.get_ues_stat_dict(filter_sport=True)] - for ue_id in ue_ids: - ue_status = nt.get_etud_ue_status(etudid, ue_id) - if not assiduite: - code_ue = AJ - else: - # log('%s: %s: ue_status=%s' % (formsemestre_id,ue_id,ue_status)) - if ( - isinstance(ue_status["moy"], float) - and ue_status["moy"] >= nt.parcours.NOTES_BARRE_VALID_UE - ): - code_ue = ADM - elif not isinstance(ue_status["moy"], float): - # aucune note (pas de moyenne) dans l'UE: ne la valide pas - code_ue = None - elif valid_semestre: - code_ue = CMP - else: - code_ue = AJ - # log('code_ue=%s' % code_ue) - if etud_est_inscrit_ue(cnx, etudid, formsemestre_id, ue_id) and code_ue: - do_formsemestre_validate_ue( - cnx, nt, formsemestre_id, etudid, ue_id, code_ue - ) - - logdb( - cnx, - method="validate_ue", - etudid=etudid, - msg="ue_id=%s code=%s" % (ue_id, code_ue), - commit=False, - ) - cnx.commit() - - -def do_formsemestre_validate_ue( - cnx, - nt, - formsemestre_id, - etudid, - ue_id, - code, - moy_ue=None, - date=None, - semestre_id=None, - is_external=False, -): - """Ajoute ou change validation UE""" - if semestre_id is None: - ue = UniteEns.query.get_or_404(ue_id) - semestre_id = ue.semestre_idx - args = { - "formsemestre_id": formsemestre_id, - "etudid": etudid, - "ue_id": ue_id, - "semestre_id": semestre_id, - "is_external": is_external, - } - if date: - args["event_date"] = date - - # delete existing - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - try: - cond = "etudid = %(etudid)s and ue_id=%(ue_id)s" - if formsemestre_id: - cond += " and formsemestre_id=%(formsemestre_id)s" - if semestre_id: - cond += " and (semestre_id=%(semestre_id)s or semestre_id is NULL)" - log(f"formsemestre_validate_ue: deleting where {cond}, args={args})") - cursor.execute("delete from scolar_formsemestre_validation where " + cond, args) - # insert - args["code"] = code - if code == ADM: - if moy_ue is None: - # stocke la moyenne d'UE capitalisée: - ue_status = nt.get_etud_ue_status(etudid, ue_id) - moy_ue = ue_status["moy"] if ue_status else "" - args["moy_ue"] = moy_ue - log("formsemestre_validate_ue: create %s" % args) - if code != None: - scolar_formsemestre_validation_create(cnx, args) - else: - log("formsemestre_validate_ue: code is None, not recording validation") - except: - cnx.rollback() - raise - - -def formsemestre_has_decisions(formsemestre_id): - """True s'il y a au moins une validation (decision de jury) dans ce semestre - equivalent to notes_table.sem_has_decisions() but much faster when nt not cached - """ - cnx = ndb.GetDBConnexion() - validations = scolar_formsemestre_validation_list( - cnx, args={"formsemestre_id": formsemestre_id} - ) - return len(validations) > 0 - - -def etud_est_inscrit_ue(cnx, etudid, formsemestre_id, ue_id): - """Vrai si l'étudiant est inscrit à au moins un module de cette UE dans ce semestre. - Ne pas utiliser pour les formations APC ! - """ - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - """SELECT mi.* - FROM notes_moduleimpl mi, notes_modules mo, notes_ue ue, notes_moduleimpl_inscription i - WHERE i.etudid = %(etudid)s - and i.moduleimpl_id=mi.id - and mi.formsemestre_id = %(formsemestre_id)s - and mi.module_id = mo.id - and mo.ue_id = %(ue_id)s - """, - {"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id}, - ) - - return len(cursor.fetchall()) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Semestres: gestion parcours DUT (Arreté du 13 août 2005) +""" + +from app import db +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre, UniteEns, ScolarAutorisationInscription + +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app import log +from app.scodoc.scolog import logdb +from app.scodoc import sco_cache, sco_etud +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formations +from app.scodoc.sco_codes_parcours import ( + CMP, + ADC, + ADJ, + ADM, + AJ, + ATT, + NO_SEMESTRE_ID, + BUG, + NEXT, + NEXT2, + NEXT_OR_NEXT2, + REO, + REDOANNEE, + REDOSEM, + RA_OR_NEXT, + RA_OR_RS, + RS_OR_NEXT, + CODES_SEM_VALIDES, + NOTES_BARRE_GEN_COMPENSATION, + code_semestre_attente, + code_semestre_validant, +) +from app.scodoc.dutrules import DUTRules # regles generees a partir du CSV +from app.scodoc.sco_exceptions import ScoValueError + + +class DecisionSem(object): + "Decision prenable pour un semestre" + + def __init__( + self, + code_etat=None, + code_etat_ues={}, # { ue_id : code } + new_code_prev="", + explication="", # aide pour le jury + formsemestre_id_utilise_pour_compenser=None, # None si code != ADC + devenir=None, # code devenir + assiduite=True, + rule_id=None, # id regle correspondante + ): + self.code_etat = code_etat + self.code_etat_ues = code_etat_ues + self.new_code_prev = new_code_prev + self.explication = explication + self.formsemestre_id_utilise_pour_compenser = ( + formsemestre_id_utilise_pour_compenser + ) + self.devenir = devenir + self.assiduite = assiduite + self.rule_id = rule_id + # code unique (string) utilise pour la gestion du formulaire + self.codechoice = ( + "C" # prefix pour éviter que Flask le considère comme int + + str( + hash( + ( + code_etat, + new_code_prev, + formsemestre_id_utilise_pour_compenser, + devenir, + assiduite, + ) + ) + ) + ) + + +class SituationEtudCursus: + "Semestre dans un cursus" + pass + + +class SituationEtudCursusClassic(SituationEtudCursus): + "Semestre dans un parcours" + + def __init__(self, etud: dict, formsemestre_id: int, nt: NotesTableCompat): + """ + etud: dict filled by fill_etuds_info() + """ + self.etud = etud + self.etudid = etud["etudid"] + self.formsemestre_id = formsemestre_id + self.sem = sco_formsemestre.get_formsemestre(formsemestre_id) + self.nt: NotesTableCompat = nt + self.formation = self.nt.formsemestre.formation + self.parcours = self.nt.parcours + # Ce semestre est-il le dernier de la formation ? (e.g. semestre 4 du DUT) + # pour le DUT, le dernier est toujours S4. + # Ici: terminal si semestre == NB_SEM ou bien semestre_id==-1 + # (licences et autres formations en 1 seule session)) + self.semestre_non_terminal = self.sem["semestre_id"] != self.parcours.NB_SEM + if self.sem["semestre_id"] == NO_SEMESTRE_ID: + self.semestre_non_terminal = False + # Liste des semestres du parcours de cet étudiant: + self._comp_semestres() + # Determine le semestre "precedent" + self.prev_formsemestre_id = self._search_prev() + # Verifie barres + self._comp_barres() + # Verifie compensation + if self.prev and self.sem["gestion_compensation"]: + self.can_compensate_with_prev = self.prev["can_compensate"] + else: + self.can_compensate_with_prev = False + + def get_possible_choices(self, assiduite=True): + """Donne la liste des décisions possibles en jury (hors décisions manuelles) + (liste d'instances de DecisionSem) + assiduite = True si pas de probleme d'assiduité + """ + choices = [] + if self.prev_decision: + prev_code_etat = self.prev_decision["code"] + else: + prev_code_etat = None + + state = ( + prev_code_etat, + assiduite, + self.barre_moy_ok, + self.barres_ue_ok, + self.can_compensate_with_prev, + self.semestre_non_terminal, + ) + # log('get_possible_choices: state=%s' % str(state) ) + for rule in DUTRules: + # Saute codes non autorisés dans ce parcours (eg ATT en LP) + if rule.conclusion[0] in self.parcours.UNUSED_CODES: + continue + # Saute regles REDOSEM si pas de semestres decales: + if (not self.sem["gestion_semestrielle"]) and rule.conclusion[ + 3 + ] == "REDOSEM": + continue + if rule.match(state): + if rule.conclusion[0] == ADC: + # dans les regles on ne peut compenser qu'avec le PRECEDENT: + fiduc = self.prev_formsemestre_id + assert fiduc + else: + fiduc = None + # Detection d'incoherences (regles BUG) + if rule.conclusion[5] == BUG: + log("get_possible_choices: inconsistency: state=%s" % str(state)) + # + # valid_semestre = code_semestre_validant(rule.conclusion[0]) + choices.append( + DecisionSem( + code_etat=rule.conclusion[0], + new_code_prev=rule.conclusion[2], + devenir=rule.conclusion[3], + formsemestre_id_utilise_pour_compenser=fiduc, + explication=rule.conclusion[5], + assiduite=assiduite, + rule_id=rule.rule_id, + ) + ) + return choices + + def explique_devenir(self, devenir): + "Phrase d'explication pour le code devenir" + if not devenir: + return "" + s = self.sem["semestre_id"] # numero semestre courant + if s < 0: # formation sans semestres (eg licence) + next_s = 1 + else: + next_s = self._get_next_semestre_id() + # log('s=%s next=%s' % (s, next_s)) + SA = self.parcours.SESSION_ABBRV # 'S' ou 'A' + if self.semestre_non_terminal and not self.all_other_validated(): + passage = "Passe en %s%s" % (SA, next_s) + else: + passage = "Formation terminée" + if devenir == NEXT: + return passage + elif devenir == REO: + return "Réorienté" + elif devenir == REDOANNEE: + return "Redouble année (recommence %s%s)" % (SA, (s - 1)) + elif devenir == REDOSEM: + return "Redouble semestre (recommence en %s%s)" % (SA, s) + elif devenir == RA_OR_NEXT: + return passage + ", ou redouble année (en %s%s)" % (SA, (s - 1)) + elif devenir == RA_OR_RS: + return "Redouble semestre %s%s, ou redouble année (en %s%s)" % ( + SA, + s, + SA, + s - 1, + ) + elif devenir == RS_OR_NEXT: + return passage + ", ou semestre %s%s" % (SA, s) + elif devenir == NEXT_OR_NEXT2: + return passage + ", ou en semestre %s%s" % ( + SA, + s + 2, + ) # coherent avec get_next_semestre_ids + elif devenir == NEXT2: + return "Passe en %s%s" % (SA, s + 2) + else: + log("explique_devenir: code devenir inconnu: %s" % devenir) + return "Code devenir inconnu !" + + def all_other_validated(self): + "True si tous les autres semestres de cette formation sont validés" + return self._sems_validated(exclude_current=True) + + def sem_idx_is_validated(self, semestre_id): + "True si le semestre d'indice indiqué est validé dans ce parcours" + return self._sem_list_validated(set([semestre_id])) + + def parcours_validated(self): + "True si parcours validé (diplôme obtenu, donc)." + return self._sems_validated() + + def _sems_validated(self, exclude_current=False): + "True si semestres du parcours validés" + if self.sem["semestre_id"] == NO_SEMESTRE_ID: + # mono-semestre: juste celui ci + decision = self.nt.get_etud_decision_sem(self.etudid) + return decision and code_semestre_validant(decision["code"]) + else: + to_validate = set( + range(1, self.parcours.NB_SEM + 1) + ) # ensemble des indices à valider + if exclude_current and self.sem["semestre_id"] in to_validate: + to_validate.remove(self.sem["semestre_id"]) + return self._sem_list_validated(to_validate) + + def can_jump_to_next2(self): + """True si l'étudiant peut passer directement en Sn+2 (eg de S2 en S4). + Il faut donc que tous les semestres 1...n-1 soient validés et que n+1 soit en attente. + (et que le sem courant n soit validé, ce qui n'est pas testé ici) + """ + n = self.sem["semestre_id"] + if not self.sem["gestion_semestrielle"]: + return False # pas de semestre décalés + if n == NO_SEMESTRE_ID or n > self.parcours.NB_SEM - 2: + return False # n+2 en dehors du parcours + if self._sem_list_validated(set(range(1, n))): + # antérieurs validé, teste suivant + n1 = n + 1 + for sem in self.get_semestres(): + if ( + sem["semestre_id"] == n1 + and sem["formation_code"] == self.formation.formation_code + ): + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results( + formsemestre + ) + decision = nt.get_etud_decision_sem(self.etudid) + if decision and ( + code_semestre_validant(decision["code"]) + or code_semestre_attente(decision["code"]) + ): + return True + return False + + def _sem_list_validated(self, sem_idx_set): + """True si les semestres dont les indices sont donnés en argument (modifié) + sont validés. En sortie, sem_idx_set contient ceux qui n'ont pas été validés.""" + for sem in self.get_semestres(): + if sem["formation_code"] == self.formation.formation_code: + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + decision = nt.get_etud_decision_sem(self.etudid) + if decision and code_semestre_validant(decision["code"]): + # validé + sem_idx_set.discard(sem["semestre_id"]) + + return not sem_idx_set + + def _comp_semestres(self): + # etud['sems'] est trie par date decroissante (voir fill_etuds_info) + if not "sems" in self.etud: + self.etud["sems"] = sco_etud.etud_inscriptions_infos( + self.etud["etudid"], self.etud["ne"] + )["sems"] + sems = self.etud["sems"][:] # copy + sems.reverse() + # Nb max d'UE et acronymes + ue_acros = {} # acronyme ue : 1 + nb_max_ue = 0 + for sem in sems: + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + ues = nt.get_ues_stat_dict(filter_sport=True) + for ue in ues: + ue_acros[ue["acronyme"]] = 1 + nb_ue = len(ues) + if nb_ue > nb_max_ue: + nb_max_ue = nb_ue + # add formation_code to each sem: + sem["formation_code"] = sco_formations.formation_list( + args={"formation_id": sem["formation_id"]} + )[0]["formation_code"] + # si sem peut servir à compenser le semestre courant, positionne + # can_compensate + sem["can_compensate"] = self.check_compensation_dut(sem, nt) + + self.ue_acros = list(ue_acros.keys()) + self.ue_acros.sort() + self.nb_max_ue = nb_max_ue + self.sems = sems + + def get_semestres(self): + """Liste des semestres dans lesquels a été inscrit + l'étudiant (quelle que soit la formation), le plus ancien en tête""" + return self.sems + + def get_parcours_descr(self, filter_futur=False): + """Description brève du parcours: "S1, S2, ..." + Si filter_futur, ne mentionne pas les semestres qui sont après le semestre courant. + """ + cur_begin_date = self.sem["dateord"] + p = [] + for s in self.sems: + if s["ins"]["etat"] == scu.DEMISSION: + dem = " (dem.)" + else: + dem = "" + if filter_futur and s["dateord"] > cur_begin_date: + continue # skip semestres demarrant apres le courant + SA = self.parcours.SESSION_ABBRV # 'S' ou 'A' + if s["semestre_id"] < 0: + SA = "A" # force, cas des DUT annuels par exemple + p.append("%s%d%s" % (SA, -s["semestre_id"], dem)) + else: + p.append("%s%d%s" % (SA, s["semestre_id"], dem)) + return ", ".join(p) + + def get_parcours_decisions(self): + """Decisions de jury de chacun des semestres du parcours, + du S1 au NB_SEM+1, ou mono-semestre. + Returns: { semestre_id : code } + """ + r = {} + if self.sem["semestre_id"] == NO_SEMESTRE_ID: + indices = [NO_SEMESTRE_ID] + else: + indices = list(range(1, self.parcours.NB_SEM + 1)) + for i in indices: + # cherche dans les semestres de l'étudiant, en partant du plus récent + sem = None + for asem in reversed(self.get_semestres()): + if asem["semestre_id"] == i: + sem = asem + break + if not sem: + code = "" # non inscrit à ce semestre + else: + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + decision = nt.get_etud_decision_sem(self.etudid) + if decision: + code = decision["code"] + else: + code = "-" + r[i] = code + return r + + def _comp_barres(self): + "calcule barres_ue_ok et barre_moy_ok: barre moy. gen. et barres UE" + self.barres_ue_ok, self.barres_ue_diag = self.nt.etud_check_conditions_ues( + self.etudid + ) + self.moy_gen = self.nt.get_etud_moy_gen(self.etudid) + self.barre_moy_ok = (isinstance(self.moy_gen, float)) and ( + self.moy_gen >= (self.parcours.BARRE_MOY - scu.NOTES_TOLERANCE) + ) + # conserve etat UEs + ue_ids = [x["ue_id"] for x in self.nt.get_ues_stat_dict(filter_sport=True)] + self.ues_status = {} # ue_id : status + for ue_id in ue_ids: + self.ues_status[ue_id] = self.nt.get_etud_ue_status(self.etudid, ue_id) + + def could_be_compensated(self): + "true si ce semestre pourrait etre compensé par un autre (e.g. barres UE > 8)" + return self.barres_ue_ok + + def _search_prev(self): + """Recherche semestre 'precedent'. + return prev_formsemestre_id + """ + self.prev = None + self.prev_decision = None + if len(self.sems) < 2: + return None + # Cherche sem courant dans la liste triee par date_debut + cur = None + icur = -1 + for cur in self.sems: + icur += 1 + if cur["formsemestre_id"] == self.formsemestre_id: + break + if not cur or cur["formsemestre_id"] != self.formsemestre_id: + log( + f"*** SituationEtudCursus: search_prev: cur not found (formsemestre_id={self.formsemestre_id}, etudid={self.etudid})" + ) + return None # pas de semestre courant !!! + # Cherche semestre antérieur de même formation (code) et semestre_id precedent + # + # i = icur - 1 # part du courant, remonte vers le passé + i = len(self.sems) - 1 # par du dernier, remonte vers le passé + prev = None + while i >= 0: + if ( + self.sems[i]["formation_code"] == self.formation.formation_code + and self.sems[i]["semestre_id"] == cur["semestre_id"] - 1 + ): + prev = self.sems[i] + break + i -= 1 + if not prev: + return None # pas de precedent trouvé + self.prev = prev + # Verifications basiques: + # ? + # Code etat du semestre precedent: + formsemestre = FormSemestre.query.get_or_404(prev["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + self.prev_decision = nt.get_etud_decision_sem(self.etudid) + self.prev_moy_gen = nt.get_etud_moy_gen(self.etudid) + self.prev_barres_ue_ok = nt.etud_check_conditions_ues(self.etudid)[0] + return self.prev["formsemestre_id"] + + def get_next_semestre_ids(self, devenir): + """Liste des numeros de semestres autorises avec ce devenir + Ne vérifie pas que le devenir est possible (doit être fait avant), + juste que le rang du semestre est dans le parcours [1..NB_SEM] + """ + s = self.sem["semestre_id"] + if devenir == NEXT: + ids = [self._get_next_semestre_id()] + elif devenir == REDOANNEE: + ids = [s - 1] + elif devenir == REDOSEM: + ids = [s] + elif devenir == RA_OR_NEXT: + ids = [s - 1, self._get_next_semestre_id()] + elif devenir == RA_OR_RS: + ids = [s - 1, s] + elif devenir == RS_OR_NEXT: + ids = [s, self._get_next_semestre_id()] + elif devenir == NEXT_OR_NEXT2: + ids = [ + self._get_next_semestre_id(), + s + 2, + ] # cohérent avec explique_devenir() + elif devenir == NEXT2: + ids = [s + 2] + else: + ids = [] # reoriente ou autre: pas de next ! + # clip [1..NB_SEM] + r = [] + for idx in ids: + if idx > 0 and idx <= self.parcours.NB_SEM: + r.append(idx) + return r + + def _get_next_semestre_id(self): + """Indice du semestre suivant non validé. + S'il n'y en a pas, ramène NB_SEM+1 + """ + s = self.sem["semestre_id"] + if s >= self.parcours.NB_SEM: + return self.parcours.NB_SEM + 1 + validated = True + while validated and (s < self.parcours.NB_SEM): + s = s + 1 + # semestre s validé ? + validated = False + for sem in self.sems: + if ( + sem["formation_code"] == self.formation.formation_code + and sem["semestre_id"] == s + ): + formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results( + formsemestre + ) + decision = nt.get_etud_decision_sem(self.etudid) + if decision and code_semestre_validant(decision["code"]): + validated = True + return s + + def valide_decision(self, decision): + """Enregistre la decision (instance de DecisionSem) + Enregistre codes semestre et UE, et autorisations inscription. + """ + cnx = ndb.GetDBConnexion() + # -- check + if decision.code_etat in self.parcours.UNUSED_CODES: + raise ScoValueError("code decision invalide dans ce parcours") + # + if decision.code_etat == ADC: + fsid = decision.formsemestre_id_utilise_pour_compenser + if fsid: + ok = False + for sem in self.sems: + if sem["formsemestre_id"] == fsid and sem["can_compensate"]: + ok = True + break + if not ok: + raise ScoValueError("valide_decision: compensation impossible") + # -- supprime decision precedente et enregistre decision + to_invalidate = [] + if self.nt.get_etud_decision_sem(self.etudid): + to_invalidate = formsemestre_update_validation_sem( + cnx, + self.formsemestre_id, + self.etudid, + decision.code_etat, + decision.assiduite, + decision.formsemestre_id_utilise_pour_compenser, + ) + else: + formsemestre_validate_sem( + cnx, + self.formsemestre_id, + self.etudid, + decision.code_etat, + decision.assiduite, + decision.formsemestre_id_utilise_pour_compenser, + ) + logdb( + cnx, + method="validate_sem", + etudid=self.etudid, + commit=False, + msg="formsemestre_id=%s code=%s" + % (self.formsemestre_id, decision.code_etat), + ) + # -- decisions UEs + formsemestre_validate_ues( + self.formsemestre_id, + self.etudid, + decision.code_etat, + decision.assiduite, + ) + # -- modification du code du semestre precedent + if self.prev and decision.new_code_prev: + if decision.new_code_prev == ADC: + # ne compense le prec. qu'avec le sem. courant + fsid = self.formsemestre_id + else: + fsid = None + to_invalidate += formsemestre_update_validation_sem( + cnx, + self.prev["formsemestre_id"], + self.etudid, + decision.new_code_prev, + assidu=True, + formsemestre_id_utilise_pour_compenser=fsid, + ) + logdb( + cnx, + method="validate_sem", + etudid=self.etudid, + commit=False, + msg="formsemestre_id=%s code=%s" + % (self.prev["formsemestre_id"], decision.new_code_prev), + ) + # modifs des codes d'UE (pourraient passer de ADM a CMP, meme sans modif des notes) + formsemestre_validate_ues( + self.prev["formsemestre_id"], + self.etudid, + decision.new_code_prev, + decision.assiduite, # attention: en toute rigueur il faudrait utiliser une indication de l'assiduite au sem. precedent, que nous n'avons pas... + ) + + sco_cache.invalidate_formsemestre( + formsemestre_id=self.prev["formsemestre_id"] + ) # > modif decisions jury (sem, UE) + + try: + # -- Supprime autorisations venant de ce formsemestre + autorisations = ScolarAutorisationInscription.query.filter_by( + etudid=self.etudid, origin_formsemestre_id=self.formsemestre_id + ) + for autorisation in autorisations: + db.session.delete(autorisation) + db.session.flush() + # -- Enregistre autorisations inscription + next_semestre_ids = self.get_next_semestre_ids(decision.devenir) + for next_semestre_id in next_semestre_ids: + autorisation = ScolarAutorisationInscription( + etudid=self.etudid, + formation_code=self.formation.formation_code, + semestre_id=next_semestre_id, + origin_formsemestre_id=self.formsemestre_id, + ) + db.session.add(autorisation) + db.session.commit() + except: + cnx.session.rollback() + raise + sco_cache.invalidate_formsemestre( + formsemestre_id=self.formsemestre_id + ) # > modif decisions jury et autorisations inscription + if decision.formsemestre_id_utilise_pour_compenser: + # inval aussi le semestre utilisé pour compenser: + sco_cache.invalidate_formsemestre( + formsemestre_id=decision.formsemestre_id_utilise_pour_compenser, + ) # > modif decision jury + for formsemestre_id in to_invalidate: + sco_cache.invalidate_formsemestre( + formsemestre_id=formsemestre_id + ) # > modif decision jury + + def check_compensation_dut(self, semc: dict, ntc: NotesTableCompat): + """Compensations DUT + Vérifie si le semestre sem peut se compenser en utilisant semc + - semc non utilisé par un autre semestre + - decision du jury prise ADM ou ADJ ou ATT ou ADC + - barres UE (moy ue > 8) dans sem et semc + - moyenne des moy_gen > 10 + Return boolean + """ + # -- deja utilise ? + decc = ntc.get_etud_decision_sem(self.etudid) + if ( + decc + and decc["compense_formsemestre_id"] + and decc["compense_formsemestre_id"] != self.sem["formsemestre_id"] + ): + return False + # -- semestres consecutifs ? + if abs(self.sem["semestre_id"] - semc["semestre_id"]) != 1: + return False + # -- decision jury: + if decc and not decc["code"] in (ADM, ADJ, ATT, ADC): + return False + # -- barres UE et moyenne des moyennes: + moy_gen = self.nt.get_etud_moy_gen(self.etudid) + moy_genc = ntc.get_etud_moy_gen(self.etudid) + try: + moy_moy = (moy_gen + moy_genc) / 2 + except: # un des semestres sans aucune note ! + return False + + if ( + self.nt.etud_check_conditions_ues(self.etudid)[0] + and ntc.etud_check_conditions_ues(self.etudid)[0] + and moy_moy >= NOTES_BARRE_GEN_COMPENSATION + ): + return True + else: + return False + + +class SituationEtudCursusECTS(SituationEtudCursusClassic): + """Gestion parcours basés sur ECTS""" + + def __init__(self, etud, formsemestre_id, nt): + SituationEtudCursusClassic.__init__(self, etud, formsemestre_id, nt) + + def could_be_compensated(self): + return False # jamais de compensations dans ce parcours + + def get_possible_choices(self, assiduite=True): + """Listes de décisions "recommandées" (hors décisions manuelles) + + Dans ce type de parcours, on n'utilise que ADM, AJ, et ADJ (?). + """ + etud_ects_infos = self.nt.get_etud_ects_pot(self.etudid) + if ( + etud_ects_infos["ects_pot"] >= self.parcours.ECTS_BARRE_VALID_YEAR + and etud_ects_infos["ects_pot"] >= self.parcours.ECTS_FONDAMENTAUX_PER_YEAR + ): + choices = [ + DecisionSem( + code_etat=ADM, + new_code_prev=None, + devenir=NEXT, + formsemestre_id_utilise_pour_compenser=None, + explication="Semestre validé", + assiduite=assiduite, + rule_id="1000", + ) + ] + else: + choices = [ + DecisionSem( + code_etat=AJ, + new_code_prev=None, + devenir=NEXT, + formsemestre_id_utilise_pour_compenser=None, + explication="Semestre non validé", + assiduite=assiduite, + rule_id="1001", + ) + ] + return choices + + +# ------------------------------------------------------------------------------------------- + + +def int_or_null(s): + if s == "": + return None + else: + return int(s) + + +_scolar_formsemestre_validation_editor = ndb.EditableTable( + "scolar_formsemestre_validation", + "formsemestre_validation_id", + ( + "formsemestre_validation_id", + "etudid", + "formsemestre_id", + "ue_id", + "code", + "assidu", + "event_date", + "compense_formsemestre_id", + "moy_ue", + "semestre_id", + "is_external", + ), + output_formators={ + "event_date": ndb.DateISOtoDMY, + }, + input_formators={ + "event_date": ndb.DateDMYtoISO, + "assidu": bool, + "is_external": bool, + }, +) + +scolar_formsemestre_validation_create = _scolar_formsemestre_validation_editor.create +scolar_formsemestre_validation_list = _scolar_formsemestre_validation_editor.list +scolar_formsemestre_validation_delete = _scolar_formsemestre_validation_editor.delete +scolar_formsemestre_validation_edit = _scolar_formsemestre_validation_editor.edit + + +def formsemestre_validate_sem( + cnx, + formsemestre_id, + etudid, + code, + assidu=True, + formsemestre_id_utilise_pour_compenser=None, +): + "Ajoute ou change validation semestre" + args = {"formsemestre_id": formsemestre_id, "etudid": etudid} + # delete existing + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + try: + cursor.execute( + """delete from scolar_formsemestre_validation + where etudid = %(etudid)s and formsemestre_id=%(formsemestre_id)s and ue_id is null""", + args, + ) + # insert + args["code"] = code + args["assidu"] = assidu + log("formsemestre_validate_sem: %s" % args) + scolar_formsemestre_validation_create(cnx, args) + # marque sem. utilise pour compenser: + if formsemestre_id_utilise_pour_compenser: + assert code == ADC + args2 = { + "formsemestre_id": formsemestre_id_utilise_pour_compenser, + "compense_formsemestre_id": formsemestre_id, + "etudid": etudid, + } + cursor.execute( + """update scolar_formsemestre_validation + set compense_formsemestre_id=%(compense_formsemestre_id)s + where etudid = %(etudid)s and formsemestre_id=%(formsemestre_id)s + and ue_id is null""", + args2, + ) + except: + cnx.rollback() + raise + + +def formsemestre_update_validation_sem( + cnx, + formsemestre_id, + etudid, + code, + assidu=True, + formsemestre_id_utilise_pour_compenser=None, +): + "Update validation semestre" + args = { + "formsemestre_id": formsemestre_id, + "etudid": etudid, + "code": code, + "assidu": assidu, + } + log("formsemestre_update_validation_sem: %s" % args) + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + to_invalidate = [] + + # enleve compensations si necessaire + # recupere les semestres auparavant utilisés pour invalider les caches + # correspondants: + cursor.execute( + """select formsemestre_id from scolar_formsemestre_validation + where compense_formsemestre_id=%(formsemestre_id)s and etudid = %(etudid)s""", + args, + ) + to_invalidate = [x[0] for x in cursor.fetchall()] + # suppress: + cursor.execute( + """update scolar_formsemestre_validation set compense_formsemestre_id=NULL + where compense_formsemestre_id=%(formsemestre_id)s and etudid = %(etudid)s""", + args, + ) + if formsemestre_id_utilise_pour_compenser: + assert code == ADC + # marque sem. utilise pour compenser: + args2 = { + "formsemestre_id": formsemestre_id_utilise_pour_compenser, + "compense_formsemestre_id": formsemestre_id, + "etudid": etudid, + } + cursor.execute( + """update scolar_formsemestre_validation + set compense_formsemestre_id=%(compense_formsemestre_id)s + where etudid = %(etudid)s and formsemestre_id=%(formsemestre_id)s + and ue_id is null""", + args2, + ) + + cursor.execute( + """update scolar_formsemestre_validation + set code = %(code)s, event_date=DEFAULT, assidu=%(assidu)s + where etudid = %(etudid)s and formsemestre_id=%(formsemestre_id)s + and ue_id is null""", + args, + ) + return to_invalidate + + +def formsemestre_validate_ues(formsemestre_id, etudid, code_etat_sem, assiduite): + """Enregistre codes UE, selon état semestre. + Les codes UE sont toujours calculés ici, et non passés en paramètres + car ils ne dépendent que de la note d'UE et de la validation ou non du semestre. + Les UE des semestres NON ASSIDUS ne sont jamais validées (code AJ). + """ + valid_semestre = CODES_SEM_VALIDES.get(code_etat_sem, False) + cnx = ndb.GetDBConnexion() + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + ue_ids = [x["ue_id"] for x in nt.get_ues_stat_dict(filter_sport=True)] + for ue_id in ue_ids: + ue_status = nt.get_etud_ue_status(etudid, ue_id) + if not assiduite: + code_ue = AJ + else: + # log('%s: %s: ue_status=%s' % (formsemestre_id,ue_id,ue_status)) + if ( + isinstance(ue_status["moy"], float) + and ue_status["moy"] >= nt.parcours.NOTES_BARRE_VALID_UE + ): + code_ue = ADM + elif not isinstance(ue_status["moy"], float): + # aucune note (pas de moyenne) dans l'UE: ne la valide pas + code_ue = None + elif valid_semestre: + code_ue = CMP + else: + code_ue = AJ + # log('code_ue=%s' % code_ue) + if etud_est_inscrit_ue(cnx, etudid, formsemestre_id, ue_id) and code_ue: + do_formsemestre_validate_ue( + cnx, nt, formsemestre_id, etudid, ue_id, code_ue + ) + + logdb( + cnx, + method="validate_ue", + etudid=etudid, + msg="ue_id=%s code=%s" % (ue_id, code_ue), + commit=False, + ) + cnx.commit() + + +def do_formsemestre_validate_ue( + cnx, + nt, + formsemestre_id, + etudid, + ue_id, + code, + moy_ue=None, + date=None, + semestre_id=None, + is_external=False, +): + """Ajoute ou change validation UE""" + if semestre_id is None: + ue = UniteEns.query.get_or_404(ue_id) + semestre_id = ue.semestre_idx + args = { + "formsemestre_id": formsemestre_id, + "etudid": etudid, + "ue_id": ue_id, + "semestre_id": semestre_id, + "is_external": is_external, + } + if date: + args["event_date"] = date + + # delete existing + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + try: + cond = "etudid = %(etudid)s and ue_id=%(ue_id)s" + if formsemestre_id: + cond += " and formsemestre_id=%(formsemestre_id)s" + if semestre_id: + cond += " and (semestre_id=%(semestre_id)s or semestre_id is NULL)" + log(f"formsemestre_validate_ue: deleting where {cond}, args={args})") + cursor.execute("delete from scolar_formsemestre_validation where " + cond, args) + # insert + args["code"] = code + if code == ADM: + if moy_ue is None: + # stocke la moyenne d'UE capitalisée: + ue_status = nt.get_etud_ue_status(etudid, ue_id) + moy_ue = ue_status["moy"] if ue_status else "" + args["moy_ue"] = moy_ue + log("formsemestre_validate_ue: create %s" % args) + if code != None: + scolar_formsemestre_validation_create(cnx, args) + else: + log("formsemestre_validate_ue: code is None, not recording validation") + except: + cnx.rollback() + raise + + +def formsemestre_has_decisions(formsemestre_id): + """True s'il y a au moins une validation (decision de jury) dans ce semestre + equivalent to notes_table.sem_has_decisions() but much faster when nt not cached + """ + cnx = ndb.GetDBConnexion() + validations = scolar_formsemestre_validation_list( + cnx, args={"formsemestre_id": formsemestre_id} + ) + return len(validations) > 0 + + +def etud_est_inscrit_ue(cnx, etudid, formsemestre_id, ue_id): + """Vrai si l'étudiant est inscrit à au moins un module de cette UE dans ce semestre. + Ne pas utiliser pour les formations APC ! + """ + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + """SELECT mi.* + FROM notes_moduleimpl mi, notes_modules mo, notes_ue ue, notes_moduleimpl_inscription i + WHERE i.etudid = %(etudid)s + and i.moduleimpl_id=mi.id + and mi.formsemestre_id = %(formsemestre_id)s + and mi.module_id = mo.id + and mo.ue_id = %(ue_id)s + """, + {"etudid": etudid, "formsemestre_id": formsemestre_id, "ue_id": ue_id}, + ) + + return len(cursor.fetchall()) diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py index 1741b2e7..bb869d5a 100644 --- a/app/scodoc/sco_etud.py +++ b/app/scodoc/sco_etud.py @@ -1,1053 +1,1053 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -""" Accès donnees etudiants -""" - -# Ancien module "scolars" -import os -import time -from operator import itemgetter - -from flask import url_for, g - -from app import email -from app import log -from app.models import Admission -from app.models.etudiants import make_etud_args -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app.scodoc.sco_exceptions import ScoGenError, ScoValueError -from app.scodoc import safehtml -from app.scodoc import sco_preferences -from app.scodoc.scolog import logdb - - -def format_etud_ident(etud): - """Format identite de l'étudiant (modifié en place) - nom, prénom et formes associees - """ - etud["nom"] = format_nom(etud["nom"]) - if "nom_usuel" in etud: - etud["nom_usuel"] = format_nom(etud["nom_usuel"]) - else: - etud["nom_usuel"] = "" - etud["prenom"] = format_prenom(etud["prenom"]) - etud["civilite_str"] = format_civilite(etud["civilite"]) - # Nom à afficher: - if etud["nom_usuel"]: - etud["nom_disp"] = etud["nom_usuel"] - if etud["nom"]: - etud["nom_disp"] += " (" + etud["nom"] + ")" - else: - etud["nom_disp"] = etud["nom"] - - etud["nomprenom"] = format_nomprenom(etud) # M. Pierre DUPONT - if etud["civilite"] == "M": - etud["ne"] = "" - elif etud["civilite"] == "F": - etud["ne"] = "e" - else: # 'X' - etud["ne"] = "(e)" - # Mail à utiliser pour les envois vers l'étudiant: - # choix qui pourrait être controé par une preference - # ici priorité au mail institutionnel: - etud["email_default"] = etud.get("email", "") or etud.get("emailperso", "") - - -def force_uppercase(s): - return s.upper() if s else s - - -def format_nomprenom(etud, reverse=False): - """Formatte civilité/nom/prenom pour affichages: "M. Pierre Dupont" - Si reverse, "Dupont Pierre", sans civilité. - - DEPRECATED: utiliser Identite.nomprenom - """ - nom = etud.get("nom_disp", "") or etud.get("nom_usuel", "") or etud["nom"] - prenom = format_prenom(etud["prenom"]) - civilite = format_civilite(etud["civilite"]) - if reverse: - fs = [nom, prenom] - else: - fs = [civilite, prenom, nom] - return " ".join([x for x in fs if x]) - - -def format_prenom(s): - """Formatte prenom etudiant pour affichage - DEPRECATED: utiliser Identite.prenom_str - """ - if not s: - return "" - frags = s.split() - r = [] - for frag in frags: - fs = frag.split("-") - r.append("-".join([x.lower().capitalize() for x in fs])) - return " ".join(r) - - -def format_nom(s, uppercase=True): - if not s: - return "" - if uppercase: - return s.upper() - else: - return format_prenom(s) - - -def input_civilite(s): - """Converts external representation of civilite to internal: - 'M', 'F', or 'X' (and nothing else). - Raises ScoValueError if conversion fails. - """ - s = s.upper().strip() - if s in ("M", "M.", "MR", "H"): - return "M" - elif s in ("F", "MLLE", "MLLE.", "MELLE", "MME"): - return "F" - elif s == "X" or not s: - return "X" - raise ScoValueError("valeur invalide pour la civilité: %s" % s) - - -def format_civilite(civilite): - """returns 'M.' ou 'Mme' ou '' (pour le genre neutre, - personne ne souhaitant pas d'affichage). - Raises ScoValueError if conversion fails. - """ - try: - return { - "M": "M.", - "F": "Mme", - "X": "", - }[civilite] - except KeyError: - raise ScoValueError("valeur invalide pour la civilité: %s" % civilite) - - -def format_lycee(nomlycee): - nomlycee = nomlycee.strip() - s = nomlycee.lower() - if s[:5] == "lycee" or s[:5] == "lycée": - return nomlycee[5:] - else: - return nomlycee - - -def format_telephone(n): - if n is None: - return "" - if len(n) < 7: - return n - else: - n = n.replace(" ", "").replace(".", "") - i = 0 - r = "" - j = len(n) - 1 - while j >= 0: - r = n[j] + r - if i % 2 == 1 and j != 0: - r = " " + r - i += 1 - j -= 1 - if len(r) == 13 and r[0] != "0": - r = "0" + r - return r - - -def format_pays(s): - "laisse le pays seulement si != FRANCE" - if s.upper() != "FRANCE": - return s - else: - return "" - - -PIVOT_YEAR = 70 - - -def pivot_year(y): - if y == "" or y is None: - return None - y = int(round(float(y))) - if y >= 0 and y < 100: - if y < PIVOT_YEAR: - y = y + 2000 - else: - y = y + 1900 - return y - - -def etud_sort_key(etud: dict) -> tuple: - """Clé de tri pour les étudiants représentés par des dict (anciens codes). - Equivalent moderne: identite.sort_key - """ - return ( - scu.sanitize_string( - etud.get("nom_usuel") or etud["nom"] or "", remove_spaces=False - ).lower(), - scu.sanitize_string(etud["prenom"] or "", remove_spaces=False).lower(), - ) - - -_identiteEditor = ndb.EditableTable( - "identite", - "etudid", - ( - "etudid", - "nom", - "nom_usuel", - "prenom", - "civilite", # 'M", "F", or "X" - "date_naissance", - "lieu_naissance", - "dept_naissance", - "nationalite", - "statut", - "boursier", - "foto", - "photo_filename", - "code_ine", - "code_nip", - ), - filter_dept=True, - sortkey="nom", - input_formators={ - "nom": force_uppercase, - "prenom": force_uppercase, - "civilite": input_civilite, - "date_naissance": ndb.DateDMYtoISO, - "boursier": bool, - }, - output_formators={"date_naissance": ndb.DateISOtoDMY}, - convert_null_outputs_to_empty=True, - # allow_set_id=True, # car on specifie le code Apogee a la creation #sco8 -) - -identite_delete = _identiteEditor.delete - - -def identite_list(cnx, *a, **kw): - """List, adding on the fly 'annee_naissance' and 'civilite_str' (M., Mme, "").""" - objs = _identiteEditor.list(cnx, *a, **kw) - for o in objs: - if o["date_naissance"]: - o["annee_naissance"] = int(o["date_naissance"].split("/")[2]) - else: - o["annee_naissance"] = o["date_naissance"] - o["civilite_str"] = format_civilite(o["civilite"]) - return objs - - -def identite_edit_nocheck(cnx, args): - """Modifie les champs mentionnes dans args, sans verification ni notification.""" - _identiteEditor.edit(cnx, args) - - -def check_nom_prenom(cnx, nom="", prenom="", etudid=None): - """Check if nom and prenom are valid. - Also check for duplicates (homonyms), excluding etudid : - in general, homonyms are allowed, but it may be useful to generate a warning. - Returns: - True | False, NbHomonyms - """ - if not nom or (not prenom and not scu.CONFIG.ALLOW_NULL_PRENOM): - return False, 0 - nom = nom.lower().strip() - if prenom: - prenom = prenom.lower().strip() - # Don't allow some special cars (eg used in sql regexps) - if scu.FORBIDDEN_CHARS_EXP.search(nom) or scu.FORBIDDEN_CHARS_EXP.search(prenom): - return False, 0 - # Now count homonyms (dans tous les départements): - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - req = """SELECT id - FROM identite - WHERE lower(nom) ~ %(nom)s - and lower(prenom) ~ %(prenom)s - """ - if etudid: - req += " and id <> %(etudid)s" - cursor.execute(req, {"nom": nom, "prenom": prenom, "etudid": etudid}) - res = cursor.dictfetchall() - return True, len(res) - - -def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True): - etudid = args.get("etudid", None) - if args.get(code_name, None): - etuds = identite_list(cnx, {code_name: str(args[code_name])}) - # log('etuds=%s'%etuds) - nb_max = 0 - if edit: - nb_max = 1 - if len(etuds) > nb_max: - listh = [] # liste des doubles - for e in etuds: - listh.append( - """Autre étudiant: """ - % url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=e["etudid"], - ) - + """%(nom)s %(prenom)s""" % e - ) - if etudid: - OK = "retour à la fiche étudiant" - dest_endpoint = "scolar.ficheEtud" - parameters = {"etudid": etudid} - else: - if "tf_submitted" in args: - del args["tf_submitted"] - OK = "Continuer" - dest_endpoint = "scolar.etudident_create_form" - parameters = args - else: - OK = "Annuler" - dest_endpoint = "notes.index_html" - parameters = {} - if not disable_notify: - err_page = f"""

Code étudiant ({code_name}) dupliqué !

-

Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir - ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur. -

-
  • - { '
  • '.join(listh) } -
-

- {OK} -

- """ - else: - err_page = f"""

Code étudiant ({code_name}) dupliqué !

""" - log("*** error: code %s duplique: %s" % (code_name, args[code_name])) - raise ScoGenError(err_page) - - -def _check_civilite(args): - civilite = args.get("civilite", "X") or "X" - args["civilite"] = input_civilite(civilite) # TODO: A faire valider - - -def identite_edit(cnx, args, disable_notify=False): - """Modifie l'identite d'un étudiant. - Si pref notification et difference, envoie message notification, sauf si disable_notify - """ - _check_duplicate_code( - cnx, args, "code_nip", disable_notify=disable_notify, edit=True - ) - _check_duplicate_code( - cnx, args, "code_ine", disable_notify=disable_notify, edit=True - ) - notify_to = None - if not disable_notify: - try: - notify_to = sco_preferences.get_preference("notify_etud_changes_to") - except: - pass - - if notify_to: - # etat AVANT edition pour envoyer diffs - before = identite_list(cnx, {"etudid": args["etudid"]})[0] - - identite_edit_nocheck(cnx, args) - - # Notification du changement par e-mail: - if notify_to: - etud = get_etud_info(etudid=args["etudid"], filled=True)[0] - after = identite_list(cnx, {"etudid": args["etudid"]})[0] - notify_etud_change( - notify_to, - etud, - before, - after, - "Modification identite %(nomprenom)s" % etud, - ) - - -def identite_create(cnx, args): - "check unique etudid, then create" - _check_duplicate_code(cnx, args, "code_nip", edit=False) - _check_duplicate_code(cnx, args, "code_ine", edit=False) - _check_civilite(args) - - if "etudid" in args: - etudid = args["etudid"] - r = identite_list(cnx, {"etudid": etudid}) - if r: - raise ScoValueError( - "Code identifiant (etudid) déjà utilisé ! (%s)" % etudid - ) - return _identiteEditor.create(cnx, args) - - -def notify_etud_change(email_addr, etud, before, after, subject): - """Send email notifying changes to etud - before and after are two dicts, with values before and after the change. - """ - txt = [ - "Code NIP:" + etud["code_nip"], - "Civilité: " + etud["civilite_str"], - "Nom: " + etud["nom"], - "Prénom: " + etud["prenom"], - "Etudid: " + str(etud["etudid"]), - "\n", - "Changements effectués:", - ] - n = 0 - for key in after.keys(): - if before[key] != after[key]: - txt.append('%s: %s (auparavant: "%s")' % (key, after[key], before[key])) - n += 1 - if not n: - return # pas de changements - txt = "\n".join(txt) - # build mail - log("notify_etud_change: sending notification to %s" % email_addr) - log("notify_etud_change: subject: %s" % subject) - log(txt) - email.send_email( - subject, sco_preferences.get_preference("email_from_addr"), [email_addr], txt - ) - return txt - - -# -------- -# Note: la table adresse n'est pas dans dans la table "identite" -# car on prevoit plusieurs adresses par etudiant (ie domicile, entreprise) - -_adresseEditor = ndb.EditableTable( - "adresse", - "adresse_id", - ( - "adresse_id", - "etudid", - "email", - "emailperso", - "domicile", - "codepostaldomicile", - "villedomicile", - "paysdomicile", - "telephone", - "telephonemobile", - "fax", - "typeadresse", - "description", - ), - convert_null_outputs_to_empty=True, -) - -adresse_create = _adresseEditor.create -adresse_delete = _adresseEditor.delete -adresse_list = _adresseEditor.list - - -def adresse_edit(cnx, args, disable_notify=False): - """Modifie l'adresse d'un étudiant. - Si pref notification et difference, envoie message notification, sauf si disable_notify - """ - notify_to = None - if not disable_notify: - try: - notify_to = sco_preferences.get_preference("notify_etud_changes_to") - except: - pass - if notify_to: - # etat AVANT edition pour envoyer diffs - before = adresse_list(cnx, {"etudid": args["etudid"]})[0] - - _adresseEditor.edit(cnx, args) - - # Notification du changement par e-mail: - if notify_to: - etud = get_etud_info(etudid=args["etudid"], filled=True)[0] - after = adresse_list(cnx, {"etudid": args["etudid"]})[0] - notify_etud_change( - notify_to, - etud, - before, - after, - "Modification adresse %(nomprenom)s" % etud, - ) - - -def getEmail(cnx, etudid): - "get email institutionnel etudiant (si plusieurs adresses, prend le premier non null" - adrs = adresse_list(cnx, {"etudid": etudid}) - for adr in adrs: - if adr["email"]: - return adr["email"] - return "" - - -# --------- -_admissionEditor = ndb.EditableTable( - "admissions", - "adm_id", - ( - "adm_id", - "etudid", - "annee", - "bac", - "specialite", - "annee_bac", - "math", - "physique", - "anglais", - "francais", - "rang", - "qualite", - "rapporteur", - "decision", - "score", - "classement", - "apb_groupe", - "apb_classement_gr", - "commentaire", - "nomlycee", - "villelycee", - "codepostallycee", - "codelycee", - "type_admission", - "boursier_prec", - ), - input_formators={ - "annee": pivot_year, - "bac": force_uppercase, - "specialite": force_uppercase, - "annee_bac": pivot_year, - "classement": ndb.int_null_is_null, - "apb_classement_gr": ndb.int_null_is_null, - "boursier_prec": bool, - }, - output_formators={"type_admission": lambda x: x or scu.TYPE_ADMISSION_DEFAULT}, - convert_null_outputs_to_empty=True, -) - -admission_create = _admissionEditor.create -admission_delete = _admissionEditor.delete -admission_list = _admissionEditor.list -admission_edit = _admissionEditor.edit - -# Edition simultanee de identite et admission -class EtudIdentEditor(object): - def create(self, cnx, args): - etudid = identite_create(cnx, args) - args["etudid"] = etudid - admission_create(cnx, args) - return etudid - - def list(self, *args, **kw): - R = identite_list(*args, **kw) - Ra = admission_list(*args, **kw) - # print len(R), len(Ra) - # merge: add admission fields to identite - A = {} - for r in Ra: - A[r["etudid"]] = r - res = [] - for i in R: - res.append(i) - if i["etudid"] in A: - # merge - res[-1].update(A[i["etudid"]]) - else: # pas d'etudiant trouve - # print "*** pas d'info admission pour %s" % str(i) - void_adm = { - k: None - for k in _admissionEditor.dbfields - if k != "etudid" and k != "adm_id" - } - res[-1].update(void_adm) - # tri par nom - res.sort(key=itemgetter("nom", "prenom")) - return res - - def edit(self, cnx, args, disable_notify=False): - identite_edit(cnx, args, disable_notify=disable_notify) - if "adm_id" in args: # safety net - admission_edit(cnx, args) - - -_etudidentEditor = EtudIdentEditor() -etudident_list = _etudidentEditor.list -etudident_edit = _etudidentEditor.edit -etudident_create = _etudidentEditor.create - - -def log_unknown_etud(): - """Log request: cas ou getEtudInfo n'a pas ramene de resultat""" - etud_args = make_etud_args(raise_exc=False) - log(f"unknown student: args={etud_args}") - - -def get_etud_info(etudid=False, code_nip=False, filled=False) -> list[dict]: - """infos sur un etudiant (API). If not found, returns empty list. - On peut spécifier etudid ou code_nip - ou bien cherche dans les arguments de la requête courante: - etudid, code_nip, code_ine (dans cet ordre). - """ - if etudid is None: - return [] - cnx = ndb.GetDBConnexion() - args = make_etud_args(etudid=etudid, code_nip=code_nip) - etud = etudident_list(cnx, args=args) - - if filled: - fill_etuds_info(etud) - return etud - - -# Optim par cache local, utilité non prouvée mais -# on s'oriente vers un cahce persistent dans Redis ou bien l'utilisation de NT -# def get_etud_info_filled_by_etudid(etudid, cnx=None) -> dict: -# """Infos sur un étudiant, avec cache local à la requête""" -# if etudid in g.stored_etud_info: -# return g.stored_etud_info[etudid] -# cnx = cnx or ndb.GetDBConnexion() -# etud = etudident_list(cnx, args={"etudid": etudid}) -# fill_etuds_info(etud) -# g.stored_etud_info[etudid] = etud[0] -# return etud[0] - - -def create_etud(cnx, args={}): - """Creation d'un étudiant. génère aussi évenement et "news". - - Args: - args: dict avec les attributs de l'étudiant - - Returns: - etud, l'étudiant créé. - """ - from app.models import ScolarNews - - # creation d'un etudiant - etudid = etudident_create(cnx, args) - # crée une adresse vide (chaque etudiant doit etre dans la table "adresse" !) - _ = adresse_create( - cnx, - { - "etudid": etudid, - "typeadresse": "domicile", - "description": "(creation individuelle)", - }, - ) - - # event - scolar_events_create( - cnx, - args={ - "etudid": etudid, - "event_date": time.strftime("%d/%m/%Y"), - "formsemestre_id": None, - "event_type": "CREATION", - }, - ) - # log - logdb( - cnx, - method="etudident_edit_form", - etudid=etudid, - msg="creation initiale", - ) - etud = etudident_list(cnx, {"etudid": etudid})[0] - fill_etuds_info([etud]) - etud["url"] = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ScolarNews.add( - typ=ScolarNews.NEWS_INSCR, - text='Nouvel étudiant %(nomprenom)s' % etud, - url=etud["url"], - ) - return etud - - -# ---------- "EVENTS" -_scolar_eventsEditor = ndb.EditableTable( - "scolar_events", - "event_id", - ( - "event_id", - "etudid", - "event_date", - "formsemestre_id", - "ue_id", - "event_type", - "comp_formsemestre_id", - ), - sortkey="event_date", - convert_null_outputs_to_empty=True, - output_formators={"event_date": ndb.DateISOtoDMY}, - input_formators={"event_date": ndb.DateDMYtoISO}, -) - -# scolar_events_create = _scolar_eventsEditor.create -scolar_events_delete = _scolar_eventsEditor.delete -scolar_events_list = _scolar_eventsEditor.list -scolar_events_edit = _scolar_eventsEditor.edit - - -def scolar_events_create(cnx, args): - # several "events" may share the same values - _scolar_eventsEditor.create(cnx, args) - - -# -------- -_etud_annotationsEditor = ndb.EditableTable( - "etud_annotations", - "id", - ( - "id", - "date", - "etudid", - "author", - "comment", - "author", - ), - sortkey="date desc", - convert_null_outputs_to_empty=True, - output_formators={"comment": safehtml.html_to_safe_html, "date": ndb.DateISOtoDMY}, -) - -etud_annotations_create = _etud_annotationsEditor.create -etud_annotations_delete = _etud_annotationsEditor.delete -etud_annotations_list = _etud_annotationsEditor.list -etud_annotations_edit = _etud_annotationsEditor.edit - - -def add_annotations_to_etud_list(etuds): - """Add key 'annotations' describing annotations of etuds - (used to list all annotations of a group) - """ - cnx = ndb.GetDBConnexion() - for etud in etuds: - l = [] - for a in etud_annotations_list(cnx, args={"etudid": etud["etudid"]}): - l.append("%(comment)s (%(date)s)" % a) - etud["annotations_str"] = ", ".join(l) - - -# -------- APPRECIATIONS (sur bulletins) ------------------- -# Les appreciations sont dans la table postgres notes_appreciations -_appreciationsEditor = ndb.EditableTable( - "notes_appreciations", - "id", - ( - "id", - "date", - "etudid", - "formsemestre_id", - "author", - "comment", - "author", - ), - sortkey="date desc", - convert_null_outputs_to_empty=True, - output_formators={"comment": safehtml.html_to_safe_html, "date": ndb.DateISOtoDMY}, -) - -appreciations_create = _appreciationsEditor.create -appreciations_delete = _appreciationsEditor.delete -appreciations_list = _appreciationsEditor.list -appreciations_edit = _appreciationsEditor.edit - - -# -------- Noms des Lycées à partir du code -def read_etablissements(): - filename = os.path.join(scu.SCO_TOOLS_DIR, scu.CONFIG.ETABL_FILENAME) - log("reading %s" % filename) - with open(filename) as f: - L = [x[:-1].split(";") for x in f] - E = {} - for l in L[1:]: - E[l[0]] = { - "name": l[1], - "address": l[2], - "codepostal": l[3], - "commune": l[4], - "position": l[5] + "," + l[6], - } - return E - - -ETABLISSEMENTS = None - - -def get_etablissements(): - global ETABLISSEMENTS - if ETABLISSEMENTS is None: - ETABLISSEMENTS = read_etablissements() - return ETABLISSEMENTS - - -def get_lycee_infos(codelycee): - E = get_etablissements() - return E.get(codelycee, None) - - -def format_lycee_from_code(codelycee): - "Description lycee à partir du code" - E = get_etablissements() - if codelycee in E: - e = E[codelycee] - nomlycee = e["name"] - return "%s (%s)" % (nomlycee, e["commune"]) - else: - return "%s (établissement inconnu)" % codelycee - - -def etud_add_lycee_infos(etud): - """Si codelycee est renseigné, ajout les champs au dict""" - if etud["codelycee"]: - il = get_lycee_infos(etud["codelycee"]) - if il: - if not etud["codepostallycee"]: - etud["codepostallycee"] = il["codepostal"] - if not etud["nomlycee"]: - etud["nomlycee"] = il["name"] - if not etud["villelycee"]: - etud["villelycee"] = il["commune"] - if not etud.get("positionlycee", None): - if il["position"] != "0.0,0.0": - etud["positionlycee"] = il["position"] - return etud - - -""" Conversion fichier original: -f = open('etablissements.csv') -o = open('etablissements2.csv', 'w') -o.write( f.readline() ) -for l in f: - fs = l.split(';') - nom = ' '.join( [ x.capitalize() for x in fs[1].split() ] ) - adr = ' '.join( [ x.capitalize() for x in fs[2].split() ] ) - ville=' '.join( [ x.capitalize() for x in fs[4].split() ] ) - o.write( '%s;%s;%s;%s;%s\n' % (fs[0], nom, adr, fs[3], ville)) - -o.close() -""" - - -def list_scolog(etudid): - "liste des operations effectuees sur cet etudiant" - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - "SELECT * FROM scolog WHERE etudid=%(etudid)s ORDER BY DATE DESC", - {"etudid": etudid}, - ) - return cursor.dictfetchall() - - -def fill_etuds_info(etuds: list[dict], add_admission=True): - """etuds est une liste d'etudiants (mappings) - Pour chaque etudiant, ajoute ou formatte les champs - -> informations pour fiche etudiant ou listes diverses - - Si add_admission: ajoute au dict le schamps "admission" s'il n'y sont pas déjà. - """ - cnx = ndb.GetDBConnexion() - for etud in etuds: - etudid = etud["etudid"] - etud["dept"] = g.scodoc_dept - # Admission - if add_admission and "nomlycee" not in etud: - admission = ( - Admission.query.filter_by(etudid=etudid).first().to_dict(no_nulls=True) - ) - del admission["id"] # pour garder id == etudid dans etud - etud.update(admission) - # - adrs = adresse_list(cnx, {"etudid": etudid}) - if not adrs: - # certains "vieux" etudiants n'ont pas d'adresse - adr = {}.fromkeys(_adresseEditor.dbfields, "") - adr["etudid"] = etudid - else: - adr = adrs[0] - if len(adrs) > 1: - log("fill_etuds_info: etudid=%s a %d adresses" % (etudid, len(adrs))) - adr.pop("id", None) - etud.update(adr) - format_etud_ident(etud) - - etud.update(etud_inscriptions_infos(etudid, etud["ne"])) - - # nettoyage champs souvent vides - if etud.get("nomlycee"): - etud["ilycee"] = "Lycée " + format_lycee(etud["nomlycee"]) - if etud["villelycee"]: - etud["ilycee"] += " (%s)" % etud.get("villelycee", "") - etud["ilycee"] += "
" - else: - if etud.get("codelycee"): - etud["ilycee"] = format_lycee_from_code(etud["codelycee"]) - else: - etud["ilycee"] = "" - rap = "" - if etud.get("rapporteur") or etud.get("commentaire"): - rap = "Note du rapporteur" - if etud.get("rapporteur"): - rap += " (%s)" % etud["rapporteur"] - rap += ": " - if etud.get("commentaire"): - rap += "%s" % etud["commentaire"] - etud["rap"] = rap - - # if etud['boursier_prec']: - # pass - - if etud.get("telephone"): - etud["telephonestr"] = "Tél.: " + format_telephone(etud["telephone"]) - else: - etud["telephonestr"] = "" - if etud.get("telephonemobile"): - etud["telephonemobilestr"] = "Mobile: " + format_telephone( - etud["telephonemobile"] - ) - else: - etud["telephonemobilestr"] = "" - - -def etud_inscriptions_infos(etudid: int, ne="") -> dict: - """Dict avec les informations sur les semestres passés et courant""" - from app.scodoc import sco_formsemestre - from app.scodoc import sco_formsemestre_inscriptions - - etud = {} - # Semestres dans lesquel il est inscrit - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - {"etudid": etudid} - ) - etud["ins"] = ins - sems = [] - cursem = None # semestre "courant" ou il est inscrit - for i in ins: - sem = sco_formsemestre.get_formsemestre(i["formsemestre_id"]) - if sco_formsemestre.sem_est_courant(sem): - cursem = sem - curi = i - sem["ins"] = i - sems.append(sem) - # trie les semestres par date de debut, le plus recent d'abord - # (important, ne pas changer (suivi cohortes)) - sems.sort(key=itemgetter("dateord"), reverse=True) - etud["sems"] = sems - etud["cursem"] = cursem - if cursem: - etud["inscription"] = cursem["titremois"] - etud["inscriptionstr"] = "Inscrit en " + cursem["titremois"] - etud["inscription_formsemestre_id"] = cursem["formsemestre_id"] - etud["etatincursem"] = curi["etat"] - etud["situation"] = descr_situation_etud(etudid, ne) - else: - if etud["sems"]: - if etud["sems"][0]["dateord"] > time.strftime("%Y-%m-%d", time.localtime()): - etud["inscription"] = "futur" - etud["situation"] = "futur élève" - else: - etud["inscription"] = "ancien" - etud["situation"] = "ancien élève" - else: - etud["inscription"] = "non inscrit" - etud["situation"] = etud["inscription"] - etud["inscriptionstr"] = etud["inscription"] - etud["inscription_formsemestre_id"] = None - etud["etatincursem"] = "?" - return etud - - -def descr_situation_etud(etudid: int, ne="") -> str: - """Chaîne décrivant la situation actuelle de l'étudiant - XXX Obsolete, utiliser Identite.descr_situation_etud() dans - les nouveaux codes - """ - from app.scodoc import sco_formsemestre - - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - """SELECT I.formsemestre_id, I.etat - FROM notes_formsemestre_inscription I, notes_formsemestre S - WHERE etudid=%(etudid)s - and S.id = I.formsemestre_id - and date_debut < now() - and date_fin > now() - ORDER BY S.date_debut DESC;""", - {"etudid": etudid}, - ) - r = cursor.dictfetchone() - if not r: - situation = "non inscrit" + ne - else: - sem = sco_formsemestre.get_formsemestre(r["formsemestre_id"]) - if r["etat"] == "I": - situation = "inscrit%s en %s" % (ne, sem["titremois"]) - # Cherche la date d'inscription dans scolar_events: - events = scolar_events_list( - cnx, - args={ - "etudid": etudid, - "formsemestre_id": sem["formsemestre_id"], - "event_type": "INSCRIPTION", - }, - ) - if not events: - log( - "*** situation inconsistante pour %s (inscrit mais pas d'event)" - % etudid - ) - date_ins = "???" # ??? - else: - date_ins = events[0]["event_date"] - situation += " le " + str(date_ins) - else: - situation = "démission de %s" % sem["titremois"] - # Cherche la date de demission dans scolar_events: - events = scolar_events_list( - cnx, - args={ - "etudid": etudid, - "formsemestre_id": sem["formsemestre_id"], - "event_type": "DEMISSION", - }, - ) - if not events: - log( - "*** situation inconsistante pour %s (demission mais pas d'event)" - % etudid - ) - date_dem = "???" # ??? - else: - date_dem = events[0]["event_date"] - situation += " le " + str(date_dem) - return situation +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" Accès donnees etudiants +""" + +# Ancien module "scolars" +import os +import time +from operator import itemgetter + +from flask import url_for, g + +from app import email +from app import log +from app.models import Admission +from app.models.etudiants import make_etud_args +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app.scodoc.sco_exceptions import ScoGenError, ScoValueError +from app.scodoc import safehtml +from app.scodoc import sco_preferences +from app.scodoc.scolog import logdb + + +def format_etud_ident(etud): + """Format identite de l'étudiant (modifié en place) + nom, prénom et formes associees + """ + etud["nom"] = format_nom(etud["nom"]) + if "nom_usuel" in etud: + etud["nom_usuel"] = format_nom(etud["nom_usuel"]) + else: + etud["nom_usuel"] = "" + etud["prenom"] = format_prenom(etud["prenom"]) + etud["civilite_str"] = format_civilite(etud["civilite"]) + # Nom à afficher: + if etud["nom_usuel"]: + etud["nom_disp"] = etud["nom_usuel"] + if etud["nom"]: + etud["nom_disp"] += " (" + etud["nom"] + ")" + else: + etud["nom_disp"] = etud["nom"] + + etud["nomprenom"] = format_nomprenom(etud) # M. Pierre DUPONT + if etud["civilite"] == "M": + etud["ne"] = "" + elif etud["civilite"] == "F": + etud["ne"] = "e" + else: # 'X' + etud["ne"] = "(e)" + # Mail à utiliser pour les envois vers l'étudiant: + # choix qui pourrait être controé par une preference + # ici priorité au mail institutionnel: + etud["email_default"] = etud.get("email", "") or etud.get("emailperso", "") + + +def force_uppercase(s): + return s.upper() if s else s + + +def format_nomprenom(etud, reverse=False): + """Formatte civilité/nom/prenom pour affichages: "M. Pierre Dupont" + Si reverse, "Dupont Pierre", sans civilité. + + DEPRECATED: utiliser Identite.nomprenom + """ + nom = etud.get("nom_disp", "") or etud.get("nom_usuel", "") or etud["nom"] + prenom = format_prenom(etud["prenom"]) + civilite = format_civilite(etud["civilite"]) + if reverse: + fs = [nom, prenom] + else: + fs = [civilite, prenom, nom] + return " ".join([x for x in fs if x]) + + +def format_prenom(s): + """Formatte prenom etudiant pour affichage + DEPRECATED: utiliser Identite.prenom_str + """ + if not s: + return "" + frags = s.split() + r = [] + for frag in frags: + fs = frag.split("-") + r.append("-".join([x.lower().capitalize() for x in fs])) + return " ".join(r) + + +def format_nom(s, uppercase=True): + if not s: + return "" + if uppercase: + return s.upper() + else: + return format_prenom(s) + + +def input_civilite(s): + """Converts external representation of civilite to internal: + 'M', 'F', or 'X' (and nothing else). + Raises ScoValueError if conversion fails. + """ + s = s.upper().strip() + if s in ("M", "M.", "MR", "H"): + return "M" + elif s in ("F", "MLLE", "MLLE.", "MELLE", "MME"): + return "F" + elif s == "X" or not s: + return "X" + raise ScoValueError("valeur invalide pour la civilité: %s" % s) + + +def format_civilite(civilite): + """returns 'M.' ou 'Mme' ou '' (pour le genre neutre, + personne ne souhaitant pas d'affichage). + Raises ScoValueError if conversion fails. + """ + try: + return { + "M": "M.", + "F": "Mme", + "X": "", + }[civilite] + except KeyError: + raise ScoValueError("valeur invalide pour la civilité: %s" % civilite) + + +def format_lycee(nomlycee): + nomlycee = nomlycee.strip() + s = nomlycee.lower() + if s[:5] == "lycee" or s[:5] == "lycée": + return nomlycee[5:] + else: + return nomlycee + + +def format_telephone(n): + if n is None: + return "" + if len(n) < 7: + return n + else: + n = n.replace(" ", "").replace(".", "") + i = 0 + r = "" + j = len(n) - 1 + while j >= 0: + r = n[j] + r + if i % 2 == 1 and j != 0: + r = " " + r + i += 1 + j -= 1 + if len(r) == 13 and r[0] != "0": + r = "0" + r + return r + + +def format_pays(s): + "laisse le pays seulement si != FRANCE" + if s.upper() != "FRANCE": + return s + else: + return "" + + +PIVOT_YEAR = 70 + + +def pivot_year(y): + if y == "" or y is None: + return None + y = int(round(float(y))) + if y >= 0 and y < 100: + if y < PIVOT_YEAR: + y = y + 2000 + else: + y = y + 1900 + return y + + +def etud_sort_key(etud: dict) -> tuple: + """Clé de tri pour les étudiants représentés par des dict (anciens codes). + Equivalent moderne: identite.sort_key + """ + return ( + scu.sanitize_string( + etud.get("nom_usuel") or etud["nom"] or "", remove_spaces=False + ).lower(), + scu.sanitize_string(etud["prenom"] or "", remove_spaces=False).lower(), + ) + + +_identiteEditor = ndb.EditableTable( + "identite", + "etudid", + ( + "etudid", + "nom", + "nom_usuel", + "prenom", + "civilite", # 'M", "F", or "X" + "date_naissance", + "lieu_naissance", + "dept_naissance", + "nationalite", + "statut", + "boursier", + "foto", + "photo_filename", + "code_ine", + "code_nip", + ), + filter_dept=True, + sortkey="nom", + input_formators={ + "nom": force_uppercase, + "prenom": force_uppercase, + "civilite": input_civilite, + "date_naissance": ndb.DateDMYtoISO, + "boursier": bool, + }, + output_formators={"date_naissance": ndb.DateISOtoDMY}, + convert_null_outputs_to_empty=True, + # allow_set_id=True, # car on specifie le code Apogee a la creation #sco8 +) + +identite_delete = _identiteEditor.delete + + +def identite_list(cnx, *a, **kw): + """List, adding on the fly 'annee_naissance' and 'civilite_str' (M., Mme, "").""" + objs = _identiteEditor.list(cnx, *a, **kw) + for o in objs: + if o["date_naissance"]: + o["annee_naissance"] = int(o["date_naissance"].split("/")[2]) + else: + o["annee_naissance"] = o["date_naissance"] + o["civilite_str"] = format_civilite(o["civilite"]) + return objs + + +def identite_edit_nocheck(cnx, args): + """Modifie les champs mentionnes dans args, sans verification ni notification.""" + _identiteEditor.edit(cnx, args) + + +def check_nom_prenom(cnx, nom="", prenom="", etudid=None): + """Check if nom and prenom are valid. + Also check for duplicates (homonyms), excluding etudid : + in general, homonyms are allowed, but it may be useful to generate a warning. + Returns: + True | False, NbHomonyms + """ + if not nom or (not prenom and not scu.CONFIG.ALLOW_NULL_PRENOM): + return False, 0 + nom = nom.lower().strip() + if prenom: + prenom = prenom.lower().strip() + # Don't allow some special cars (eg used in sql regexps) + if scu.FORBIDDEN_CHARS_EXP.search(nom) or scu.FORBIDDEN_CHARS_EXP.search(prenom): + return False, 0 + # Now count homonyms (dans tous les départements): + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + req = """SELECT id + FROM identite + WHERE lower(nom) ~ %(nom)s + and lower(prenom) ~ %(prenom)s + """ + if etudid: + req += " and id <> %(etudid)s" + cursor.execute(req, {"nom": nom, "prenom": prenom, "etudid": etudid}) + res = cursor.dictfetchall() + return True, len(res) + + +def _check_duplicate_code(cnx, args, code_name, disable_notify=False, edit=True): + etudid = args.get("etudid", None) + if args.get(code_name, None): + etuds = identite_list(cnx, {code_name: str(args[code_name])}) + # log('etuds=%s'%etuds) + nb_max = 0 + if edit: + nb_max = 1 + if len(etuds) > nb_max: + listh = [] # liste des doubles + for e in etuds: + listh.append( + """Autre étudiant: """ + % url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=e["etudid"], + ) + + """%(nom)s %(prenom)s""" % e + ) + if etudid: + OK = "retour à la fiche étudiant" + dest_endpoint = "scolar.ficheEtud" + parameters = {"etudid": etudid} + else: + if "tf_submitted" in args: + del args["tf_submitted"] + OK = "Continuer" + dest_endpoint = "scolar.etudident_create_form" + parameters = args + else: + OK = "Annuler" + dest_endpoint = "notes.index_html" + parameters = {} + if not disable_notify: + err_page = f"""

Code étudiant ({code_name}) dupliqué !

+

Le {code_name} {args[code_name]} est déjà utilisé: un seul étudiant peut avoir + ce code. Vérifier votre valeur ou supprimer l'autre étudiant avec cette valeur. +

+
  • + { '
  • '.join(listh) } +
+

+ {OK} +

+ """ + else: + err_page = f"""

Code étudiant ({code_name}) dupliqué !

""" + log("*** error: code %s duplique: %s" % (code_name, args[code_name])) + raise ScoGenError(err_page) + + +def _check_civilite(args): + civilite = args.get("civilite", "X") or "X" + args["civilite"] = input_civilite(civilite) # TODO: A faire valider + + +def identite_edit(cnx, args, disable_notify=False): + """Modifie l'identite d'un étudiant. + Si pref notification et difference, envoie message notification, sauf si disable_notify + """ + _check_duplicate_code( + cnx, args, "code_nip", disable_notify=disable_notify, edit=True + ) + _check_duplicate_code( + cnx, args, "code_ine", disable_notify=disable_notify, edit=True + ) + notify_to = None + if not disable_notify: + try: + notify_to = sco_preferences.get_preference("notify_etud_changes_to") + except: + pass + + if notify_to: + # etat AVANT edition pour envoyer diffs + before = identite_list(cnx, {"etudid": args["etudid"]})[0] + + identite_edit_nocheck(cnx, args) + + # Notification du changement par e-mail: + if notify_to: + etud = get_etud_info(etudid=args["etudid"], filled=True)[0] + after = identite_list(cnx, {"etudid": args["etudid"]})[0] + notify_etud_change( + notify_to, + etud, + before, + after, + "Modification identite %(nomprenom)s" % etud, + ) + + +def identite_create(cnx, args): + "check unique etudid, then create" + _check_duplicate_code(cnx, args, "code_nip", edit=False) + _check_duplicate_code(cnx, args, "code_ine", edit=False) + _check_civilite(args) + + if "etudid" in args: + etudid = args["etudid"] + r = identite_list(cnx, {"etudid": etudid}) + if r: + raise ScoValueError( + "Code identifiant (etudid) déjà utilisé ! (%s)" % etudid + ) + return _identiteEditor.create(cnx, args) + + +def notify_etud_change(email_addr, etud, before, after, subject): + """Send email notifying changes to etud + before and after are two dicts, with values before and after the change. + """ + txt = [ + "Code NIP:" + etud["code_nip"], + "Civilité: " + etud["civilite_str"], + "Nom: " + etud["nom"], + "Prénom: " + etud["prenom"], + "Etudid: " + str(etud["etudid"]), + "\n", + "Changements effectués:", + ] + n = 0 + for key in after.keys(): + if before[key] != after[key]: + txt.append('%s: %s (auparavant: "%s")' % (key, after[key], before[key])) + n += 1 + if not n: + return # pas de changements + txt = "\n".join(txt) + # build mail + log("notify_etud_change: sending notification to %s" % email_addr) + log("notify_etud_change: subject: %s" % subject) + log(txt) + email.send_email( + subject, sco_preferences.get_preference("email_from_addr"), [email_addr], txt + ) + return txt + + +# -------- +# Note: la table adresse n'est pas dans dans la table "identite" +# car on prevoit plusieurs adresses par etudiant (ie domicile, entreprise) + +_adresseEditor = ndb.EditableTable( + "adresse", + "adresse_id", + ( + "adresse_id", + "etudid", + "email", + "emailperso", + "domicile", + "codepostaldomicile", + "villedomicile", + "paysdomicile", + "telephone", + "telephonemobile", + "fax", + "typeadresse", + "description", + ), + convert_null_outputs_to_empty=True, +) + +adresse_create = _adresseEditor.create +adresse_delete = _adresseEditor.delete +adresse_list = _adresseEditor.list + + +def adresse_edit(cnx, args, disable_notify=False): + """Modifie l'adresse d'un étudiant. + Si pref notification et difference, envoie message notification, sauf si disable_notify + """ + notify_to = None + if not disable_notify: + try: + notify_to = sco_preferences.get_preference("notify_etud_changes_to") + except: + pass + if notify_to: + # etat AVANT edition pour envoyer diffs + before = adresse_list(cnx, {"etudid": args["etudid"]})[0] + + _adresseEditor.edit(cnx, args) + + # Notification du changement par e-mail: + if notify_to: + etud = get_etud_info(etudid=args["etudid"], filled=True)[0] + after = adresse_list(cnx, {"etudid": args["etudid"]})[0] + notify_etud_change( + notify_to, + etud, + before, + after, + "Modification adresse %(nomprenom)s" % etud, + ) + + +def getEmail(cnx, etudid): + "get email institutionnel etudiant (si plusieurs adresses, prend le premier non null" + adrs = adresse_list(cnx, {"etudid": etudid}) + for adr in adrs: + if adr["email"]: + return adr["email"] + return "" + + +# --------- +_admissionEditor = ndb.EditableTable( + "admissions", + "adm_id", + ( + "adm_id", + "etudid", + "annee", + "bac", + "specialite", + "annee_bac", + "math", + "physique", + "anglais", + "francais", + "rang", + "qualite", + "rapporteur", + "decision", + "score", + "classement", + "apb_groupe", + "apb_classement_gr", + "commentaire", + "nomlycee", + "villelycee", + "codepostallycee", + "codelycee", + "type_admission", + "boursier_prec", + ), + input_formators={ + "annee": pivot_year, + "bac": force_uppercase, + "specialite": force_uppercase, + "annee_bac": pivot_year, + "classement": ndb.int_null_is_null, + "apb_classement_gr": ndb.int_null_is_null, + "boursier_prec": bool, + }, + output_formators={"type_admission": lambda x: x or scu.TYPE_ADMISSION_DEFAULT}, + convert_null_outputs_to_empty=True, +) + +admission_create = _admissionEditor.create +admission_delete = _admissionEditor.delete +admission_list = _admissionEditor.list +admission_edit = _admissionEditor.edit + +# Edition simultanee de identite et admission +class EtudIdentEditor(object): + def create(self, cnx, args): + etudid = identite_create(cnx, args) + args["etudid"] = etudid + admission_create(cnx, args) + return etudid + + def list(self, *args, **kw): + R = identite_list(*args, **kw) + Ra = admission_list(*args, **kw) + # print len(R), len(Ra) + # merge: add admission fields to identite + A = {} + for r in Ra: + A[r["etudid"]] = r + res = [] + for i in R: + res.append(i) + if i["etudid"] in A: + # merge + res[-1].update(A[i["etudid"]]) + else: # pas d'etudiant trouve + # print "*** pas d'info admission pour %s" % str(i) + void_adm = { + k: None + for k in _admissionEditor.dbfields + if k != "etudid" and k != "adm_id" + } + res[-1].update(void_adm) + # tri par nom + res.sort(key=itemgetter("nom", "prenom")) + return res + + def edit(self, cnx, args, disable_notify=False): + identite_edit(cnx, args, disable_notify=disable_notify) + if "adm_id" in args: # safety net + admission_edit(cnx, args) + + +_etudidentEditor = EtudIdentEditor() +etudident_list = _etudidentEditor.list +etudident_edit = _etudidentEditor.edit +etudident_create = _etudidentEditor.create + + +def log_unknown_etud(): + """Log request: cas ou getEtudInfo n'a pas ramene de resultat""" + etud_args = make_etud_args(raise_exc=False) + log(f"unknown student: args={etud_args}") + + +def get_etud_info(etudid=False, code_nip=False, filled=False) -> list[dict]: + """infos sur un etudiant (API). If not found, returns empty list. + On peut spécifier etudid ou code_nip + ou bien cherche dans les arguments de la requête courante: + etudid, code_nip, code_ine (dans cet ordre). + """ + if etudid is None: + return [] + cnx = ndb.GetDBConnexion() + args = make_etud_args(etudid=etudid, code_nip=code_nip) + etud = etudident_list(cnx, args=args) + + if filled: + fill_etuds_info(etud) + return etud + + +# Optim par cache local, utilité non prouvée mais +# on s'oriente vers un cahce persistent dans Redis ou bien l'utilisation de NT +# def get_etud_info_filled_by_etudid(etudid, cnx=None) -> dict: +# """Infos sur un étudiant, avec cache local à la requête""" +# if etudid in g.stored_etud_info: +# return g.stored_etud_info[etudid] +# cnx = cnx or ndb.GetDBConnexion() +# etud = etudident_list(cnx, args={"etudid": etudid}) +# fill_etuds_info(etud) +# g.stored_etud_info[etudid] = etud[0] +# return etud[0] + + +def create_etud(cnx, args={}): + """Creation d'un étudiant. génère aussi évenement et "news". + + Args: + args: dict avec les attributs de l'étudiant + + Returns: + etud, l'étudiant créé. + """ + from app.models import ScolarNews + + # creation d'un etudiant + etudid = etudident_create(cnx, args) + # crée une adresse vide (chaque etudiant doit etre dans la table "adresse" !) + _ = adresse_create( + cnx, + { + "etudid": etudid, + "typeadresse": "domicile", + "description": "(creation individuelle)", + }, + ) + + # event + scolar_events_create( + cnx, + args={ + "etudid": etudid, + "event_date": time.strftime("%d/%m/%Y"), + "formsemestre_id": None, + "event_type": "CREATION", + }, + ) + # log + logdb( + cnx, + method="etudident_edit_form", + etudid=etudid, + msg="creation initiale", + ) + etud = etudident_list(cnx, {"etudid": etudid})[0] + fill_etuds_info([etud]) + etud["url"] = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ScolarNews.add( + typ=ScolarNews.NEWS_INSCR, + text='Nouvel étudiant %(nomprenom)s' % etud, + url=etud["url"], + ) + return etud + + +# ---------- "EVENTS" +_scolar_eventsEditor = ndb.EditableTable( + "scolar_events", + "event_id", + ( + "event_id", + "etudid", + "event_date", + "formsemestre_id", + "ue_id", + "event_type", + "comp_formsemestre_id", + ), + sortkey="event_date", + convert_null_outputs_to_empty=True, + output_formators={"event_date": ndb.DateISOtoDMY}, + input_formators={"event_date": ndb.DateDMYtoISO}, +) + +# scolar_events_create = _scolar_eventsEditor.create +scolar_events_delete = _scolar_eventsEditor.delete +scolar_events_list = _scolar_eventsEditor.list +scolar_events_edit = _scolar_eventsEditor.edit + + +def scolar_events_create(cnx, args): + # several "events" may share the same values + _scolar_eventsEditor.create(cnx, args) + + +# -------- +_etud_annotationsEditor = ndb.EditableTable( + "etud_annotations", + "id", + ( + "id", + "date", + "etudid", + "author", + "comment", + "author", + ), + sortkey="date desc", + convert_null_outputs_to_empty=True, + output_formators={"comment": safehtml.html_to_safe_html, "date": ndb.DateISOtoDMY}, +) + +etud_annotations_create = _etud_annotationsEditor.create +etud_annotations_delete = _etud_annotationsEditor.delete +etud_annotations_list = _etud_annotationsEditor.list +etud_annotations_edit = _etud_annotationsEditor.edit + + +def add_annotations_to_etud_list(etuds): + """Add key 'annotations' describing annotations of etuds + (used to list all annotations of a group) + """ + cnx = ndb.GetDBConnexion() + for etud in etuds: + l = [] + for a in etud_annotations_list(cnx, args={"etudid": etud["etudid"]}): + l.append("%(comment)s (%(date)s)" % a) + etud["annotations_str"] = ", ".join(l) + + +# -------- APPRECIATIONS (sur bulletins) ------------------- +# Les appreciations sont dans la table postgres notes_appreciations +_appreciationsEditor = ndb.EditableTable( + "notes_appreciations", + "id", + ( + "id", + "date", + "etudid", + "formsemestre_id", + "author", + "comment", + "author", + ), + sortkey="date desc", + convert_null_outputs_to_empty=True, + output_formators={"comment": safehtml.html_to_safe_html, "date": ndb.DateISOtoDMY}, +) + +appreciations_create = _appreciationsEditor.create +appreciations_delete = _appreciationsEditor.delete +appreciations_list = _appreciationsEditor.list +appreciations_edit = _appreciationsEditor.edit + + +# -------- Noms des Lycées à partir du code +def read_etablissements(): + filename = os.path.join(scu.SCO_TOOLS_DIR, scu.CONFIG.ETABL_FILENAME) + log("reading %s" % filename) + with open(filename) as f: + L = [x[:-1].split(";") for x in f] + E = {} + for l in L[1:]: + E[l[0]] = { + "name": l[1], + "address": l[2], + "codepostal": l[3], + "commune": l[4], + "position": l[5] + "," + l[6], + } + return E + + +ETABLISSEMENTS = None + + +def get_etablissements(): + global ETABLISSEMENTS + if ETABLISSEMENTS is None: + ETABLISSEMENTS = read_etablissements() + return ETABLISSEMENTS + + +def get_lycee_infos(codelycee): + E = get_etablissements() + return E.get(codelycee, None) + + +def format_lycee_from_code(codelycee): + "Description lycee à partir du code" + E = get_etablissements() + if codelycee in E: + e = E[codelycee] + nomlycee = e["name"] + return "%s (%s)" % (nomlycee, e["commune"]) + else: + return "%s (établissement inconnu)" % codelycee + + +def etud_add_lycee_infos(etud): + """Si codelycee est renseigné, ajout les champs au dict""" + if etud["codelycee"]: + il = get_lycee_infos(etud["codelycee"]) + if il: + if not etud["codepostallycee"]: + etud["codepostallycee"] = il["codepostal"] + if not etud["nomlycee"]: + etud["nomlycee"] = il["name"] + if not etud["villelycee"]: + etud["villelycee"] = il["commune"] + if not etud.get("positionlycee", None): + if il["position"] != "0.0,0.0": + etud["positionlycee"] = il["position"] + return etud + + +""" Conversion fichier original: +f = open('etablissements.csv') +o = open('etablissements2.csv', 'w') +o.write( f.readline() ) +for l in f: + fs = l.split(';') + nom = ' '.join( [ x.capitalize() for x in fs[1].split() ] ) + adr = ' '.join( [ x.capitalize() for x in fs[2].split() ] ) + ville=' '.join( [ x.capitalize() for x in fs[4].split() ] ) + o.write( '%s;%s;%s;%s;%s\n' % (fs[0], nom, adr, fs[3], ville)) + +o.close() +""" + + +def list_scolog(etudid): + "liste des operations effectuees sur cet etudiant" + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + "SELECT * FROM scolog WHERE etudid=%(etudid)s ORDER BY DATE DESC", + {"etudid": etudid}, + ) + return cursor.dictfetchall() + + +def fill_etuds_info(etuds: list[dict], add_admission=True): + """etuds est une liste d'etudiants (mappings) + Pour chaque etudiant, ajoute ou formatte les champs + -> informations pour fiche etudiant ou listes diverses + + Si add_admission: ajoute au dict le schamps "admission" s'il n'y sont pas déjà. + """ + cnx = ndb.GetDBConnexion() + for etud in etuds: + etudid = etud["etudid"] + etud["dept"] = g.scodoc_dept + # Admission + if add_admission and "nomlycee" not in etud: + admission = ( + Admission.query.filter_by(etudid=etudid).first().to_dict(no_nulls=True) + ) + del admission["id"] # pour garder id == etudid dans etud + etud.update(admission) + # + adrs = adresse_list(cnx, {"etudid": etudid}) + if not adrs: + # certains "vieux" etudiants n'ont pas d'adresse + adr = {}.fromkeys(_adresseEditor.dbfields, "") + adr["etudid"] = etudid + else: + adr = adrs[0] + if len(adrs) > 1: + log("fill_etuds_info: etudid=%s a %d adresses" % (etudid, len(adrs))) + adr.pop("id", None) + etud.update(adr) + format_etud_ident(etud) + + etud.update(etud_inscriptions_infos(etudid, etud["ne"])) + + # nettoyage champs souvent vides + if etud.get("nomlycee"): + etud["ilycee"] = "Lycée " + format_lycee(etud["nomlycee"]) + if etud["villelycee"]: + etud["ilycee"] += " (%s)" % etud.get("villelycee", "") + etud["ilycee"] += "
" + else: + if etud.get("codelycee"): + etud["ilycee"] = format_lycee_from_code(etud["codelycee"]) + else: + etud["ilycee"] = "" + rap = "" + if etud.get("rapporteur") or etud.get("commentaire"): + rap = "Note du rapporteur" + if etud.get("rapporteur"): + rap += " (%s)" % etud["rapporteur"] + rap += ": " + if etud.get("commentaire"): + rap += "%s" % etud["commentaire"] + etud["rap"] = rap + + # if etud['boursier_prec']: + # pass + + if etud.get("telephone"): + etud["telephonestr"] = "Tél.: " + format_telephone(etud["telephone"]) + else: + etud["telephonestr"] = "" + if etud.get("telephonemobile"): + etud["telephonemobilestr"] = "Mobile: " + format_telephone( + etud["telephonemobile"] + ) + else: + etud["telephonemobilestr"] = "" + + +def etud_inscriptions_infos(etudid: int, ne="") -> dict: + """Dict avec les informations sur les semestres passés et courant""" + from app.scodoc import sco_formsemestre + from app.scodoc import sco_formsemestre_inscriptions + + etud = {} + # Semestres dans lesquel il est inscrit + ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + {"etudid": etudid} + ) + etud["ins"] = ins + sems = [] + cursem = None # semestre "courant" ou il est inscrit + for i in ins: + sem = sco_formsemestre.get_formsemestre(i["formsemestre_id"]) + if sco_formsemestre.sem_est_courant(sem): + cursem = sem + curi = i + sem["ins"] = i + sems.append(sem) + # trie les semestres par date de debut, le plus recent d'abord + # (important, ne pas changer (suivi cohortes)) + sems.sort(key=itemgetter("dateord"), reverse=True) + etud["sems"] = sems + etud["cursem"] = cursem + if cursem: + etud["inscription"] = cursem["titremois"] + etud["inscriptionstr"] = "Inscrit en " + cursem["titremois"] + etud["inscription_formsemestre_id"] = cursem["formsemestre_id"] + etud["etatincursem"] = curi["etat"] + etud["situation"] = descr_situation_etud(etudid, ne) + else: + if etud["sems"]: + if etud["sems"][0]["dateord"] > time.strftime("%Y-%m-%d", time.localtime()): + etud["inscription"] = "futur" + etud["situation"] = "futur élève" + else: + etud["inscription"] = "ancien" + etud["situation"] = "ancien élève" + else: + etud["inscription"] = "non inscrit" + etud["situation"] = etud["inscription"] + etud["inscriptionstr"] = etud["inscription"] + etud["inscription_formsemestre_id"] = None + etud["etatincursem"] = "?" + return etud + + +def descr_situation_etud(etudid: int, ne="") -> str: + """Chaîne décrivant la situation actuelle de l'étudiant + XXX Obsolete, utiliser Identite.descr_situation_etud() dans + les nouveaux codes + """ + from app.scodoc import sco_formsemestre + + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + """SELECT I.formsemestre_id, I.etat + FROM notes_formsemestre_inscription I, notes_formsemestre S + WHERE etudid=%(etudid)s + and S.id = I.formsemestre_id + and date_debut < now() + and date_fin > now() + ORDER BY S.date_debut DESC;""", + {"etudid": etudid}, + ) + r = cursor.dictfetchone() + if not r: + situation = "non inscrit" + ne + else: + sem = sco_formsemestre.get_formsemestre(r["formsemestre_id"]) + if r["etat"] == scu.INSCRIT: + situation = "inscrit%s en %s" % (ne, sem["titremois"]) + # Cherche la date d'inscription dans scolar_events: + events = scolar_events_list( + cnx, + args={ + "etudid": etudid, + "formsemestre_id": sem["formsemestre_id"], + "event_type": "INSCRIPTION", + }, + ) + if not events: + log( + "*** situation inconsistante pour %s (inscrit mais pas d'event)" + % etudid + ) + date_ins = "???" # ??? + else: + date_ins = events[0]["event_date"] + situation += " le " + str(date_ins) + else: + situation = "démission de %s" % sem["titremois"] + # Cherche la date de demission dans scolar_events: + events = scolar_events_list( + cnx, + args={ + "etudid": etudid, + "formsemestre_id": sem["formsemestre_id"], + "event_type": "DEMISSION", + }, + ) + if not events: + log( + "*** situation inconsistante pour %s (demission mais pas d'event)" + % etudid + ) + date_dem = "???" # ??? + else: + date_dem = events[0]["event_date"] + situation += " le " + str(date_dem) + return situation diff --git a/app/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py index baec914d..c33b7309 100644 --- a/app/scodoc/sco_formsemestre_exterieurs.py +++ b/app/scodoc/sco_formsemestre_exterieurs.py @@ -117,7 +117,7 @@ def formsemestre_ext_create_form(etudid, formsemestre_id): # Les autres situations (eg redoublements en changeant d'établissement) # doivent être gérées par les validations de semestres "antérieurs" insem = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - args={"etudid": etudid, "etat": "I"} + args={"etudid": etudid, "etat": scu.INSCRIT} ) semlist = [sco_formsemestre.get_formsemestre(i["formsemestre_id"]) for i in insem] existing_semestre_ids = {s["semestre_id"] for s in semlist} diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py index 90f739ab..540282e9 100644 --- a/app/scodoc/sco_formsemestre_inscriptions.py +++ b/app/scodoc/sco_formsemestre_inscriptions.py @@ -1,888 +1,905 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Opérations d'inscriptions aux semestres et modules -""" -import time - -import flask -from flask import flash, url_for, g, request - -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import Formation, FormSemestre, FormSemestreInscription -from app.models.etudiants import Identite -from app.models.groups import GroupDescr -import app.scodoc.sco_utils as scu -from app import log -from app.scodoc.scolog import logdb -from app.scodoc.sco_exceptions import ScoException, ScoValueError -from app.scodoc.sco_codes_parcours import UE_STANDARD, UE_SPORT, UE_TYPE_NAME -import app.scodoc.notesdb as ndb -from app.scodoc.TrivialFormulator import TrivialFormulator -from app.scodoc import sco_find_etud -from app.scodoc import sco_formsemestre -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_groups -from app.scodoc import sco_etud -from app.scodoc import sco_cache -from app.scodoc import html_sco_header - - -# --- Gestion des inscriptions aux semestres -_formsemestre_inscriptionEditor = ndb.EditableTable( - "notes_formsemestre_inscription", - "formsemestre_inscription_id", - ("formsemestre_inscription_id", "etudid", "formsemestre_id", "etat", "etape"), - sortkey="formsemestre_id", - insert_ignore_conflicts=True, -) - - -def do_formsemestre_inscription_list(*args, **kw): - "list formsemestre_inscriptions" - cnx = ndb.GetDBConnexion() - return _formsemestre_inscriptionEditor.list(cnx, *args, **kw) - - -def do_formsemestre_inscription_listinscrits(formsemestre_id): - """Liste les inscrits (état I) à ce semestre et cache le résultat. - Result: [ { "etudid":, "formsemestre_id": , "etat": , "etape": }] - """ - r = sco_cache.SemInscriptionsCache.get(formsemestre_id) - if r is None: - # retreive list - r = do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id, "etat": "I"} - ) - sco_cache.SemInscriptionsCache.set(formsemestre_id, r) - return r - - -def do_formsemestre_inscription_create(args, method=None): - "create a formsemestre_inscription (and sco event)" - cnx = ndb.GetDBConnexion() - log(f"do_formsemestre_inscription_create: args={args}") - sems = sco_formsemestre.do_formsemestre_list( - {"formsemestre_id": args["formsemestre_id"]} - ) - if len(sems) != 1: - raise ScoValueError(f"code de semestre invalide: {args['formsemestre_id']}") - sem = sems[0] - # check lock - if not sem["etat"]: - raise ScoValueError("inscription: semestre verrouille") - # - r = _formsemestre_inscriptionEditor.create(cnx, args) - # Evenement - sco_etud.scolar_events_create( - cnx, - args={ - "etudid": args["etudid"], - "event_date": time.strftime("%d/%m/%Y"), - "formsemestre_id": args["formsemestre_id"], - "event_type": "INSCRIPTION", - }, - ) - # Log etudiant - logdb( - cnx, - method=method, - etudid=args["etudid"], - msg=f"inscription en semestre {args['formsemestre_id']}", - commit=False, - ) - # - sco_cache.invalidate_formsemestre(formsemestre_id=args["formsemestre_id"]) - return r - - -def do_formsemestre_inscription_delete(oid, formsemestre_id=None): - "delete formsemestre_inscription" - cnx = ndb.GetDBConnexion() - _formsemestre_inscriptionEditor.delete(cnx, oid) - - sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) - - -def do_formsemestre_demission( - etudid, - formsemestre_id, - event_date=None, - etat_new="D", # 'D' or DEF - operation_method="demEtudiant", - event_type="DEMISSION", -): - "Démission ou défaillance d'un étudiant" - # marque 'D' ou DEF dans l'inscription au semestre et ajoute - # un "evenement" scolarite - cnx = ndb.GetDBConnexion() - # check lock - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - if not sem["etat"]: - raise ScoValueError("Modification impossible: semestre verrouille") - # - ins = do_formsemestre_inscription_list( - {"etudid": etudid, "formsemestre_id": formsemestre_id} - )[0] - if not ins: - raise ScoException("etudiant non inscrit ?!") - ins["etat"] = etat_new - do_formsemestre_inscription_edit(args=ins, formsemestre_id=formsemestre_id) - logdb(cnx, method=operation_method, etudid=etudid) - sco_etud.scolar_events_create( - cnx, - args={ - "etudid": etudid, - "event_date": event_date, - "formsemestre_id": formsemestre_id, - "event_type": event_type, - }, - ) - - -def do_formsemestre_inscription_edit(args=None, formsemestre_id=None): - "edit a formsemestre_inscription" - cnx = ndb.GetDBConnexion() - _formsemestre_inscriptionEditor.edit(cnx, args) - sco_cache.invalidate_formsemestre( - formsemestre_id=formsemestre_id - ) # > modif inscription semestre (demission ?) - - -def do_formsemestre_desinscription(etudid, formsemestre_id): - """Désinscription d'un étudiant. - Si semestre extérieur et dernier inscrit, suppression de ce semestre. - """ - from app.scodoc import sco_formsemestre_edit - - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - etud = Identite.query.get_or_404(etudid) - # -- check lock - if not formsemestre.etat: - raise ScoValueError("désinscription impossible: semestre verrouille") - - # -- Si decisions de jury, désinscription interdite - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - if nt.etud_has_decision(etudid): - raise ScoValueError( - """désinscription impossible: l'étudiant {etud.nomprenom} a - une décision de jury (la supprimer avant si nécessaire)""" - ) - - insem = do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id, "etudid": etudid} - ) - if not insem: - raise ScoValueError(f"{etud.nomprenom} n'est pas inscrit au semestre !") - insem = insem[0] - # -- desinscription de tous les modules - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - """SELECT Im.id AS moduleimpl_inscription_id - FROM notes_moduleimpl_inscription Im, notes_moduleimpl M - WHERE Im.etudid=%(etudid)s - and Im.moduleimpl_id = M.id - and M.formsemestre_id = %(formsemestre_id)s - """, - {"etudid": etudid, "formsemestre_id": formsemestre_id}, - ) - res = cursor.fetchall() - moduleimpl_inscription_ids = [x[0] for x in res] - for moduleimpl_inscription_id in moduleimpl_inscription_ids: - sco_moduleimpl.do_moduleimpl_inscription_delete( - moduleimpl_inscription_id, formsemestre_id=formsemestre_id - ) - # -- désincription du semestre - do_formsemestre_inscription_delete( - insem["formsemestre_inscription_id"], formsemestre_id=formsemestre_id - ) - # --- Semestre extérieur - if formsemestre.modalite == "EXT": - inscrits = do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id} - ) - nbinscrits = len(inscrits) - if nbinscrits == 0: - log( - f"""do_formsemestre_desinscription: - suppression du semestre extérieur {formsemestre}""" - ) - flash("Semestre exterieur supprimé") - sco_formsemestre_edit.do_formsemestre_delete(formsemestre_id) - - logdb( - cnx, - method="formsemestre_desinscription", - etudid=etudid, - msg="desinscription semestre %s" % formsemestre_id, - commit=False, - ) - - -def do_formsemestre_inscription_with_modules( - formsemestre_id, - etudid, - group_ids=[], - etat="I", - etape=None, - method="inscription_with_modules", -): - """Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS - (donc sauf le sport) - """ - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - # inscription au semestre - args = {"formsemestre_id": formsemestre_id, "etudid": etudid} - if etat is not None: - args["etat"] = etat - do_formsemestre_inscription_create(args, method=method) - log( - f"do_formsemestre_inscription_with_modules: etudid={etudid} formsemestre_id={formsemestre_id}" - ) - # inscriptions aux groupes - # 1- inscrit au groupe 'tous' - group_id = sco_groups.get_default_group(formsemestre_id) - sco_groups.set_group(etudid, group_id) - gdone = {group_id: 1} # empeche doublons - - # 2- inscrit aux groupes - for group_id in group_ids: - if group_id and not group_id in gdone: - group = GroupDescr.query.get_or_404(group_id) - sco_groups.set_group(etudid, group_id) - gdone[group_id] = 1 - - # Inscription à tous les modules de ce semestre - modimpls = sco_moduleimpl.moduleimpl_withmodule_list( - formsemestre_id=formsemestre_id - ) - for mod in modimpls: - if mod["ue"]["type"] != UE_SPORT: - sco_moduleimpl.do_moduleimpl_inscription_create( - {"moduleimpl_id": mod["moduleimpl_id"], "etudid": etudid}, - formsemestre_id=formsemestre_id, - ) - # Mise à jour des inscriptions aux parcours: - formsemestre.update_inscriptions_parcours_from_groups() - - -def formsemestre_inscription_with_modules_etud( - formsemestre_id, etudid=None, group_ids=None -): - """Form. inscription d'un étudiant au semestre. - Si etudid n'est pas specifié, form. choix etudiant. - """ - if etudid is None: - return sco_find_etud.form_search_etud( - title="Choix de l'étudiant à inscrire dans ce semestre", - add_headers=True, - dest_url="notes.formsemestre_inscription_with_modules_etud", - parameters={"formsemestre_id": formsemestre_id}, - parameters_keys="formsemestre_id", - ) - - return formsemestre_inscription_with_modules( - etudid, formsemestre_id, group_ids=group_ids - ) - - -def formsemestre_inscription_with_modules_form(etudid, only_ext=False): - """Formulaire inscription de l'etud dans l'un des semestres existants. - Si only_ext, ne montre que les semestre extérieurs. - """ - etud: Identite = Identite.query.filter_by( - id=etudid, dept_id=g.scodoc_dept_id - ).first_or_404() - H = [ - html_sco_header.sco_header(), - f"

Inscription de {etud.nomprenom}", - ] - if only_ext: - H.append(" dans un semestre extérieur") - H.append( - """

-

L'étudiant sera inscrit à tous les modules du semestre - choisi (sauf Sport & Culture). -

-

Choisir un semestre:

""" - ) - footer = html_sco_header.sco_footer() - # sems = sco_formsemestre.do_formsemestre_list(args={"etat": "1"}) - formsemestres = ( - FormSemestre.query.filter_by(etat=True, dept_id=g.scodoc_dept_id) - .join(Formation) - .order_by( - Formation.acronyme, - FormSemestre.semestre_id, - FormSemestre.modalite, - FormSemestre.date_debut, - ) - .all() - ) - if len(formsemestres): - H.append("
    ") - for formsemestre in formsemestres: - # Ne propose que les semestres où etudid n'est pas déjà inscrit - if formsemestre.id not in { - ins.formsemestre_id for ins in etud.inscriptions() - }: - if (not only_ext) or (formsemestre.modalite == "EXT"): - H.append( - f""" -
  • - {formsemestre.titre_mois()} -
  • - """ - ) - H.append("
") - else: - H.append("

aucune session de formation !

") - H.append( - f"""

ou

retour à la fiche de {etud.nomprenom}""" - ) - return "\n".join(H) + footer - - -def formsemestre_inscription_with_modules( - etudid, formsemestre_id, group_ids=None, multiple_ok=False -): - """ - Inscription de l'etud dans ce semestre. - Formulaire avec choix groupe. - """ - log( - f"""formsemestre_inscription_with_modules: etudid={etudid} formsemestre_id={ - formsemestre_id} group_ids={group_ids}""" - ) - if multiple_ok: - multiple_ok = int(multiple_ok) - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - etud: Identite = Identite.query.get_or_404(etudid) - if etud.dept_id != formsemestre.dept_id: - raise ScoValueError("l'étudiant n'est pas dans ce département") - H = [ - html_sco_header.html_sem_header( - f"Inscription de {etud.nomprenom} dans ce semestre", - ) - ] - footer = html_sco_header.sco_footer() - # Check 1: déjà inscrit ici ? - inscr = FormSemestreInscription.query.filter_by( - etudid=etud.id, formsemestre_id=formsemestre.id - ).first() - if inscr is not None: - H.append( - f""" -

{etud.nomprenom} est déjà inscrit - dans le semestre {formsemestre.titre_mois()} -

- - """ - ) - return "\n".join(H) + footer - # Check 2: déjà inscrit dans un semestre recouvrant les même dates ? - # Informe et propose dé-inscriptions - others = est_inscrit_ailleurs(etudid, formsemestre_id) - if others and not multiple_ok: - l = [] - for s in others: - l.append( - f"""{s['titremois']}""" - ) - - H.append( - f"""

Attention: {etud.nomprenom} est déjà inscrit sur - la même période dans: {", ".join(l)}. -

""" - ) - H.append("") - H.append( - f"""

Continuer quand même l'inscription -

""" - # was sco_groups.make_query_groups(group_ids) - ) - return "\n".join(H) + footer - # - if group_ids is not None: - # OK, inscription - do_formsemestre_inscription_with_modules( - formsemestre_id, - etudid, - group_ids=group_ids, - etat="I", - method="formsemestre_inscription_with_modules", - ) - return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - else: - # formulaire choix groupe - H.append( - f"""
- - - """ - ) - - H.append(sco_groups.form_group_choice(formsemestre_id, allow_none=True)) - - # - H.append( - """ - -

Note: l'étudiant sera inscrit dans les groupes sélectionnés

-
- """ - ) - return "\n".join(H) + footer - - -def formsemestre_inscription_option(etudid, formsemestre_id): - """Dialogue pour (dés)inscription à des modules optionnels.""" - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - if not sem["etat"]: - raise ScoValueError("Modification impossible: semestre verrouille") - - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - footer = html_sco_header.sco_footer() - H = [ - html_sco_header.sco_header() - + "

Inscription de %s aux modules de %s (%s - %s)

" - % (etud["nomprenom"], sem["titre_num"], sem["date_debut"], sem["date_fin"]) - ] - - # Cherche les moduleimpls et les inscriptions - mods = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) - inscr = sco_moduleimpl.do_moduleimpl_inscription_list(etudid=etudid) - # Formulaire - modimpls_by_ue_ids = scu.DictDefault(defaultvalue=[]) # ue_id : [ moduleimpl_id ] - modimpls_by_ue_names = scu.DictDefault( - defaultvalue=[] - ) # ue_id : [ moduleimpl_name ] - ues = [] - ue_ids = set() - initvalues = {} - for mod in mods: - ue_id = mod["ue"]["ue_id"] - if not ue_id in ue_ids: - ues.append(mod["ue"]) - ue_ids.add(ue_id) - modimpls_by_ue_ids[ue_id].append(mod["moduleimpl_id"]) - - modimpls_by_ue_names[ue_id].append( - "%s %s" % (mod["module"]["code"] or "", mod["module"]["titre"] or "") - ) - vals = scu.get_request_args() - if not vals.get("tf_submitted", False): - # inscrit ? - for ins in inscr: - if ins["moduleimpl_id"] == mod["moduleimpl_id"]: - key = "moduleimpls_%s" % ue_id - if key in initvalues: - initvalues[key].append(str(mod["moduleimpl_id"])) - else: - initvalues[key] = [str(mod["moduleimpl_id"])] - break - - descr = [ - ("formsemestre_id", {"input_type": "hidden"}), - ("etudid", {"input_type": "hidden"}), - ] - for ue in ues: - ue_id = ue["ue_id"] - ue_descr = ue["acronyme"] - if ue["type"] != UE_STANDARD: - ue_descr += " %s" % UE_TYPE_NAME[ue["type"]] - ue_status = nt.get_etud_ue_status(etudid, ue_id) - if ue_status and ue_status["is_capitalized"]: - sem_origin = sco_formsemestre.get_formsemestre(ue_status["formsemestre_id"]) - ue_descr += ( - ' (capitalisée le %s)' - % ( - sem_origin["formsemestre_id"], - etudid, - sem_origin["titreannee"], - ndb.DateISOtoDMY(ue_status["event_date"]), - ) - ) - descr.append( - ( - "sec_%s" % ue_id, - { - "input_type": "separator", - "title": """%s : inscrire | désinscrire à tous les modules""" - % (ue_descr, ue_id, ue_id), - }, - ) - ) - descr.append( - ( - "moduleimpls_%s" % ue_id, - { - "input_type": "checkbox", - "title": "", - "dom_id": ue_id, - "allowed_values": [str(x) for x in modimpls_by_ue_ids[ue_id]], - "labels": modimpls_by_ue_names[ue_id], - "vertical": True, - }, - ) - ) - - H.append( - """ - """ - ) - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - descr, - initvalues, - cancelbutton="Annuler", - method="post", - submitlabel="Modifier les inscriptions", - cssclass="inscription", - name="tf", - ) - if tf[0] == 0: - H.append( - """ -

Voici la liste des modules du semestre choisi.

-

- Les modules cochés sont ceux dans lesquels l'étudiant est inscrit. - Vous pouvez l'inscrire ou le désincrire d'un ou plusieurs modules. -

-

Attention: cette méthode ne devrait être utilisée que pour les modules - optionnels (ou les activités culturelles et sportives) et pour désinscrire - les étudiants dispensés (UE validées). -

- """ - ) - return "\n".join(H) + "\n" + tf[1] + footer - elif tf[0] == -1: - return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - else: - # Inscriptions aux modules choisis - # il faut desinscrire des modules qui ne figurent pas - # et inscrire aux autres, sauf si deja inscrit - a_desinscrire = {}.fromkeys([x["moduleimpl_id"] for x in mods]) - insdict = {} - for ins in inscr: - insdict[ins["moduleimpl_id"]] = ins - for ue in ues: - ue_id = ue["ue_id"] - for moduleimpl_id in [int(x) for x in tf[2]["moduleimpls_%s" % ue_id]]: - if moduleimpl_id in a_desinscrire: - del a_desinscrire[moduleimpl_id] - # supprime ceux auxquel pas inscrit - moduleimpls_a_desinscrire = list(a_desinscrire.keys()) - for moduleimpl_id in moduleimpls_a_desinscrire: - if moduleimpl_id not in insdict: - del a_desinscrire[moduleimpl_id] - - a_inscrire = set() - for ue in ues: - ue_id = ue["ue_id"] - a_inscrire.update( - int(x) for x in tf[2]["moduleimpls_%s" % ue_id] - ) # conversion en int ! - # supprime ceux auquel deja inscrit: - for ins in inscr: - if ins["moduleimpl_id"] in a_inscrire: - a_inscrire.remove(ins["moduleimpl_id"]) - # dict des modules: - modsdict = {} - for mod in mods: - modsdict[mod["moduleimpl_id"]] = mod - # - if (not a_inscrire) and (not a_desinscrire): - H.append( - """

Aucune modification à effectuer

-

retour à la fiche étudiant

- """ - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - return "\n".join(H) + footer - - H.append("

Confirmer les modifications:

") - if a_desinscrire: - H.append( - "

%s va être désinscrit%s des modules:

  • " - % (etud["nomprenom"], etud["ne"]) - ) - H.append( - "
  • ".join( - [ - "%s (%s)" - % ( - modsdict[x]["module"]["titre"], - modsdict[x]["module"]["code"] or "(module sans code)", - ) - for x in a_desinscrire - ] - ) - + "

    " - ) - H.append("
") - if a_inscrire: - H.append( - "

%s va être inscrit%s aux modules:

  • " - % (etud["nomprenom"], etud["ne"]) - ) - H.append( - "
  • ".join( - [ - "%s (%s)" - % ( - modsdict[x]["module"]["titre"], - modsdict[x]["module"]["code"] or "(module sans code)", - ) - for x in a_inscrire - ] - ) - + "

    " - ) - H.append("
") - modulesimpls_ainscrire = ",".join(str(x) for x in a_inscrire) - modulesimpls_adesinscrire = ",".join(str(x) for x in a_desinscrire) - H.append( - """
- - - - - -
- """ - % ( - etudid, - modulesimpls_ainscrire, - modulesimpls_adesinscrire, - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - ) - ) - return "\n".join(H) + footer - - -def do_moduleimpl_incription_options( - etudid, modulesimpls_ainscrire, modulesimpls_adesinscrire -): - """ - Effectue l'inscription et la description aux modules optionnels - """ - if isinstance(modulesimpls_ainscrire, int): - modulesimpls_ainscrire = str(modulesimpls_ainscrire) - if isinstance(modulesimpls_adesinscrire, int): - modulesimpls_adesinscrire = str(modulesimpls_adesinscrire) - if modulesimpls_ainscrire: - a_inscrire = [int(x) for x in modulesimpls_ainscrire.split(",")] - else: - a_inscrire = [] - if modulesimpls_adesinscrire: - a_desinscrire = [int(x) for x in modulesimpls_adesinscrire.split(",")] - else: - a_desinscrire = [] - # inscriptions - for moduleimpl_id in a_inscrire: - # verifie que ce module existe bien - mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id) - if len(mods) != 1: - raise ScoValueError( - "inscription: invalid moduleimpl_id: %s" % moduleimpl_id - ) - mod = mods[0] - sco_moduleimpl.do_moduleimpl_inscription_create( - {"moduleimpl_id": moduleimpl_id, "etudid": etudid}, - formsemestre_id=mod["formsemestre_id"], - ) - # desinscriptions - for moduleimpl_id in a_desinscrire: - # verifie que ce module existe bien - mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id) - if len(mods) != 1: - raise ScoValueError( - "desinscription: invalid moduleimpl_id: %s" % moduleimpl_id - ) - mod = mods[0] - inscr = sco_moduleimpl.do_moduleimpl_inscription_list( - moduleimpl_id=moduleimpl_id, etudid=etudid - ) - if not inscr: - raise ScoValueError( - "pas inscrit a ce module ! (etudid=%s, moduleimpl_id=%s)" - % (etudid, moduleimpl_id) - ) - oid = inscr[0]["moduleimpl_inscription_id"] - sco_moduleimpl.do_moduleimpl_inscription_delete( - oid, formsemestre_id=mod["formsemestre_id"] - ) - - H = [ - html_sco_header.sco_header(), - """

Modifications effectuées

-

- Retour à la fiche étudiant

- """ - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def est_inscrit_ailleurs(etudid, formsemestre_id): - """Vrai si l'étudiant est inscrit dans un semestre en même - temps que celui indiqué (par formsemestre_id). - Retourne la liste des semestres concernés (ou liste vide). - """ - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - debut_s = sem["dateord"] - fin_s = ndb.DateDMYtoISO(sem["date_fin"]) - r = [] - for s in etud["sems"]: - if s["formsemestre_id"] != formsemestre_id: - debut = s["dateord"] - fin = ndb.DateDMYtoISO(s["date_fin"]) - if debut < fin_s and fin > debut_s: - r.append(s) # intersection - return r - - -def list_inscrits_ailleurs(formsemestre_id): - """Liste des etudiants inscrits ailleurs en même temps que formsemestre_id. - Pour chacun, donne la liste des semestres. - { etudid : [ liste de sems ] } - """ - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - etudids = nt.get_etudids() - d = {} - for etudid in etudids: - d[etudid] = est_inscrit_ailleurs(etudid, formsemestre_id) - return d - - -def formsemestre_inscrits_ailleurs(formsemestre_id): - """Page listant les étudiants inscrits dans un autre semestre - dont les dates recouvrent le semestre indiqué. - """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - H = [ - html_sco_header.html_sem_header( - "Inscriptions multiples parmi les étudiants du semestre ", - ) - ] - insd = list_inscrits_ailleurs(formsemestre_id) - # liste ordonnée par nom - etudlist = [ - sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - for etudid in insd.keys() - if insd[etudid] - ] - etudlist.sort(key=lambda x: x["nom"]) - if etudlist: - H.append("
    ") - for etud in etudlist: - H.append( - '
  • %s : ' - % ( - url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=etud["etudid"], - ), - etud["nomprenom"], - ) - ) - l = [] - for s in insd[etud["etudid"]]: - l.append( - '%(titremois)s' - % s - ) - H.append(", ".join(l)) - H.append("
  • ") - H.append("
") - H.append("

Total: %d étudiants concernés.

" % len(etudlist)) - H.append( - """

Ces étudiants sont inscrits dans le semestre sélectionné et aussi dans d'autres semestres qui se déroulent en même temps !
Sauf exception, cette situation est anormale:

-
    -
  • vérifier que les dates des semestres se suivent sans se chevaucher
  • -
  • ou si besoin désinscrire le(s) étudiant(s) de l'un des semestres (via leurs fiches individuelles).
  • -
- """ - ) - else: - H.append("""

Aucun étudiant en inscription multiple (c'est normal) !

""") - return "\n".join(H) + html_sco_header.sco_footer() +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Opérations d'inscriptions aux semestres et modules +""" +import time + +import flask +from flask import flash, url_for, g, request + +from app import db +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import Formation, FormSemestre, FormSemestreInscription, Scolog +from app.models.etudiants import Identite +from app.models.groups import GroupDescr +from app.models.validations import ScolarEvent +import app.scodoc.sco_utils as scu +from app import log +from app.scodoc.scolog import logdb +from app.scodoc.sco_exceptions import ScoException, ScoValueError +from app.scodoc.sco_codes_parcours import UE_STANDARD, UE_SPORT, UE_TYPE_NAME +import app.scodoc.notesdb as ndb +from app.scodoc.TrivialFormulator import TrivialFormulator +from app.scodoc import sco_find_etud +from app.scodoc import sco_formsemestre +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_groups +from app.scodoc import sco_etud +from app.scodoc import sco_cache +from app.scodoc import html_sco_header + + +# --- Gestion des inscriptions aux semestres +_formsemestre_inscriptionEditor = ndb.EditableTable( + "notes_formsemestre_inscription", + "formsemestre_inscription_id", + ("formsemestre_inscription_id", "etudid", "formsemestre_id", "etat", "etape"), + sortkey="formsemestre_id", + insert_ignore_conflicts=True, +) + + +def do_formsemestre_inscription_list(*args, **kw): + "list formsemestre_inscriptions" + cnx = ndb.GetDBConnexion() + return _formsemestre_inscriptionEditor.list(cnx, *args, **kw) + + +def do_formsemestre_inscription_listinscrits(formsemestre_id): + """Liste les inscrits (état I) à ce semestre et cache le résultat. + Result: [ { "etudid":, "formsemestre_id": , "etat": , "etape": }] + """ + r = sco_cache.SemInscriptionsCache.get(formsemestre_id) + if r is None: + # retreive list + r = do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id, "etat": scu.INSCRIT} + ) + sco_cache.SemInscriptionsCache.set(formsemestre_id, r) + return r + + +def do_formsemestre_inscription_create(args, method=None): + "create a formsemestre_inscription (and sco event)" + cnx = ndb.GetDBConnexion() + log(f"do_formsemestre_inscription_create: args={args}") + sems = sco_formsemestre.do_formsemestre_list( + {"formsemestre_id": args["formsemestre_id"]} + ) + if len(sems) != 1: + raise ScoValueError(f"code de semestre invalide: {args['formsemestre_id']}") + sem = sems[0] + # check lock + if not sem["etat"]: + raise ScoValueError("inscription: semestre verrouille") + # + r = _formsemestre_inscriptionEditor.create(cnx, args) + # Evenement + sco_etud.scolar_events_create( + cnx, + args={ + "etudid": args["etudid"], + "event_date": time.strftime("%d/%m/%Y"), + "formsemestre_id": args["formsemestre_id"], + "event_type": "INSCRIPTION", + }, + ) + # Log etudiant + logdb( + cnx, + method=method, + etudid=args["etudid"], + msg=f"inscription en semestre {args['formsemestre_id']}", + commit=False, + ) + # + sco_cache.invalidate_formsemestre(formsemestre_id=args["formsemestre_id"]) + return r + + +def do_formsemestre_inscription_delete(oid, formsemestre_id=None): + "delete formsemestre_inscription" + cnx = ndb.GetDBConnexion() + _formsemestre_inscriptionEditor.delete(cnx, oid) + + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) + + +def do_formsemestre_demission( + etudid, + formsemestre_id, + event_date=None, + etat_new=scu.DEMISSION, # DEMISSION or DEF + operation_method="dem_etudiant", + event_type="DEMISSION", +): + "Démission ou défaillance d'un étudiant" + # marque 'D' ou DEF dans l'inscription au semestre et ajoute + # un "evenement" scolarite + if etat_new not in (scu.DEF, scu.DEMISSION): + raise ScoValueError("nouveau code d'état invalide") + try: + event_date_iso = ndb.DateDMYtoISO(event_date) + except ValueError as exc: + raise ScoValueError("format de date invalide") from exc + etud: Identite = Identite.query.filter_by( + id=etudid, dept_id=g.scodoc_dept_id + ).first_or_404() + # check lock + formsemestre: FormSemestre = FormSemestre.query.filter_by( + id=formsemestre_id, dept_id=g.scodoc_dept_id + ).first_or_404() + if not formsemestre.etat: + raise ScoValueError("Modification impossible: semestre verrouille") + # + if formsemestre_id not in (inscr.formsemestre_id for inscr in etud.inscriptions()): + raise ScoValueError("étudiant non inscrit dans ce semestre !") + inscr = next( + inscr + for inscr in etud.inscriptions() + if inscr.formsemestre_id == formsemestre_id + ) + inscr.etat = etat_new + db.session.add(inscr) + Scolog.logdb(method=operation_method, etudid=etudid) + event = ScolarEvent( + etudid=etudid, + event_date=event_date_iso, + formsemestre_id=formsemestre_id, + event_type=event_type, + ) + db.session.add(event) + db.session.commit() + if etat_new == scu.DEMISSION: + flash("Démission enregistrée") + elif etat_new == scu.DEF: + flash("Défaillance enregistrée") + + +def do_formsemestre_inscription_edit(args=None, formsemestre_id=None): + "edit a formsemestre_inscription" + cnx = ndb.GetDBConnexion() + _formsemestre_inscriptionEditor.edit(cnx, args) + sco_cache.invalidate_formsemestre( + formsemestre_id=formsemestre_id + ) # > modif inscription semestre (demission ?) + + +def do_formsemestre_desinscription(etudid, formsemestre_id): + """Désinscription d'un étudiant. + Si semestre extérieur et dernier inscrit, suppression de ce semestre. + """ + from app.scodoc import sco_formsemestre_edit + + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + etud = Identite.query.get_or_404(etudid) + # -- check lock + if not formsemestre.etat: + raise ScoValueError("désinscription impossible: semestre verrouille") + + # -- Si decisions de jury, désinscription interdite + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + if nt.etud_has_decision(etudid): + raise ScoValueError( + """désinscription impossible: l'étudiant {etud.nomprenom} a + une décision de jury (la supprimer avant si nécessaire)""" + ) + + insem = do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id, "etudid": etudid} + ) + if not insem: + raise ScoValueError(f"{etud.nomprenom} n'est pas inscrit au semestre !") + insem = insem[0] + # -- desinscription de tous les modules + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + """SELECT Im.id AS moduleimpl_inscription_id + FROM notes_moduleimpl_inscription Im, notes_moduleimpl M + WHERE Im.etudid=%(etudid)s + and Im.moduleimpl_id = M.id + and M.formsemestre_id = %(formsemestre_id)s + """, + {"etudid": etudid, "formsemestre_id": formsemestre_id}, + ) + res = cursor.fetchall() + moduleimpl_inscription_ids = [x[0] for x in res] + for moduleimpl_inscription_id in moduleimpl_inscription_ids: + sco_moduleimpl.do_moduleimpl_inscription_delete( + moduleimpl_inscription_id, formsemestre_id=formsemestre_id + ) + # -- désincription du semestre + do_formsemestre_inscription_delete( + insem["formsemestre_inscription_id"], formsemestre_id=formsemestre_id + ) + # --- Semestre extérieur + if formsemestre.modalite == "EXT": + inscrits = do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id} + ) + nbinscrits = len(inscrits) + if nbinscrits == 0: + log( + f"""do_formsemestre_desinscription: + suppression du semestre extérieur {formsemestre}""" + ) + flash("Semestre exterieur supprimé") + sco_formsemestre_edit.do_formsemestre_delete(formsemestre_id) + + logdb( + cnx, + method="formsemestre_desinscription", + etudid=etudid, + msg="desinscription semestre %s" % formsemestre_id, + commit=False, + ) + + +def do_formsemestre_inscription_with_modules( + formsemestre_id, + etudid, + group_ids=[], + etat=scu.INSCRIT, + etape=None, + method="inscription_with_modules", +): + """Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS + (donc sauf le sport) + """ + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + # inscription au semestre + args = {"formsemestre_id": formsemestre_id, "etudid": etudid} + if etat is not None: + args["etat"] = etat + do_formsemestre_inscription_create(args, method=method) + log( + f"do_formsemestre_inscription_with_modules: etudid={etudid} formsemestre_id={formsemestre_id}" + ) + # inscriptions aux groupes + # 1- inscrit au groupe 'tous' + group_id = sco_groups.get_default_group(formsemestre_id) + sco_groups.set_group(etudid, group_id) + gdone = {group_id: 1} # empeche doublons + + # 2- inscrit aux groupes + for group_id in group_ids: + if group_id and not group_id in gdone: + group = GroupDescr.query.get_or_404(group_id) + sco_groups.set_group(etudid, group_id) + gdone[group_id] = 1 + + # Inscription à tous les modules de ce semestre + modimpls = sco_moduleimpl.moduleimpl_withmodule_list( + formsemestre_id=formsemestre_id + ) + for mod in modimpls: + if mod["ue"]["type"] != UE_SPORT: + sco_moduleimpl.do_moduleimpl_inscription_create( + {"moduleimpl_id": mod["moduleimpl_id"], "etudid": etudid}, + formsemestre_id=formsemestre_id, + ) + # Mise à jour des inscriptions aux parcours: + formsemestre.update_inscriptions_parcours_from_groups() + + +def formsemestre_inscription_with_modules_etud( + formsemestre_id, etudid=None, group_ids=None +): + """Form. inscription d'un étudiant au semestre. + Si etudid n'est pas specifié, form. choix etudiant. + """ + if etudid is None: + return sco_find_etud.form_search_etud( + title="Choix de l'étudiant à inscrire dans ce semestre", + add_headers=True, + dest_url="notes.formsemestre_inscription_with_modules_etud", + parameters={"formsemestre_id": formsemestre_id}, + parameters_keys="formsemestre_id", + ) + + return formsemestre_inscription_with_modules( + etudid, formsemestre_id, group_ids=group_ids + ) + + +def formsemestre_inscription_with_modules_form(etudid, only_ext=False): + """Formulaire inscription de l'etud dans l'un des semestres existants. + Si only_ext, ne montre que les semestre extérieurs. + """ + etud: Identite = Identite.query.filter_by( + id=etudid, dept_id=g.scodoc_dept_id + ).first_or_404() + H = [ + html_sco_header.sco_header(), + f"

Inscription de {etud.nomprenom}", + ] + if only_ext: + H.append(" dans un semestre extérieur") + H.append( + """

+

L'étudiant sera inscrit à tous les modules du semestre + choisi (sauf Sport & Culture). +

+

Choisir un semestre:

""" + ) + footer = html_sco_header.sco_footer() + # sems = sco_formsemestre.do_formsemestre_list(args={"etat": "1"}) + formsemestres = ( + FormSemestre.query.filter_by(etat=True, dept_id=g.scodoc_dept_id) + .join(Formation) + .order_by( + Formation.acronyme, + FormSemestre.semestre_id, + FormSemestre.modalite, + FormSemestre.date_debut, + ) + .all() + ) + if len(formsemestres): + H.append("
    ") + for formsemestre in formsemestres: + # Ne propose que les semestres où etudid n'est pas déjà inscrit + if formsemestre.id not in { + ins.formsemestre_id for ins in etud.inscriptions() + }: + if (not only_ext) or (formsemestre.modalite == "EXT"): + H.append( + f""" +
  • + {formsemestre.titre_mois()} +
  • + """ + ) + H.append("
") + else: + H.append("

aucune session de formation !

") + H.append( + f"""

ou

retour à la fiche de {etud.nomprenom}""" + ) + return "\n".join(H) + footer + + +def formsemestre_inscription_with_modules( + etudid, formsemestre_id, group_ids=None, multiple_ok=False +): + """ + Inscription de l'etud dans ce semestre. + Formulaire avec choix groupe. + """ + log( + f"""formsemestre_inscription_with_modules: etudid={etudid} formsemestre_id={ + formsemestre_id} group_ids={group_ids}""" + ) + if multiple_ok: + multiple_ok = int(multiple_ok) + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + etud: Identite = Identite.query.get_or_404(etudid) + if etud.dept_id != formsemestre.dept_id: + raise ScoValueError("l'étudiant n'est pas dans ce département") + H = [ + html_sco_header.html_sem_header( + f"Inscription de {etud.nomprenom} dans ce semestre", + ) + ] + footer = html_sco_header.sco_footer() + # Check 1: déjà inscrit ici ? + inscr = FormSemestreInscription.query.filter_by( + etudid=etud.id, formsemestre_id=formsemestre.id + ).first() + if inscr is not None: + H.append( + f""" +

{etud.nomprenom} est déjà inscrit + dans le semestre {formsemestre.titre_mois()} +

+ + """ + ) + return "\n".join(H) + footer + # Check 2: déjà inscrit dans un semestre recouvrant les même dates ? + # Informe et propose dé-inscriptions + others = est_inscrit_ailleurs(etudid, formsemestre_id) + if others and not multiple_ok: + l = [] + for s in others: + l.append( + f"""{s['titremois']}""" + ) + + H.append( + f"""

Attention: {etud.nomprenom} est déjà inscrit sur + la même période dans: {", ".join(l)}. +

""" + ) + H.append("") + H.append( + f"""

Continuer quand même l'inscription +

""" + # was sco_groups.make_query_groups(group_ids) + ) + return "\n".join(H) + footer + # + if group_ids is not None: + # OK, inscription + do_formsemestre_inscription_with_modules( + formsemestre_id, + etudid, + group_ids=group_ids, + etat=scu.INSCRIT, + method="formsemestre_inscription_with_modules", + ) + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + else: + # formulaire choix groupe + H.append( + f"""
+ + + """ + ) + + H.append(sco_groups.form_group_choice(formsemestre_id, allow_none=True)) + + # + H.append( + """ + +

Note: l'étudiant sera inscrit dans les groupes sélectionnés

+
+ """ + ) + return "\n".join(H) + footer + + +def formsemestre_inscription_option(etudid, formsemestre_id): + """Dialogue pour (dés)inscription à des modules optionnels.""" + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + if not sem["etat"]: + raise ScoValueError("Modification impossible: semestre verrouille") + + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + footer = html_sco_header.sco_footer() + H = [ + html_sco_header.sco_header() + + "

Inscription de %s aux modules de %s (%s - %s)

" + % (etud["nomprenom"], sem["titre_num"], sem["date_debut"], sem["date_fin"]) + ] + + # Cherche les moduleimpls et les inscriptions + mods = sco_moduleimpl.moduleimpl_withmodule_list(formsemestre_id=formsemestre_id) + inscr = sco_moduleimpl.do_moduleimpl_inscription_list(etudid=etudid) + # Formulaire + modimpls_by_ue_ids = scu.DictDefault(defaultvalue=[]) # ue_id : [ moduleimpl_id ] + modimpls_by_ue_names = scu.DictDefault( + defaultvalue=[] + ) # ue_id : [ moduleimpl_name ] + ues = [] + ue_ids = set() + initvalues = {} + for mod in mods: + ue_id = mod["ue"]["ue_id"] + if not ue_id in ue_ids: + ues.append(mod["ue"]) + ue_ids.add(ue_id) + modimpls_by_ue_ids[ue_id].append(mod["moduleimpl_id"]) + + modimpls_by_ue_names[ue_id].append( + "%s %s" % (mod["module"]["code"] or "", mod["module"]["titre"] or "") + ) + vals = scu.get_request_args() + if not vals.get("tf_submitted", False): + # inscrit ? + for ins in inscr: + if ins["moduleimpl_id"] == mod["moduleimpl_id"]: + key = "moduleimpls_%s" % ue_id + if key in initvalues: + initvalues[key].append(str(mod["moduleimpl_id"])) + else: + initvalues[key] = [str(mod["moduleimpl_id"])] + break + + descr = [ + ("formsemestre_id", {"input_type": "hidden"}), + ("etudid", {"input_type": "hidden"}), + ] + for ue in ues: + ue_id = ue["ue_id"] + ue_descr = ue["acronyme"] + if ue["type"] != UE_STANDARD: + ue_descr += " %s" % UE_TYPE_NAME[ue["type"]] + ue_status = nt.get_etud_ue_status(etudid, ue_id) + if ue_status and ue_status["is_capitalized"]: + sem_origin = sco_formsemestre.get_formsemestre(ue_status["formsemestre_id"]) + ue_descr += ( + ' (capitalisée le %s)' + % ( + sem_origin["formsemestre_id"], + etudid, + sem_origin["titreannee"], + ndb.DateISOtoDMY(ue_status["event_date"]), + ) + ) + descr.append( + ( + "sec_%s" % ue_id, + { + "input_type": "separator", + "title": """%s : inscrire | désinscrire à tous les modules""" + % (ue_descr, ue_id, ue_id), + }, + ) + ) + descr.append( + ( + "moduleimpls_%s" % ue_id, + { + "input_type": "checkbox", + "title": "", + "dom_id": ue_id, + "allowed_values": [str(x) for x in modimpls_by_ue_ids[ue_id]], + "labels": modimpls_by_ue_names[ue_id], + "vertical": True, + }, + ) + ) + + H.append( + """ + """ + ) + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + descr, + initvalues, + cancelbutton="Annuler", + method="post", + submitlabel="Modifier les inscriptions", + cssclass="inscription", + name="tf", + ) + if tf[0] == 0: + H.append( + """ +

Voici la liste des modules du semestre choisi.

+

+ Les modules cochés sont ceux dans lesquels l'étudiant est inscrit. + Vous pouvez l'inscrire ou le désincrire d'un ou plusieurs modules. +

+

Attention: cette méthode ne devrait être utilisée que pour les modules + optionnels (ou les activités culturelles et sportives) et pour désinscrire + les étudiants dispensés (UE validées). +

+ """ + ) + return "\n".join(H) + "\n" + tf[1] + footer + elif tf[0] == -1: + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + else: + # Inscriptions aux modules choisis + # il faut desinscrire des modules qui ne figurent pas + # et inscrire aux autres, sauf si deja inscrit + a_desinscrire = {}.fromkeys([x["moduleimpl_id"] for x in mods]) + insdict = {} + for ins in inscr: + insdict[ins["moduleimpl_id"]] = ins + for ue in ues: + ue_id = ue["ue_id"] + for moduleimpl_id in [int(x) for x in tf[2]["moduleimpls_%s" % ue_id]]: + if moduleimpl_id in a_desinscrire: + del a_desinscrire[moduleimpl_id] + # supprime ceux auxquel pas inscrit + moduleimpls_a_desinscrire = list(a_desinscrire.keys()) + for moduleimpl_id in moduleimpls_a_desinscrire: + if moduleimpl_id not in insdict: + del a_desinscrire[moduleimpl_id] + + a_inscrire = set() + for ue in ues: + ue_id = ue["ue_id"] + a_inscrire.update( + int(x) for x in tf[2]["moduleimpls_%s" % ue_id] + ) # conversion en int ! + # supprime ceux auquel deja inscrit: + for ins in inscr: + if ins["moduleimpl_id"] in a_inscrire: + a_inscrire.remove(ins["moduleimpl_id"]) + # dict des modules: + modsdict = {} + for mod in mods: + modsdict[mod["moduleimpl_id"]] = mod + # + if (not a_inscrire) and (not a_desinscrire): + H.append( + """

Aucune modification à effectuer

+

retour à la fiche étudiant

+ """ + % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + return "\n".join(H) + footer + + H.append("

Confirmer les modifications:

") + if a_desinscrire: + H.append( + "

%s va être désinscrit%s des modules:

  • " + % (etud["nomprenom"], etud["ne"]) + ) + H.append( + "
  • ".join( + [ + "%s (%s)" + % ( + modsdict[x]["module"]["titre"], + modsdict[x]["module"]["code"] or "(module sans code)", + ) + for x in a_desinscrire + ] + ) + + "

    " + ) + H.append("
") + if a_inscrire: + H.append( + "

%s va être inscrit%s aux modules:

  • " + % (etud["nomprenom"], etud["ne"]) + ) + H.append( + "
  • ".join( + [ + "%s (%s)" + % ( + modsdict[x]["module"]["titre"], + modsdict[x]["module"]["code"] or "(module sans code)", + ) + for x in a_inscrire + ] + ) + + "

    " + ) + H.append("
") + modulesimpls_ainscrire = ",".join(str(x) for x in a_inscrire) + modulesimpls_adesinscrire = ",".join(str(x) for x in a_desinscrire) + H.append( + """
+ + + + + +
+ """ + % ( + etudid, + modulesimpls_ainscrire, + modulesimpls_adesinscrire, + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + ) + ) + return "\n".join(H) + footer + + +def do_moduleimpl_incription_options( + etudid, modulesimpls_ainscrire, modulesimpls_adesinscrire +): + """ + Effectue l'inscription et la description aux modules optionnels + """ + if isinstance(modulesimpls_ainscrire, int): + modulesimpls_ainscrire = str(modulesimpls_ainscrire) + if isinstance(modulesimpls_adesinscrire, int): + modulesimpls_adesinscrire = str(modulesimpls_adesinscrire) + if modulesimpls_ainscrire: + a_inscrire = [int(x) for x in modulesimpls_ainscrire.split(",")] + else: + a_inscrire = [] + if modulesimpls_adesinscrire: + a_desinscrire = [int(x) for x in modulesimpls_adesinscrire.split(",")] + else: + a_desinscrire = [] + # inscriptions + for moduleimpl_id in a_inscrire: + # verifie que ce module existe bien + mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id) + if len(mods) != 1: + raise ScoValueError( + "inscription: invalid moduleimpl_id: %s" % moduleimpl_id + ) + mod = mods[0] + sco_moduleimpl.do_moduleimpl_inscription_create( + {"moduleimpl_id": moduleimpl_id, "etudid": etudid}, + formsemestre_id=mod["formsemestre_id"], + ) + # desinscriptions + for moduleimpl_id in a_desinscrire: + # verifie que ce module existe bien + mods = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id) + if len(mods) != 1: + raise ScoValueError( + "desinscription: invalid moduleimpl_id: %s" % moduleimpl_id + ) + mod = mods[0] + inscr = sco_moduleimpl.do_moduleimpl_inscription_list( + moduleimpl_id=moduleimpl_id, etudid=etudid + ) + if not inscr: + raise ScoValueError( + "pas inscrit a ce module ! (etudid=%s, moduleimpl_id=%s)" + % (etudid, moduleimpl_id) + ) + oid = inscr[0]["moduleimpl_inscription_id"] + sco_moduleimpl.do_moduleimpl_inscription_delete( + oid, formsemestre_id=mod["formsemestre_id"] + ) + + H = [ + html_sco_header.sco_header(), + """

Modifications effectuées

+

+ Retour à la fiche étudiant

+ """ + % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def est_inscrit_ailleurs(etudid, formsemestre_id): + """Vrai si l'étudiant est inscrit dans un semestre en même + temps que celui indiqué (par formsemestre_id). + Retourne la liste des semestres concernés (ou liste vide). + """ + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + debut_s = sem["dateord"] + fin_s = ndb.DateDMYtoISO(sem["date_fin"]) + r = [] + for s in etud["sems"]: + if s["formsemestre_id"] != formsemestre_id: + debut = s["dateord"] + fin = ndb.DateDMYtoISO(s["date_fin"]) + if debut < fin_s and fin > debut_s: + r.append(s) # intersection + return r + + +def list_inscrits_ailleurs(formsemestre_id): + """Liste des etudiants inscrits ailleurs en même temps que formsemestre_id. + Pour chacun, donne la liste des semestres. + { etudid : [ liste de sems ] } + """ + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + etudids = nt.get_etudids() + d = {} + for etudid in etudids: + d[etudid] = est_inscrit_ailleurs(etudid, formsemestre_id) + return d + + +def formsemestre_inscrits_ailleurs(formsemestre_id): + """Page listant les étudiants inscrits dans un autre semestre + dont les dates recouvrent le semestre indiqué. + """ + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + H = [ + html_sco_header.html_sem_header( + "Inscriptions multiples parmi les étudiants du semestre ", + ) + ] + insd = list_inscrits_ailleurs(formsemestre_id) + # liste ordonnée par nom + etudlist = [ + sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + for etudid in insd.keys() + if insd[etudid] + ] + etudlist.sort(key=lambda x: x["nom"]) + if etudlist: + H.append("
    ") + for etud in etudlist: + H.append( + '
  • %s : ' + % ( + url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etud["etudid"], + ), + etud["nomprenom"], + ) + ) + l = [] + for s in insd[etud["etudid"]]: + l.append( + '%(titremois)s' + % s + ) + H.append(", ".join(l)) + H.append("
  • ") + H.append("
") + H.append("

Total: %d étudiants concernés.

" % len(etudlist)) + H.append( + """

Ces étudiants sont inscrits dans le semestre sélectionné et aussi dans d'autres semestres qui se déroulent en même temps !
Sauf exception, cette situation est anormale:

+
    +
  • vérifier que les dates des semestres se suivent sans se chevaucher
  • +
  • ou si besoin désinscrire le(s) étudiant(s) de l'un des semestres (via leurs fiches individuelles).
  • +
+ """ + ) + else: + H.append("""

Aucun étudiant en inscription multiple (c'est normal) !

""") + return "\n".join(H) + html_sco_header.sco_footer() diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index dab861e9..f129a36a 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -1,1326 +1,1326 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Semestres: validation semestre et UE dans parcours -""" -import time - -import flask -from flask import url_for, g, request -from app.models.etudiants import Identite - -import app.scodoc.notesdb as ndb -import app.scodoc.sco_utils as scu -from app import db, log - -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre -from app.models.notes import etud_has_notes_attente -from app.models.validations import ( - ScolarAutorisationInscription, - ScolarFormSemestreValidation, -) -from app.models.but_validations import ApcValidationRCUE, ApcValidationAnnee -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc.scolog import logdb -from app.scodoc.sco_codes_parcours import * -from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message - -from app.scodoc import html_sco_header -from app.scodoc import sco_abs -from app.scodoc import sco_codes_parcours -from app.scodoc import sco_cache -from app.scodoc import sco_edit_ue -from app.scodoc import sco_etud -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_cursus -from app.scodoc import sco_cursus_dut -from app.scodoc.sco_cursus_dut import etud_est_inscrit_ue -from app.scodoc import sco_photos -from app.scodoc import sco_preferences -from app.scodoc import sco_pvjury - -# ------------------------------------------------------------------------------------ -def formsemestre_validation_etud_form( - formsemestre_id=None, # required - etudid=None, # one of etudid or etud_index is required - etud_index=None, - check=0, # opt: si true, propose juste une relecture du parcours - desturl=None, - sortcol=None, - readonly=True, -): - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - T = nt.get_table_moyennes_triees() - if not etudid and etud_index is None: - raise ValueError("formsemestre_validation_etud_form: missing argument etudid") - if etud_index is not None: - etud_index = int(etud_index) - # cherche l'etudid correspondant - if etud_index < 0 or etud_index >= len(T): - raise ValueError( - "formsemestre_validation_etud_form: invalid etud_index value" - ) - etudid = T[etud_index][-1] - else: - # cherche index pour liens navigation - etud_index = len(T) - 1 - while etud_index >= 0 and T[etud_index][-1] != etudid: - etud_index -= 1 - if etud_index < 0: - raise ValueError( - "formsemestre_validation_etud_form: can't retreive etud_index !" - ) - # prev, next pour liens navigation - etud_index_next = etud_index + 1 - if etud_index_next >= len(T): - etud_index_next = None - etud_index_prev = etud_index - 1 - if etud_index_prev < 0: - etud_index_prev = None - if readonly: - check = True - - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) - if not Se.sem["etat"]: - raise ScoValueError("validation: semestre verrouille") - - url_tableau = url_for( - "notes.formsemestre_recapcomplet", - scodoc_dept=g.scodoc_dept, - mode_jury=1, - formsemestre_id=formsemestre_id, - selected_etudid=etudid, # va a la bonne ligne - ) - - H = [ - html_sco_header.sco_header( - page_title=f"Parcours {etud['nomprenom']}", - javascripts=["js/recap_parcours.js"], - ) - ] - - # Navigation suivant/precedent - if etud_index_prev is not None: - etud_prev = Identite.query.get(T[etud_index_prev][-1]) - url_prev = url_for( - "notes.formsemestre_validation_etud_form", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etud_index=etud_index_prev, - ) - else: - url_prev = None - if etud_index_next is not None: - etud_next = Identite.query.get(T[etud_index_next][-1]) - url_next = url_for( - "notes.formsemestre_validation_etud_form", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - etud_index=etud_index_next, - ) - else: - url_next = None - footer = ["""") - - footer.append(html_sco_header.sco_footer()) - - H.append('
') - if not check: - H.append( - '

%s: validation %s%s

Parcours: %s' - % ( - etud["nomprenom"], - Se.parcours.SESSION_NAME_A, - Se.parcours.SESSION_NAME, - Se.get_parcours_descr(), - ) - ) - else: - H.append( - '

Parcours de %s

%s' - % (etud["nomprenom"], Se.get_parcours_descr()) - ) - - H.append( - '
%s
' - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]), - ) - ) - - etud_etat = nt.get_etud_etat(etudid) - if etud_etat == "D": - H.append('
Etudiant démissionnaire
') - if etud_etat == DEF: - H.append('
Etudiant défaillant
') - if etud_etat != "I": - H.append( - tf_error_message( - f"""Impossible de statuer sur cet étudiant: - il est démissionnaire ou défaillant (voir sa fiche) - """ - ) - ) - return "\n".join(H + footer) - - H.append( - formsemestre_recap_parcours_table( - Se, etudid, with_links=(check and not readonly) - ) - ) - if check: - if not desturl: - desturl = url_tableau - H.append(f'') - - return "\n".join(H + footer) - - decision_jury = Se.nt.get_etud_decision_sem(etudid) - - # Bloque si note en attente - if etud_has_notes_attente(etudid, formsemestre_id): - H.append( - tf_error_message( - f"""Impossible de statuer sur cet étudiant: il a des notes en - attente dans des évaluations de ce semestre (voir tableau de bord) - """ - ) - ) - return "\n".join(H + footer) - - # Infos si pas de semestre précédent - if not Se.prev: - if Se.sem["semestre_id"] == 1: - H.append("

Premier semestre (pas de précédent)

") - else: - H.append("

Pas de semestre précédent !

") - else: - if not Se.prev_decision: - H.append( - tf_error_message( - f"""Le jury n'a pas statué sur le semestre précédent ! (le faire maintenant) - """ - ) - ) - if decision_jury: - H.append( - f"""Supprimer décision existante - """ - ) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - # Infos sur decisions déjà saisies - if decision_jury: - if decision_jury["assidu"]: - ass = "assidu" - else: - ass = "non assidu" - H.append("

Décision existante du %(event_date)s: %(code)s" % decision_jury) - H.append(" (%s)" % ass) - autorisations = ScolarAutorisationInscription.query.filter_by( - etudid=etudid, origin_formsemestre_id=formsemestre_id - ).all() - if autorisations: - H.append(". Autorisé%s à s'inscrire en " % etud["ne"]) - H.append(", ".join([f"S{aut.semestre_id}" for aut in autorisations]) + ".") - H.append("

") - - # Cas particulier pour ATJ: corriger precedent avant de continuer - if Se.prev_decision and Se.prev_decision["code"] == ATJ: - H.append( - """

La décision du semestre précédent est en - attente à cause d\'un problème d\'assiduité.

-

Vous devez la corriger avant de continuer ce jury. Soit vous considérez que le - problème d'assiduité n'est pas réglé et choisissez de ne pas valider le semestre - précédent (échec), soit vous entrez une décision sans prendre en compte - l'assiduité.

-
- - - - - """ - % (Se.prev["formsemestre_id"], etudid, etudid, formsemestre_id) - ) - if sortcol: - H.append('' % sortcol) - H.append("
") - - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - # Explication sur barres actuelles - H.append('

L\'étudiant ') - if Se.barre_moy_ok: - H.append("a la moyenne générale, ") - else: - H.append("n'a pas la moyenne générale, ") - - H.append(Se.barres_ue_diag) # eg 'les UEs sont au dessus des barres' - - if (not Se.barre_moy_ok) and Se.can_compensate_with_prev: - H.append(", et ce semestre peut se compenser avec le précédent") - H.append(".

") - - # Décisions possibles - rows_assidu = decisions_possible_rows( - Se, True, subtitle="Étudiant assidu:", trclass="sfv_ass" - ) - rows_non_assidu = decisions_possible_rows( - Se, False, subtitle="Si problème d'assiduité:", trclass="sfv_pbass" - ) - # s'il y a des decisions recommandees issues des regles: - if rows_assidu or rows_non_assidu: - H.append( - """
- - """ - % (etudid, formsemestre_id) - ) - if desturl: - H.append('' % desturl) - if sortcol: - H.append('' % sortcol) - - H.append('

Décisions recommandées :

') - H.append("") - H.append(rows_assidu) - if rows_non_assidu: - H.append("") # spacer - H.append(rows_non_assidu) - - H.append("
 
") - H.append( - '


' - ) - H.append("
") - - H.append(form_decision_manuelle(Se, formsemestre_id, etudid)) - - H.append( - f"""""" - ) - - H.append('

Formation ') - if Se.sem["gestion_semestrielle"]: - H.append("avec semestres décalés

") - else: - H.append("sans semestres décalés

") - - return "".join(H + footer) - - -def formsemestre_validation_etud( - formsemestre_id=None, # required - etudid=None, # required - codechoice=None, # required - desturl="", - sortcol=None, -): - """Enregistre validation""" - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) - # retrouve la decision correspondant au code: - choices = Se.get_possible_choices(assiduite=True) - choices += Se.get_possible_choices(assiduite=False) - selected_choice = None - for choice in choices: - if choice.codechoice == codechoice: - selected_choice = choice - break - if not selected_choice: - raise ValueError("code choix invalide ! (%s)" % codechoice) - # - Se.valide_decision(selected_choice) # enregistre - return _redirect_valid_choice( - formsemestre_id, etudid, Se, selected_choice, desturl, sortcol - ) - - -def formsemestre_validation_etud_manu( - formsemestre_id=None, # required - etudid=None, # required - code_etat="", - new_code_prev="", - devenir="", # required (la decision manuelle) - assidu=False, - desturl="", - sortcol=None, - redirect=True, -): - """Enregistre validation""" - if assidu: - assidu = True - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) - if code_etat in Se.parcours.UNUSED_CODES: - raise ScoValueError("code decision invalide dans ce parcours") - # Si code ADC, extrait le semestre utilisé: - if code_etat[:3] == ADC: - formsemestre_id_utilise_pour_compenser = code_etat.split("_")[1] - if not formsemestre_id_utilise_pour_compenser: - formsemestre_id_utilise_pour_compenser = ( - None # compense avec semestre hors ScoDoc - ) - code_etat = ADC - else: - formsemestre_id_utilise_pour_compenser = None - - # Construit le choix correspondant: - choice = sco_cursus_dut.DecisionSem( - code_etat=code_etat, - new_code_prev=new_code_prev, - devenir=devenir, - assiduite=assidu, - formsemestre_id_utilise_pour_compenser=formsemestre_id_utilise_pour_compenser, - ) - # - Se.valide_decision(choice) # enregistre - if redirect: - return _redirect_valid_choice( - formsemestre_id, etudid, Se, choice, desturl, sortcol - ) - - -def _redirect_valid_choice(formsemestre_id, etudid, Se, choice, desturl, sortcol): - adr = "formsemestre_validation_etud_form?formsemestre_id=%s&etudid=%s&check=1" % ( - formsemestre_id, - etudid, - ) - if sortcol: - adr += "&sortcol=" + str(sortcol) - # if desturl: - # desturl += "&desturl=" + desturl - return flask.redirect(adr) - # Si le precedent a été modifié, demande relecture du parcours. - # sinon renvoie au listing general, - - -def _dispcode(c): - if not c: - return "" - return c - - -def decisions_possible_rows(Se, assiduite, subtitle="", trclass=""): - "Liste HTML des decisions possibles" - choices = Se.get_possible_choices(assiduite=assiduite) - if not choices: - return "" - TitlePrev = "" - if Se.prev: - if Se.prev["semestre_id"] >= 0: - TitlePrev = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.prev["semestre_id"]) - else: - TitlePrev = "Prec." - - if Se.sem["semestre_id"] >= 0: - TitleCur = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.sem["semestre_id"]) - else: - TitleCur = Se.parcours.SESSION_NAME - - H = [ - '%s' - % (trclass, subtitle) - ] - if Se.prev: - H.append("Code %s" % TitlePrev) - H.append("Code %sDevenir" % TitleCur) - for ch in choices: - H.append( - """""" - % (trclass, ch.rule_id, ch.codechoice) - ) - H.append("%s " % ch.explication) - if Se.prev: - H.append('%s' % _dispcode(ch.new_code_prev)) - H.append( - '%s%s' - % (_dispcode(ch.code_etat), Se.explique_devenir(ch.devenir)) - ) - H.append("") - - return "\n".join(H) - - -def formsemestre_recap_parcours_table( - Se, - etudid, - with_links=False, - with_all_columns=True, - a_url="", - sem_info=None, - show_details=False, -): - """Tableau HTML recap parcours - Si with_links, ajoute liens pour modifier decisions (colonne de droite) - sem_info = { formsemestre_id : txt } permet d'ajouter des informations associées à chaque semestre - with_all_columns: si faux, pas de colonne "assiduité". - """ - sem_info = sem_info or {} - H = [] - linktmpl = '%s' - minuslink = linktmpl % scu.icontag("minus_img", border="0", alt="-") - pluslink = linktmpl % scu.icontag("plus_img", border="0", alt="+") - if show_details: - sd = " recap_show_details" - plusminus = minuslink - else: - sd = " recap_hide_details" - plusminus = pluslink - H.append('' % sd) - H.append( - '' - % scu.icontag("plus18_img", width=18, height=18, border=0, title="", alt="+") - ) - H.append("") - # titres des UE - H.append("" * Se.nb_max_ue) - # - if with_links: - H.append("") - H.append("") - num_sem = 0 - - for sem in Se.get_semestres(): - is_prev = Se.prev and (Se.prev["formsemestre_id"] == sem["formsemestre_id"]) - is_cur = Se.formsemestre_id == sem["formsemestre_id"] - num_sem += 1 - - dpv = sco_pvjury.dict_pvjury(sem["formsemestre_id"], etudids=[etudid]) - pv = dpv["decisions"][0] - decision_sem = pv["decision_sem"] - decisions_ue = pv["decisions_ue"] - if with_all_columns and decision_sem and not decision_sem["assidu"]: - ass = " (non ass.)" - else: - ass = "" - - formsemestre = FormSemestre.query.get(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - if is_cur: - type_sem = "*" # now unused - class_sem = "sem_courant" - elif is_prev: - type_sem = "p" - class_sem = "sem_precedent" - else: - type_sem = "" - class_sem = "sem_autre" - if sem["formation_code"] != Se.formation.formation_code: - class_sem += " sem_autre_formation" - if sem["bul_bgcolor"]: - bgcolor = sem["bul_bgcolor"] - else: - bgcolor = "background-color: rgb(255,255,240)" - # 1ere ligne: titre sem, decision, acronymes UE - H.append('' % (class_sem, sem["formsemestre_id"])) - if is_cur: - pm = "" - elif is_prev: - pm = minuslink % sem["formsemestre_id"] - else: - pm = plusminus % sem["formsemestre_id"] - - inscr = formsemestre.etuds_inscriptions.get(etudid) - parcours_name = ( - f' {inscr.parcour.code}' - if (inscr and inscr.parcour) - else "" - ) - H.append( - f""" - - - - """ - ) - if nt.is_apc: - H.append('') - elif decision_sem: - H.append('' % decision_sem["code"]) - else: - H.append("") - H.append('' % ass) # abs - # acronymes UEs auxquelles l'étudiant est inscrit (ou capitalisé) - ues = nt.get_ues_stat_dict(filter_sport=True) - cnx = ndb.GetDBConnexion() - etud_ue_status = { - ue["ue_id"]: nt.get_etud_ue_status(etudid, ue["ue_id"]) for ue in ues - } - if not nt.is_apc: - # formations classiques: filtre UE sur inscriptions (et garde UE capitalisées) - ues = [ - ue - for ue in ues - if etud_est_inscrit_ue(cnx, etudid, sem["formsemestre_id"], ue["ue_id"]) - or etud_ue_status[ue["ue_id"]]["is_capitalized"] - ] - - for ue in ues: - H.append('' % ue["acronyme"]) - if len(ues) < Se.nb_max_ue: - H.append('' % (Se.nb_max_ue - len(ues))) - # indique le semestre compensé par celui ci: - if decision_sem and decision_sem["compense_formsemestre_id"]: - csem = sco_formsemestre.get_formsemestre( - decision_sem["compense_formsemestre_id"] - ) - H.append("" % csem["semestre_id"]) - else: - H.append("") - if with_links: - H.append("") - H.append("") - # 2eme ligne: notes - H.append('' % (class_sem, sem["formsemestre_id"])) - H.append( - '' - % (bgcolor) - ) - if is_prev: - default_sem_info = '[sem. précédent]' - else: - default_sem_info = "" - if not sem["etat"]: # locked - lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") - default_sem_info += lockicon - if sem["formation_code"] != Se.formation.formation_code: - default_sem_info += "Autre formation: %s" % sem["formation_code"] - H.append( - '' - % (sem["mois_fin"], sem_info.get(sem["formsemestre_id"], default_sem_info)) - ) - # Moy Gen (sous le code decision) - H.append( - '' % scu.fmt_note(nt.get_etud_moy_gen(etudid)) - ) - # Absences (nb d'abs non just. dans ce semestre) - nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem) - H.append('' % (nbabs - nbabsjust)) - - # UEs - for ue in ues: - if decisions_ue and ue["ue_id"] in decisions_ue: - code = decisions_ue[ue["ue_id"]]["code"] - else: - code = "" - ue_status = etud_ue_status[ue["ue_id"]] - moy_ue = ue_status["moy"] if ue_status else "" - explanation_ue = [] # list of strings - if code == ADM: - class_ue = "ue_adm" - elif code == CMP: - class_ue = "ue_cmp" - else: - class_ue = "ue" - if ue_status and ue_status["is_external"]: # validation externe - explanation_ue.append("UE externe.") - - if ue_status and ue_status["is_capitalized"]: - class_ue += " ue_capitalized" - explanation_ue.append( - "Capitalisée le %s." % (ue_status["event_date"] or "?") - ) - - H.append( - '' - % (class_ue, " ".join(explanation_ue), scu.fmt_note(moy_ue)) - ) - if len(ues) < Se.nb_max_ue: - H.append('' % (Se.nb_max_ue - len(ues))) - - H.append("") - if with_links: - H.append( - '' - % (a_url, sem["formsemestre_id"], etudid) - ) - - H.append("") - # 3eme ligne: ECTS - if ( - sco_preferences.get_preference("bul_show_ects", sem["formsemestre_id"]) - or nt.parcours.ECTS_ONLY - ): - etud_ects_infos = nt.get_etud_ects_pot(etudid) # ECTS potentiels - H.append( - f""" - - """ - ) - # Total ECTS (affiché sous la moyenne générale) - H.append( - f""" - - - """ - ) - # ECTS validables dans chaque UE - for ue in ues: - ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) - if ue_status: - ects = ue_status["ects"] - ects_pot = ue_status["ects_pot"] - H.append( - f"""""" - ) - else: - H.append(f"""""") - H.append("") - - H.append("
%sSemestreEtatAbs
{num_sem}{pm}{sem['mois_debut']}{formsemestre.titre_annee()}{parcours_name}BUT%sen cours%s%scompense S%s
 %s%s%s%d%smodifier
 ECTS:{pv.get("sum_ects",0):2.2g} / {etud_ects_infos["ects_total"]:2.2g}{ects:2.2g}
") - return "\n".join(H) - - -def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None): - """Formulaire pour saisie décision manuelle""" - H = [ - """ - - -
- - - """ - % (etudid, formsemestre_id) - ] - if desturl: - H.append('' % desturl) - if sortcol: - H.append('' % sortcol) - - H.append( - '

Décisions manuelles : (vérifiez bien votre choix !)

' - ) - - # Choix code semestre: - codes = list(sco_codes_parcours.CODES_JURY_SEM) - codes.sort() # fortuitement, cet ordre convient bien ! - - H.append( - '") - - # Choix code semestre precedent: - if Se.prev: - H.append( - '") - - # Choix code devenir - codes = list(sco_codes_parcours.DEVENIR_EXPL.keys()) - codes.sort() # fortuitement, cet ordre convient aussi bien ! - - if Se.sem["semestre_id"] == -1: - allowed_codes = sco_codes_parcours.DEVENIRS_MONO - else: - allowed_codes = set(sco_codes_parcours.DEVENIRS_STD) - # semestres decales ? - if Se.sem["gestion_semestrielle"]: - allowed_codes = allowed_codes.union(sco_codes_parcours.DEVENIRS_DEC) - # n'autorise les codes NEXT2 que si semestres décalés et s'il ne manque qu'un semestre avant le n+2 - if Se.can_jump_to_next2(): - allowed_codes = allowed_codes.union(sco_codes_parcours.DEVENIRS_NEXT2) - - H.append( - '") - - H.append( - '' - ) - - H.append( - """
Code semestre:
Code semestre précédent:
Devenir:
assidu
- - Supprimer décision existante -
- """ - % (etudid, formsemestre_id) - ) - return "\n".join(H) - - -# ----------- -def formsemestre_validation_auto(formsemestre_id): - "Formulaire saisie automatisee des decisions d'un semestre" - H = [ - html_sco_header.html_sem_header("Saisie automatique des décisions du semestre"), - f""" -
    -
  • Seuls les étudiants qui obtiennent le semestre seront affectés (code ADM, moyenne générale et - toutes les barres, semestre précédent validé);
  • -
  • le semestre précédent, s'il y en a un, doit avoir été validé;
  • -
  • les décisions du semestre précédent ne seront pas modifiées;
  • -
  • l'assiduité n'est pas prise en compte;
  • -
  • les étudiants avec des notes en attente sont ignorés.
  • -
-

Il est donc vivement conseillé de relire soigneusement les décisions à l'issue - de cette procédure !

-
- - -

Le calcul prend quelques minutes, soyez patients !

-
- """, - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def do_formsemestre_validation_auto(formsemestre_id): - "Saisie automatisee des decisions d'un semestre" - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - next_semestre_id = sem["semestre_id"] + 1 - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - etudids = nt.get_etudids() - nb_valid = 0 - conflicts = [] # liste des etudiants avec decision differente déjà saisie - with sco_cache.DeferredSemCacheManager(): - for etudid in etudids: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - {"etudid": etudid, "formsemestre_id": formsemestre_id} - )[0] - - # Conditions pour validation automatique: - if ins["etat"] == "I" and ( - ( - (not Se.prev) - or ( - Se.prev_decision and Se.prev_decision["code"] in (ADM, ADC, ADJ) - ) - ) - and Se.barre_moy_ok - and Se.barres_ue_ok - and not etud_has_notes_attente(etudid, formsemestre_id) - ): - # check: s'il existe une decision ou autorisation et qu'elles sont differentes, - # warning (et ne fait rien) - decision_sem = nt.get_etud_decision_sem(etudid) - ok = True - if decision_sem and decision_sem["code"] != ADM: - ok = False - conflicts.append(etud) - autorisations = ScolarAutorisationInscription.query.filter_by( - etudid=etudid, origin_formsemestre_id=formsemestre_id - ).all() - if len(autorisations) != 0: - if ( - len(autorisations) > 1 - or autorisations[0].semestre_id != next_semestre_id - ): - if ok: - conflicts.append(etud) - ok = False - - # ok, valide ! - if ok: - formsemestre_validation_etud_manu( - formsemestre_id, - etudid, - code_etat=ADM, - devenir="NEXT", - assidu=True, - redirect=False, - ) - nb_valid += 1 - log( - "do_formsemestre_validation_auto: %d validations, %d conflicts" - % (nb_valid, len(conflicts)) - ) - H = [html_sco_header.sco_header(page_title="Saisie automatique")] - H.append( - """

Saisie automatique des décisions du semestre %s

-

Opération effectuée.

-

%d étudiants validés (sur %s)

""" - % (sem["titreannee"], nb_valid, len(etudids)) - ) - if conflicts: - H.append( - f"""

Attention: {len(conflicts)} étudiants non modifiés - car décisions différentes déja saisies : -

") - H.append( - f"""continuer""" - ) - H.append(html_sco_header.sco_footer()) - return "\n".join(H) - - -def formsemestre_validation_suppress_etud(formsemestre_id, etudid): - """Suppression des décisions de jury pour un étudiant/formsemestre. - Efface toutes les décisions enregistrées concernant ce formsemestre et cet étudiant: - code semestre, UEs, autorisations d'inscription - """ - log(f"formsemestre_validation_suppress_etud( {formsemestre_id}, {etudid})") - - # Validations jury classiques (semestres, UEs, autorisations) - for v in ScolarFormSemestreValidation.query.filter_by( - etudid=etudid, formsemestre_id=formsemestre_id - ): - db.session.delete(v) - for v in ScolarAutorisationInscription.query.filter_by( - etudid=etudid, origin_formsemestre_id=formsemestre_id - ): - db.session.delete(v) - # Validations jury spécifiques BUT - for v in ApcValidationRCUE.query.filter_by( - etudid=etudid, formsemestre_id=formsemestre_id - ): - db.session.delete(v) - for v in ApcValidationAnnee.query.filter_by( - etudid=etudid, formsemestre_id=formsemestre_id - ): - db.session.delete(v) - - db.session.commit() - - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - _invalidate_etud_formation_caches( - etudid, sem["formation_id"] - ) # > suppr. decision jury (peut affecter de plusieurs semestres utilisant UE capitalisée) - - -def formsemestre_validate_previous_ue(formsemestre_id, etudid): - """Form. saisie UE validée hors ScoDoc - (pour étudiants arrivant avec un UE antérieurement validée). - """ - from app.scodoc import sco_formations - - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - Fo = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] - - H = [ - html_sco_header.sco_header( - page_title="Validation UE", - javascripts=["js/validate_previous_ue.js"], - ), - '
', - """

%s: validation d'une UE antérieure

""" - % etud["nomprenom"], - ( - '
%s
' - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]), - ) - ), - """

Utiliser cette page pour enregistrer une UE validée antérieurement, - dans un semestre hors ScoDoc.

-

Les UE validées dans ScoDoc sont déjà - automatiquement prises en compte. Cette page n'est utile que pour les étudiants ayant - suivi un début de cursus dans un autre établissement, ou bien dans un semestre géré sans - ScoDoc et qui redouble ce semestre (ne pas utiliser pour les semestres précédents !). -

-

Notez que l'UE est validée, avec enregistrement immédiat de la décision et - l'attribution des ECTS.

""", - "

On ne peut prendre en compte ici que les UE du cursus %(titre)s

" - % Fo, - ] - - # Toutes les UE de cette formation sont présentées (même celles des autres semestres) - ues = sco_edit_ue.ue_list({"formation_id": Fo["formation_id"]}) - ue_names = ["Choisir..."] + ["%(acronyme)s %(titre)s" % ue for ue in ues] - ue_ids = [""] + [ue["ue_id"] for ue in ues] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("etudid", {"input_type": "hidden"}), - ("formsemestre_id", {"input_type": "hidden"}), - ( - "ue_id", - { - "input_type": "menu", - "title": "Unité d'Enseignement (UE)", - "allow_null": False, - "allowed_values": ue_ids, - "labels": ue_names, - }, - ), - ( - "semestre_id", - { - "input_type": "menu", - "title": "Indice du semestre", - "explanation": "Facultatif: indice du semestre dans la formation", - "allow_null": True, - "allowed_values": [""] + [x for x in range(11)], - "labels": ["-"] + list(range(11)), - }, - ), - ( - "date", - { - "input_type": "date", - "size": 9, - "explanation": "j/m/a", - "default": time.strftime("%d/%m/%Y"), - }, - ), - ( - "moy_ue", - { - "type": "float", - "allow_null": False, - "min_value": 0, - "max_value": 20, - "title": "Moyenne (/20) obtenue dans cette UE:", - }, - ), - ), - cancelbutton="Annuler", - submitlabel="Enregistrer validation d'UE", - ) - if tf[0] == 0: - X = """ -
-
- """ - warn, ue_multiples = check_formation_ues(Fo["formation_id"]) - return "\n".join(H) + tf[1] + X + warn + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect( - scu.NotesURL() - + "/formsemestre_status?formsemestre_id=" - + str(formsemestre_id) - ) - else: - if tf[2]["semestre_id"]: - semestre_id = int(tf[2]["semestre_id"]) - else: - semestre_id = None - do_formsemestre_validate_previous_ue( - formsemestre_id, - etudid, - tf[2]["ue_id"], - tf[2]["moy_ue"], - tf[2]["date"], - semestre_id=semestre_id, - ) - return flask.redirect( - scu.ScoURL() - + "/Notes/formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s&head_message=Validation%%20d'UE%%20enregistree" - % (formsemestre_id, etudid) - ) - - -def do_formsemestre_validate_previous_ue( - formsemestre_id, - etudid, - ue_id, - moy_ue, - date, - code=ADM, - semestre_id=None, - ue_coefficient=None, -): - """Enregistre (ou modifie) validation d'UE (obtenue hors ScoDoc). - Si le coefficient est spécifié, modifie le coefficient de - cette UE (utile seulement pour les semestres extérieurs). - """ - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - cnx = ndb.GetDBConnexion() - if ue_coefficient != None: - sco_formsemestre.do_formsemestre_uecoef_edit_or_create( - cnx, formsemestre_id, ue_id, ue_coefficient - ) - else: - sco_formsemestre.do_formsemestre_uecoef_delete(cnx, formsemestre_id, ue_id) - sco_cursus_dut.do_formsemestre_validate_ue( - cnx, - nt, - formsemestre_id, # "importe" cette UE dans le semestre (new 3/2015) - etudid, - ue_id, - code, - moy_ue=moy_ue, - date=date, - semestre_id=semestre_id, - is_external=True, - ) - - logdb( - cnx, - method="formsemestre_validate_previous_ue", - etudid=etudid, - msg="Validation UE %s" % ue_id, - commit=False, - ) - _invalidate_etud_formation_caches(etudid, sem["formation_id"]) - cnx.commit() - - -def _invalidate_etud_formation_caches(etudid, formation_id): - "Invalide tous les semestres de cette formation où l'etudiant est inscrit..." - r = ndb.SimpleDictFetch( - """SELECT sem.id - FROM notes_formsemestre sem, notes_formsemestre_inscription i - WHERE sem.formation_id = %(formation_id)s - AND i.formsemestre_id = sem.id - AND i.etudid = %(etudid)s - """, - {"etudid": etudid, "formation_id": formation_id}, - ) - for fsid in [s["id"] for s in r]: - sco_cache.invalidate_formsemestre( - formsemestre_id=fsid - ) # > modif decision UE (inval tous semestres avec cet etudiant, ok mais conservatif) - - -def get_etud_ue_cap_html(etudid, formsemestre_id, ue_id): - """Ramene bout de HTML pour pouvoir supprimer une validation de cette UE""" - valids = ndb.SimpleDictFetch( - """SELECT SFV.* - FROM scolar_formsemestre_validation SFV - WHERE ue_id=%(ue_id)s - AND etudid=%(etudid)s""", - {"etudid": etudid, "ue_id": ue_id}, - ) - if not valids: - return "" - H = [ - '
Validations existantes pour cette UE:
    ' - ] - for valid in valids: - valid["event_date"] = ndb.DateISOtoDMY(valid["event_date"]) - if valid["moy_ue"] != None: - valid["m"] = ", moyenne %(moy_ue)g/20" % valid - else: - valid["m"] = "" - if valid["formsemestre_id"]: - sem = sco_formsemestre.get_formsemestre(valid["formsemestre_id"]) - valid["s"] = ", du semestre %s" % sem["titreannee"] - else: - valid["s"] = " enregistrée d'un parcours antérieur (hors ScoDoc)" - if valid["semestre_id"]: - valid["s"] += " (S%d)" % valid["semestre_id"] - valid["ds"] = formsemestre_id - H.append( - '
  • %(code)s%(m)s%(s)s, le %(event_date)s effacer
  • ' - % valid - ) - H.append("
") - return "\n".join(H) - - -def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id): - """Suppress a validation (ue_id, etudid) and redirect to formsemestre""" - log("etud_ue_suppress_validation( %s, %s, %s)" % (etudid, formsemestre_id, ue_id)) - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - "DELETE FROM scolar_formsemestre_validation WHERE etudid=%(etudid)s and ue_id=%(ue_id)s", - {"etudid": etudid, "ue_id": ue_id}, - ) - - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - _invalidate_etud_formation_caches(etudid, sem["formation_id"]) - - return flask.redirect( - scu.NotesURL() - + "/formsemestre_validate_previous_ue?etudid=%s&formsemestre_id=%s" - % (etudid, formsemestre_id) - ) - - -def check_formation_ues(formation_id): - """Verifie que les UE d'une formation sont chacune utilisée dans un seul semestre_id - Si ce n'est pas le cas, c'est probablement (mais pas forcément) une erreur de - définition du programme: cette fonction retourne un bout de HTML - à afficher pour prévenir l'utilisateur, ou '' si tout est ok. - """ - ues = sco_edit_ue.ue_list({"formation_id": formation_id}) - ue_multiples = {} # { ue_id : [ liste des formsemestre ] } - for ue in ues: - # formsemestres utilisant cette ue ? - sems = ndb.SimpleDictFetch( - """SELECT DISTINCT sem.id AS formsemestre_id, sem.* - FROM notes_formsemestre sem, notes_modules mod, notes_moduleimpl mi - WHERE sem.formation_id = %(formation_id)s - AND mod.id = mi.module_id - AND mi.formsemestre_id = sem.id - AND mod.ue_id = %(ue_id)s - """, - {"ue_id": ue["ue_id"], "formation_id": formation_id}, - ) - semestre_ids = set([x["semestre_id"] for x in sems]) - if ( - len(semestre_ids) > 1 - ): # plusieurs semestres d'indices differents dans le cursus - ue_multiples[ue["ue_id"]] = sems - - if not ue_multiples: - return "", {} - # Genere message HTML: - H = [ - """
Attention: les UE suivantes de cette formation - sont utilisées dans des - semestres de rangs différents (eg S1 et S3).
Cela peut engendrer des problèmes pour - la capitalisation des UE. Il serait préférable d'essayer de rectifier cette situation: - soit modifier le programme de la formation (définir des UE dans chaque semestre), - soit veiller à saisir le bon indice de semestre dans le menu lors de la validation d'une - UE extérieure. -
    - """ - ] - for ue in ues: - if ue["ue_id"] in ue_multiples: - sems = [ - sco_formsemestre.get_formsemestre(x["formsemestre_id"]) - for x in ue_multiples[ue["ue_id"]] - ] - slist = ", ".join( - [ - """%(titreannee)s (semestre %(semestre_id)s)""" - % s - for s in sems - ] - ) - H.append("
  • %s : %s
  • " % (ue["acronyme"], slist)) - H.append("
") - - return "\n".join(H), ue_multiples +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Semestres: validation semestre et UE dans parcours +""" +import time + +import flask +from flask import url_for, g, request +from app.models.etudiants import Identite + +import app.scodoc.notesdb as ndb +import app.scodoc.sco_utils as scu +from app import db, log + +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre +from app.models.notes import etud_has_notes_attente +from app.models.validations import ( + ScolarAutorisationInscription, + ScolarFormSemestreValidation, +) +from app.models.but_validations import ApcValidationRCUE, ApcValidationAnnee +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.scolog import logdb +from app.scodoc.sco_codes_parcours import * +from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message + +from app.scodoc import html_sco_header +from app.scodoc import sco_abs +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_cache +from app.scodoc import sco_edit_ue +from app.scodoc import sco_etud +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_cursus +from app.scodoc import sco_cursus_dut +from app.scodoc.sco_cursus_dut import etud_est_inscrit_ue +from app.scodoc import sco_photos +from app.scodoc import sco_preferences +from app.scodoc import sco_pvjury + +# ------------------------------------------------------------------------------------ +def formsemestre_validation_etud_form( + formsemestre_id=None, # required + etudid=None, # one of etudid or etud_index is required + etud_index=None, + check=0, # opt: si true, propose juste une relecture du parcours + desturl=None, + sortcol=None, + readonly=True, +): + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + T = nt.get_table_moyennes_triees() + if not etudid and etud_index is None: + raise ValueError("formsemestre_validation_etud_form: missing argument etudid") + if etud_index is not None: + etud_index = int(etud_index) + # cherche l'etudid correspondant + if etud_index < 0 or etud_index >= len(T): + raise ValueError( + "formsemestre_validation_etud_form: invalid etud_index value" + ) + etudid = T[etud_index][-1] + else: + # cherche index pour liens navigation + etud_index = len(T) - 1 + while etud_index >= 0 and T[etud_index][-1] != etudid: + etud_index -= 1 + if etud_index < 0: + raise ValueError( + "formsemestre_validation_etud_form: can't retreive etud_index !" + ) + # prev, next pour liens navigation + etud_index_next = etud_index + 1 + if etud_index_next >= len(T): + etud_index_next = None + etud_index_prev = etud_index - 1 + if etud_index_prev < 0: + etud_index_prev = None + if readonly: + check = True + + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) + if not Se.sem["etat"]: + raise ScoValueError("validation: semestre verrouille") + + url_tableau = url_for( + "notes.formsemestre_recapcomplet", + scodoc_dept=g.scodoc_dept, + mode_jury=1, + formsemestre_id=formsemestre_id, + selected_etudid=etudid, # va a la bonne ligne + ) + + H = [ + html_sco_header.sco_header( + page_title=f"Parcours {etud['nomprenom']}", + javascripts=["js/recap_parcours.js"], + ) + ] + + # Navigation suivant/precedent + if etud_index_prev is not None: + etud_prev = Identite.query.get(T[etud_index_prev][-1]) + url_prev = url_for( + "notes.formsemestre_validation_etud_form", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etud_index=etud_index_prev, + ) + else: + url_prev = None + if etud_index_next is not None: + etud_next = Identite.query.get(T[etud_index_next][-1]) + url_next = url_for( + "notes.formsemestre_validation_etud_form", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etud_index=etud_index_next, + ) + else: + url_next = None + footer = ["""") + + footer.append(html_sco_header.sco_footer()) + + H.append('
') + if not check: + H.append( + '

%s: validation %s%s

Parcours: %s' + % ( + etud["nomprenom"], + Se.parcours.SESSION_NAME_A, + Se.parcours.SESSION_NAME, + Se.get_parcours_descr(), + ) + ) + else: + H.append( + '

Parcours de %s

%s' + % (etud["nomprenom"], Se.get_parcours_descr()) + ) + + H.append( + '
%s
' + % ( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]), + ) + ) + + etud_etat = nt.get_etud_etat(etudid) + if etud_etat == scu.DEMISSION: + H.append('
Etudiant démissionnaire
') + if etud_etat == scu.DEF: + H.append('
Etudiant défaillant
') + if etud_etat != scu.INSCRIT: + H.append( + tf_error_message( + f"""Impossible de statuer sur cet étudiant: + il est démissionnaire ou défaillant (voir sa fiche) + """ + ) + ) + return "\n".join(H + footer) + + H.append( + formsemestre_recap_parcours_table( + Se, etudid, with_links=(check and not readonly) + ) + ) + if check: + if not desturl: + desturl = url_tableau + H.append(f'') + + return "\n".join(H + footer) + + decision_jury = Se.nt.get_etud_decision_sem(etudid) + + # Bloque si note en attente + if etud_has_notes_attente(etudid, formsemestre_id): + H.append( + tf_error_message( + f"""Impossible de statuer sur cet étudiant: il a des notes en + attente dans des évaluations de ce semestre (voir tableau de bord) + """ + ) + ) + return "\n".join(H + footer) + + # Infos si pas de semestre précédent + if not Se.prev: + if Se.sem["semestre_id"] == 1: + H.append("

Premier semestre (pas de précédent)

") + else: + H.append("

Pas de semestre précédent !

") + else: + if not Se.prev_decision: + H.append( + tf_error_message( + f"""Le jury n'a pas statué sur le semestre précédent ! (le faire maintenant) + """ + ) + ) + if decision_jury: + H.append( + f"""Supprimer décision existante + """ + ) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + # Infos sur decisions déjà saisies + if decision_jury: + if decision_jury["assidu"]: + ass = "assidu" + else: + ass = "non assidu" + H.append("

Décision existante du %(event_date)s: %(code)s" % decision_jury) + H.append(" (%s)" % ass) + autorisations = ScolarAutorisationInscription.query.filter_by( + etudid=etudid, origin_formsemestre_id=formsemestre_id + ).all() + if autorisations: + H.append(". Autorisé%s à s'inscrire en " % etud["ne"]) + H.append(", ".join([f"S{aut.semestre_id}" for aut in autorisations]) + ".") + H.append("

") + + # Cas particulier pour ATJ: corriger precedent avant de continuer + if Se.prev_decision and Se.prev_decision["code"] == ATJ: + H.append( + """

La décision du semestre précédent est en + attente à cause d\'un problème d\'assiduité.

+

Vous devez la corriger avant de continuer ce jury. Soit vous considérez que le + problème d'assiduité n'est pas réglé et choisissez de ne pas valider le semestre + précédent (échec), soit vous entrez une décision sans prendre en compte + l'assiduité.

+
+ + + + + """ + % (Se.prev["formsemestre_id"], etudid, etudid, formsemestre_id) + ) + if sortcol: + H.append('' % sortcol) + H.append("
") + + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + # Explication sur barres actuelles + H.append('

L\'étudiant ') + if Se.barre_moy_ok: + H.append("a la moyenne générale, ") + else: + H.append("n'a pas la moyenne générale, ") + + H.append(Se.barres_ue_diag) # eg 'les UEs sont au dessus des barres' + + if (not Se.barre_moy_ok) and Se.can_compensate_with_prev: + H.append(", et ce semestre peut se compenser avec le précédent") + H.append(".

") + + # Décisions possibles + rows_assidu = decisions_possible_rows( + Se, True, subtitle="Étudiant assidu:", trclass="sfv_ass" + ) + rows_non_assidu = decisions_possible_rows( + Se, False, subtitle="Si problème d'assiduité:", trclass="sfv_pbass" + ) + # s'il y a des decisions recommandees issues des regles: + if rows_assidu or rows_non_assidu: + H.append( + """
+ + """ + % (etudid, formsemestre_id) + ) + if desturl: + H.append('' % desturl) + if sortcol: + H.append('' % sortcol) + + H.append('

Décisions recommandées :

') + H.append("") + H.append(rows_assidu) + if rows_non_assidu: + H.append("") # spacer + H.append(rows_non_assidu) + + H.append("
 
") + H.append( + '


' + ) + H.append("
") + + H.append(form_decision_manuelle(Se, formsemestre_id, etudid)) + + H.append( + f"""""" + ) + + H.append('

Formation ') + if Se.sem["gestion_semestrielle"]: + H.append("avec semestres décalés

") + else: + H.append("sans semestres décalés

") + + return "".join(H + footer) + + +def formsemestre_validation_etud( + formsemestre_id=None, # required + etudid=None, # required + codechoice=None, # required + desturl="", + sortcol=None, +): + """Enregistre validation""" + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) + # retrouve la decision correspondant au code: + choices = Se.get_possible_choices(assiduite=True) + choices += Se.get_possible_choices(assiduite=False) + selected_choice = None + for choice in choices: + if choice.codechoice == codechoice: + selected_choice = choice + break + if not selected_choice: + raise ValueError("code choix invalide ! (%s)" % codechoice) + # + Se.valide_decision(selected_choice) # enregistre + return _redirect_valid_choice( + formsemestre_id, etudid, Se, selected_choice, desturl, sortcol + ) + + +def formsemestre_validation_etud_manu( + formsemestre_id=None, # required + etudid=None, # required + code_etat="", + new_code_prev="", + devenir="", # required (la decision manuelle) + assidu=False, + desturl="", + sortcol=None, + redirect=True, +): + """Enregistre validation""" + if assidu: + assidu = True + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) + if code_etat in Se.parcours.UNUSED_CODES: + raise ScoValueError("code decision invalide dans ce parcours") + # Si code ADC, extrait le semestre utilisé: + if code_etat[:3] == ADC: + formsemestre_id_utilise_pour_compenser = code_etat.split("_")[1] + if not formsemestre_id_utilise_pour_compenser: + formsemestre_id_utilise_pour_compenser = ( + None # compense avec semestre hors ScoDoc + ) + code_etat = ADC + else: + formsemestre_id_utilise_pour_compenser = None + + # Construit le choix correspondant: + choice = sco_cursus_dut.DecisionSem( + code_etat=code_etat, + new_code_prev=new_code_prev, + devenir=devenir, + assiduite=assidu, + formsemestre_id_utilise_pour_compenser=formsemestre_id_utilise_pour_compenser, + ) + # + Se.valide_decision(choice) # enregistre + if redirect: + return _redirect_valid_choice( + formsemestre_id, etudid, Se, choice, desturl, sortcol + ) + + +def _redirect_valid_choice(formsemestre_id, etudid, Se, choice, desturl, sortcol): + adr = "formsemestre_validation_etud_form?formsemestre_id=%s&etudid=%s&check=1" % ( + formsemestre_id, + etudid, + ) + if sortcol: + adr += "&sortcol=" + str(sortcol) + # if desturl: + # desturl += "&desturl=" + desturl + return flask.redirect(adr) + # Si le precedent a été modifié, demande relecture du parcours. + # sinon renvoie au listing general, + + +def _dispcode(c): + if not c: + return "" + return c + + +def decisions_possible_rows(Se, assiduite, subtitle="", trclass=""): + "Liste HTML des decisions possibles" + choices = Se.get_possible_choices(assiduite=assiduite) + if not choices: + return "" + TitlePrev = "" + if Se.prev: + if Se.prev["semestre_id"] >= 0: + TitlePrev = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.prev["semestre_id"]) + else: + TitlePrev = "Prec." + + if Se.sem["semestre_id"] >= 0: + TitleCur = "%s%d" % (Se.parcours.SESSION_ABBRV, Se.sem["semestre_id"]) + else: + TitleCur = Se.parcours.SESSION_NAME + + H = [ + '%s' + % (trclass, subtitle) + ] + if Se.prev: + H.append("Code %s" % TitlePrev) + H.append("Code %sDevenir" % TitleCur) + for ch in choices: + H.append( + """""" + % (trclass, ch.rule_id, ch.codechoice) + ) + H.append("%s " % ch.explication) + if Se.prev: + H.append('%s' % _dispcode(ch.new_code_prev)) + H.append( + '%s%s' + % (_dispcode(ch.code_etat), Se.explique_devenir(ch.devenir)) + ) + H.append("") + + return "\n".join(H) + + +def formsemestre_recap_parcours_table( + Se, + etudid, + with_links=False, + with_all_columns=True, + a_url="", + sem_info=None, + show_details=False, +): + """Tableau HTML recap parcours + Si with_links, ajoute liens pour modifier decisions (colonne de droite) + sem_info = { formsemestre_id : txt } permet d'ajouter des informations associées à chaque semestre + with_all_columns: si faux, pas de colonne "assiduité". + """ + sem_info = sem_info or {} + H = [] + linktmpl = '%s' + minuslink = linktmpl % scu.icontag("minus_img", border="0", alt="-") + pluslink = linktmpl % scu.icontag("plus_img", border="0", alt="+") + if show_details: + sd = " recap_show_details" + plusminus = minuslink + else: + sd = " recap_hide_details" + plusminus = pluslink + H.append('' % sd) + H.append( + '' + % scu.icontag("plus18_img", width=18, height=18, border=0, title="", alt="+") + ) + H.append("") + # titres des UE + H.append("" * Se.nb_max_ue) + # + if with_links: + H.append("") + H.append("") + num_sem = 0 + + for sem in Se.get_semestres(): + is_prev = Se.prev and (Se.prev["formsemestre_id"] == sem["formsemestre_id"]) + is_cur = Se.formsemestre_id == sem["formsemestre_id"] + num_sem += 1 + + dpv = sco_pvjury.dict_pvjury(sem["formsemestre_id"], etudids=[etudid]) + pv = dpv["decisions"][0] + decision_sem = pv["decision_sem"] + decisions_ue = pv["decisions_ue"] + if with_all_columns and decision_sem and not decision_sem["assidu"]: + ass = " (non ass.)" + else: + ass = "" + + formsemestre = FormSemestre.query.get(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + if is_cur: + type_sem = "*" # now unused + class_sem = "sem_courant" + elif is_prev: + type_sem = "p" + class_sem = "sem_precedent" + else: + type_sem = "" + class_sem = "sem_autre" + if sem["formation_code"] != Se.formation.formation_code: + class_sem += " sem_autre_formation" + if sem["bul_bgcolor"]: + bgcolor = sem["bul_bgcolor"] + else: + bgcolor = "background-color: rgb(255,255,240)" + # 1ere ligne: titre sem, decision, acronymes UE + H.append('' % (class_sem, sem["formsemestre_id"])) + if is_cur: + pm = "" + elif is_prev: + pm = minuslink % sem["formsemestre_id"] + else: + pm = plusminus % sem["formsemestre_id"] + + inscr = formsemestre.etuds_inscriptions.get(etudid) + parcours_name = ( + f' {inscr.parcour.code}' + if (inscr and inscr.parcour) + else "" + ) + H.append( + f""" + + + + """ + ) + if nt.is_apc: + H.append('') + elif decision_sem: + H.append('' % decision_sem["code"]) + else: + H.append("") + H.append('' % ass) # abs + # acronymes UEs auxquelles l'étudiant est inscrit (ou capitalisé) + ues = nt.get_ues_stat_dict(filter_sport=True) + cnx = ndb.GetDBConnexion() + etud_ue_status = { + ue["ue_id"]: nt.get_etud_ue_status(etudid, ue["ue_id"]) for ue in ues + } + if not nt.is_apc: + # formations classiques: filtre UE sur inscriptions (et garde UE capitalisées) + ues = [ + ue + for ue in ues + if etud_est_inscrit_ue(cnx, etudid, sem["formsemestre_id"], ue["ue_id"]) + or etud_ue_status[ue["ue_id"]]["is_capitalized"] + ] + + for ue in ues: + H.append('' % ue["acronyme"]) + if len(ues) < Se.nb_max_ue: + H.append('' % (Se.nb_max_ue - len(ues))) + # indique le semestre compensé par celui ci: + if decision_sem and decision_sem["compense_formsemestre_id"]: + csem = sco_formsemestre.get_formsemestre( + decision_sem["compense_formsemestre_id"] + ) + H.append("" % csem["semestre_id"]) + else: + H.append("") + if with_links: + H.append("") + H.append("") + # 2eme ligne: notes + H.append('' % (class_sem, sem["formsemestre_id"])) + H.append( + '' + % (bgcolor) + ) + if is_prev: + default_sem_info = '[sem. précédent]' + else: + default_sem_info = "" + if not sem["etat"]: # locked + lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") + default_sem_info += lockicon + if sem["formation_code"] != Se.formation.formation_code: + default_sem_info += "Autre formation: %s" % sem["formation_code"] + H.append( + '' + % (sem["mois_fin"], sem_info.get(sem["formsemestre_id"], default_sem_info)) + ) + # Moy Gen (sous le code decision) + H.append( + '' % scu.fmt_note(nt.get_etud_moy_gen(etudid)) + ) + # Absences (nb d'abs non just. dans ce semestre) + nbabs, nbabsjust = sco_abs.get_abs_count(etudid, sem) + H.append('' % (nbabs - nbabsjust)) + + # UEs + for ue in ues: + if decisions_ue and ue["ue_id"] in decisions_ue: + code = decisions_ue[ue["ue_id"]]["code"] + else: + code = "" + ue_status = etud_ue_status[ue["ue_id"]] + moy_ue = ue_status["moy"] if ue_status else "" + explanation_ue = [] # list of strings + if code == ADM: + class_ue = "ue_adm" + elif code == CMP: + class_ue = "ue_cmp" + else: + class_ue = "ue" + if ue_status and ue_status["is_external"]: # validation externe + explanation_ue.append("UE externe.") + + if ue_status and ue_status["is_capitalized"]: + class_ue += " ue_capitalized" + explanation_ue.append( + "Capitalisée le %s." % (ue_status["event_date"] or "?") + ) + + H.append( + '' + % (class_ue, " ".join(explanation_ue), scu.fmt_note(moy_ue)) + ) + if len(ues) < Se.nb_max_ue: + H.append('' % (Se.nb_max_ue - len(ues))) + + H.append("") + if with_links: + H.append( + '' + % (a_url, sem["formsemestre_id"], etudid) + ) + + H.append("") + # 3eme ligne: ECTS + if ( + sco_preferences.get_preference("bul_show_ects", sem["formsemestre_id"]) + or nt.parcours.ECTS_ONLY + ): + etud_ects_infos = nt.get_etud_ects_pot(etudid) # ECTS potentiels + H.append( + f""" + + """ + ) + # Total ECTS (affiché sous la moyenne générale) + H.append( + f""" + + + """ + ) + # ECTS validables dans chaque UE + for ue in ues: + ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) + if ue_status: + ects = ue_status["ects"] + ects_pot = ue_status["ects_pot"] + H.append( + f"""""" + ) + else: + H.append(f"""""") + H.append("") + + H.append("
%sSemestreEtatAbs
{num_sem}{pm}{sem['mois_debut']}{formsemestre.titre_annee()}{parcours_name}BUT%sen cours%s%scompense S%s
 %s%s%s%d%smodifier
 ECTS:{pv.get("sum_ects",0):2.2g} / {etud_ects_infos["ects_total"]:2.2g}{ects:2.2g}
") + return "\n".join(H) + + +def form_decision_manuelle(Se, formsemestre_id, etudid, desturl="", sortcol=None): + """Formulaire pour saisie décision manuelle""" + H = [ + """ + + +
+ + + """ + % (etudid, formsemestre_id) + ] + if desturl: + H.append('' % desturl) + if sortcol: + H.append('' % sortcol) + + H.append( + '

Décisions manuelles : (vérifiez bien votre choix !)

' + ) + + # Choix code semestre: + codes = list(sco_codes_parcours.CODES_JURY_SEM) + codes.sort() # fortuitement, cet ordre convient bien ! + + H.append( + '") + + # Choix code semestre precedent: + if Se.prev: + H.append( + '") + + # Choix code devenir + codes = list(sco_codes_parcours.DEVENIR_EXPL.keys()) + codes.sort() # fortuitement, cet ordre convient aussi bien ! + + if Se.sem["semestre_id"] == -1: + allowed_codes = sco_codes_parcours.DEVENIRS_MONO + else: + allowed_codes = set(sco_codes_parcours.DEVENIRS_STD) + # semestres decales ? + if Se.sem["gestion_semestrielle"]: + allowed_codes = allowed_codes.union(sco_codes_parcours.DEVENIRS_DEC) + # n'autorise les codes NEXT2 que si semestres décalés et s'il ne manque qu'un semestre avant le n+2 + if Se.can_jump_to_next2(): + allowed_codes = allowed_codes.union(sco_codes_parcours.DEVENIRS_NEXT2) + + H.append( + '") + + H.append( + '' + ) + + H.append( + """
Code semestre:
Code semestre précédent:
Devenir:
assidu
+ + Supprimer décision existante +
+ """ + % (etudid, formsemestre_id) + ) + return "\n".join(H) + + +# ----------- +def formsemestre_validation_auto(formsemestre_id): + "Formulaire saisie automatisee des decisions d'un semestre" + H = [ + html_sco_header.html_sem_header("Saisie automatique des décisions du semestre"), + f""" +
    +
  • Seuls les étudiants qui obtiennent le semestre seront affectés (code ADM, moyenne générale et + toutes les barres, semestre précédent validé);
  • +
  • le semestre précédent, s'il y en a un, doit avoir été validé;
  • +
  • les décisions du semestre précédent ne seront pas modifiées;
  • +
  • l'assiduité n'est pas prise en compte;
  • +
  • les étudiants avec des notes en attente sont ignorés.
  • +
+

Il est donc vivement conseillé de relire soigneusement les décisions à l'issue + de cette procédure !

+
+ + +

Le calcul prend quelques minutes, soyez patients !

+
+ """, + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def do_formsemestre_validation_auto(formsemestre_id): + "Saisie automatisee des decisions d'un semestre" + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + next_semestre_id = sem["semestre_id"] + 1 + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + etudids = nt.get_etudids() + nb_valid = 0 + conflicts = [] # liste des etudiants avec decision differente déjà saisie + with sco_cache.DeferredSemCacheManager(): + for etudid in etudids: + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + Se = sco_cursus.get_situation_etud_cursus(etud, formsemestre_id) + ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + {"etudid": etudid, "formsemestre_id": formsemestre_id} + )[0] + + # Conditions pour validation automatique: + if ins["etat"] == scu.INSCRIT and ( + ( + (not Se.prev) + or ( + Se.prev_decision and Se.prev_decision["code"] in (ADM, ADC, ADJ) + ) + ) + and Se.barre_moy_ok + and Se.barres_ue_ok + and not etud_has_notes_attente(etudid, formsemestre_id) + ): + # check: s'il existe une decision ou autorisation et qu'elles sont differentes, + # warning (et ne fait rien) + decision_sem = nt.get_etud_decision_sem(etudid) + ok = True + if decision_sem and decision_sem["code"] != ADM: + ok = False + conflicts.append(etud) + autorisations = ScolarAutorisationInscription.query.filter_by( + etudid=etudid, origin_formsemestre_id=formsemestre_id + ).all() + if len(autorisations) != 0: + if ( + len(autorisations) > 1 + or autorisations[0].semestre_id != next_semestre_id + ): + if ok: + conflicts.append(etud) + ok = False + + # ok, valide ! + if ok: + formsemestre_validation_etud_manu( + formsemestre_id, + etudid, + code_etat=ADM, + devenir="NEXT", + assidu=True, + redirect=False, + ) + nb_valid += 1 + log( + "do_formsemestre_validation_auto: %d validations, %d conflicts" + % (nb_valid, len(conflicts)) + ) + H = [html_sco_header.sco_header(page_title="Saisie automatique")] + H.append( + """

Saisie automatique des décisions du semestre %s

+

Opération effectuée.

+

%d étudiants validés (sur %s)

""" + % (sem["titreannee"], nb_valid, len(etudids)) + ) + if conflicts: + H.append( + f"""

Attention: {len(conflicts)} étudiants non modifiés + car décisions différentes déja saisies : +

") + H.append( + f"""continuer""" + ) + H.append(html_sco_header.sco_footer()) + return "\n".join(H) + + +def formsemestre_validation_suppress_etud(formsemestre_id, etudid): + """Suppression des décisions de jury pour un étudiant/formsemestre. + Efface toutes les décisions enregistrées concernant ce formsemestre et cet étudiant: + code semestre, UEs, autorisations d'inscription + """ + log(f"formsemestre_validation_suppress_etud( {formsemestre_id}, {etudid})") + + # Validations jury classiques (semestres, UEs, autorisations) + for v in ScolarFormSemestreValidation.query.filter_by( + etudid=etudid, formsemestre_id=formsemestre_id + ): + db.session.delete(v) + for v in ScolarAutorisationInscription.query.filter_by( + etudid=etudid, origin_formsemestre_id=formsemestre_id + ): + db.session.delete(v) + # Validations jury spécifiques BUT + for v in ApcValidationRCUE.query.filter_by( + etudid=etudid, formsemestre_id=formsemestre_id + ): + db.session.delete(v) + for v in ApcValidationAnnee.query.filter_by( + etudid=etudid, formsemestre_id=formsemestre_id + ): + db.session.delete(v) + + db.session.commit() + + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + _invalidate_etud_formation_caches( + etudid, sem["formation_id"] + ) # > suppr. decision jury (peut affecter de plusieurs semestres utilisant UE capitalisée) + + +def formsemestre_validate_previous_ue(formsemestre_id, etudid): + """Form. saisie UE validée hors ScoDoc + (pour étudiants arrivant avec un UE antérieurement validée). + """ + from app.scodoc import sco_formations + + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + Fo = sco_formations.formation_list(args={"formation_id": sem["formation_id"]})[0] + + H = [ + html_sco_header.sco_header( + page_title="Validation UE", + javascripts=["js/validate_previous_ue.js"], + ), + '
', + """

%s: validation d'une UE antérieure

""" + % etud["nomprenom"], + ( + '
%s
' + % ( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + sco_photos.etud_photo_html(etud, title="fiche de %s" % etud["nom"]), + ) + ), + """

Utiliser cette page pour enregistrer une UE validée antérieurement, + dans un semestre hors ScoDoc.

+

Les UE validées dans ScoDoc sont déjà + automatiquement prises en compte. Cette page n'est utile que pour les étudiants ayant + suivi un début de cursus dans un autre établissement, ou bien dans un semestre géré sans + ScoDoc et qui redouble ce semestre (ne pas utiliser pour les semestres précédents !). +

+

Notez que l'UE est validée, avec enregistrement immédiat de la décision et + l'attribution des ECTS.

""", + "

On ne peut prendre en compte ici que les UE du cursus %(titre)s

" + % Fo, + ] + + # Toutes les UE de cette formation sont présentées (même celles des autres semestres) + ues = sco_edit_ue.ue_list({"formation_id": Fo["formation_id"]}) + ue_names = ["Choisir..."] + ["%(acronyme)s %(titre)s" % ue for ue in ues] + ue_ids = [""] + [ue["ue_id"] for ue in ues] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("etudid", {"input_type": "hidden"}), + ("formsemestre_id", {"input_type": "hidden"}), + ( + "ue_id", + { + "input_type": "menu", + "title": "Unité d'Enseignement (UE)", + "allow_null": False, + "allowed_values": ue_ids, + "labels": ue_names, + }, + ), + ( + "semestre_id", + { + "input_type": "menu", + "title": "Indice du semestre", + "explanation": "Facultatif: indice du semestre dans la formation", + "allow_null": True, + "allowed_values": [""] + [x for x in range(11)], + "labels": ["-"] + list(range(11)), + }, + ), + ( + "date", + { + "input_type": "date", + "size": 9, + "explanation": "j/m/a", + "default": time.strftime("%d/%m/%Y"), + }, + ), + ( + "moy_ue", + { + "type": "float", + "allow_null": False, + "min_value": 0, + "max_value": 20, + "title": "Moyenne (/20) obtenue dans cette UE:", + }, + ), + ), + cancelbutton="Annuler", + submitlabel="Enregistrer validation d'UE", + ) + if tf[0] == 0: + X = """ +
+
+ """ + warn, ue_multiples = check_formation_ues(Fo["formation_id"]) + return "\n".join(H) + tf[1] + X + warn + html_sco_header.sco_footer() + elif tf[0] == -1: + return flask.redirect( + scu.NotesURL() + + "/formsemestre_status?formsemestre_id=" + + str(formsemestre_id) + ) + else: + if tf[2]["semestre_id"]: + semestre_id = int(tf[2]["semestre_id"]) + else: + semestre_id = None + do_formsemestre_validate_previous_ue( + formsemestre_id, + etudid, + tf[2]["ue_id"], + tf[2]["moy_ue"], + tf[2]["date"], + semestre_id=semestre_id, + ) + return flask.redirect( + scu.ScoURL() + + "/Notes/formsemestre_bulletinetud?formsemestre_id=%s&etudid=%s&head_message=Validation%%20d'UE%%20enregistree" + % (formsemestre_id, etudid) + ) + + +def do_formsemestre_validate_previous_ue( + formsemestre_id, + etudid, + ue_id, + moy_ue, + date, + code=ADM, + semestre_id=None, + ue_coefficient=None, +): + """Enregistre (ou modifie) validation d'UE (obtenue hors ScoDoc). + Si le coefficient est spécifié, modifie le coefficient de + cette UE (utile seulement pour les semestres extérieurs). + """ + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + cnx = ndb.GetDBConnexion() + if ue_coefficient != None: + sco_formsemestre.do_formsemestre_uecoef_edit_or_create( + cnx, formsemestre_id, ue_id, ue_coefficient + ) + else: + sco_formsemestre.do_formsemestre_uecoef_delete(cnx, formsemestre_id, ue_id) + sco_cursus_dut.do_formsemestre_validate_ue( + cnx, + nt, + formsemestre_id, # "importe" cette UE dans le semestre (new 3/2015) + etudid, + ue_id, + code, + moy_ue=moy_ue, + date=date, + semestre_id=semestre_id, + is_external=True, + ) + + logdb( + cnx, + method="formsemestre_validate_previous_ue", + etudid=etudid, + msg="Validation UE %s" % ue_id, + commit=False, + ) + _invalidate_etud_formation_caches(etudid, sem["formation_id"]) + cnx.commit() + + +def _invalidate_etud_formation_caches(etudid, formation_id): + "Invalide tous les semestres de cette formation où l'etudiant est inscrit..." + r = ndb.SimpleDictFetch( + """SELECT sem.id + FROM notes_formsemestre sem, notes_formsemestre_inscription i + WHERE sem.formation_id = %(formation_id)s + AND i.formsemestre_id = sem.id + AND i.etudid = %(etudid)s + """, + {"etudid": etudid, "formation_id": formation_id}, + ) + for fsid in [s["id"] for s in r]: + sco_cache.invalidate_formsemestre( + formsemestre_id=fsid + ) # > modif decision UE (inval tous semestres avec cet etudiant, ok mais conservatif) + + +def get_etud_ue_cap_html(etudid, formsemestre_id, ue_id): + """Ramene bout de HTML pour pouvoir supprimer une validation de cette UE""" + valids = ndb.SimpleDictFetch( + """SELECT SFV.* + FROM scolar_formsemestre_validation SFV + WHERE ue_id=%(ue_id)s + AND etudid=%(etudid)s""", + {"etudid": etudid, "ue_id": ue_id}, + ) + if not valids: + return "" + H = [ + '
Validations existantes pour cette UE:
    ' + ] + for valid in valids: + valid["event_date"] = ndb.DateISOtoDMY(valid["event_date"]) + if valid["moy_ue"] != None: + valid["m"] = ", moyenne %(moy_ue)g/20" % valid + else: + valid["m"] = "" + if valid["formsemestre_id"]: + sem = sco_formsemestre.get_formsemestre(valid["formsemestre_id"]) + valid["s"] = ", du semestre %s" % sem["titreannee"] + else: + valid["s"] = " enregistrée d'un parcours antérieur (hors ScoDoc)" + if valid["semestre_id"]: + valid["s"] += " (S%d)" % valid["semestre_id"] + valid["ds"] = formsemestre_id + H.append( + '
  • %(code)s%(m)s%(s)s, le %(event_date)s effacer
  • ' + % valid + ) + H.append("
") + return "\n".join(H) + + +def etud_ue_suppress_validation(etudid, formsemestre_id, ue_id): + """Suppress a validation (ue_id, etudid) and redirect to formsemestre""" + log("etud_ue_suppress_validation( %s, %s, %s)" % (etudid, formsemestre_id, ue_id)) + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + "DELETE FROM scolar_formsemestre_validation WHERE etudid=%(etudid)s and ue_id=%(ue_id)s", + {"etudid": etudid, "ue_id": ue_id}, + ) + + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + _invalidate_etud_formation_caches(etudid, sem["formation_id"]) + + return flask.redirect( + scu.NotesURL() + + "/formsemestre_validate_previous_ue?etudid=%s&formsemestre_id=%s" + % (etudid, formsemestre_id) + ) + + +def check_formation_ues(formation_id): + """Verifie que les UE d'une formation sont chacune utilisée dans un seul semestre_id + Si ce n'est pas le cas, c'est probablement (mais pas forcément) une erreur de + définition du programme: cette fonction retourne un bout de HTML + à afficher pour prévenir l'utilisateur, ou '' si tout est ok. + """ + ues = sco_edit_ue.ue_list({"formation_id": formation_id}) + ue_multiples = {} # { ue_id : [ liste des formsemestre ] } + for ue in ues: + # formsemestres utilisant cette ue ? + sems = ndb.SimpleDictFetch( + """SELECT DISTINCT sem.id AS formsemestre_id, sem.* + FROM notes_formsemestre sem, notes_modules mod, notes_moduleimpl mi + WHERE sem.formation_id = %(formation_id)s + AND mod.id = mi.module_id + AND mi.formsemestre_id = sem.id + AND mod.ue_id = %(ue_id)s + """, + {"ue_id": ue["ue_id"], "formation_id": formation_id}, + ) + semestre_ids = set([x["semestre_id"] for x in sems]) + if ( + len(semestre_ids) > 1 + ): # plusieurs semestres d'indices differents dans le cursus + ue_multiples[ue["ue_id"]] = sems + + if not ue_multiples: + return "", {} + # Genere message HTML: + H = [ + """
Attention: les UE suivantes de cette formation + sont utilisées dans des + semestres de rangs différents (eg S1 et S3).
Cela peut engendrer des problèmes pour + la capitalisation des UE. Il serait préférable d'essayer de rectifier cette situation: + soit modifier le programme de la formation (définir des UE dans chaque semestre), + soit veiller à saisir le bon indice de semestre dans le menu lors de la validation d'une + UE extérieure. +
    + """ + ] + for ue in ues: + if ue["ue_id"] in ue_multiples: + sems = [ + sco_formsemestre.get_formsemestre(x["formsemestre_id"]) + for x in ue_multiples[ue["ue_id"]] + ] + slist = ", ".join( + [ + """%(titreannee)s (semestre %(semestre_id)s)""" + % s + for s in sems + ] + ) + H.append("
  • %s : %s
  • " % (ue["acronyme"], slist)) + H.append("
") + + return "\n".join(H), ue_multiples diff --git a/app/scodoc/sco_groups.py b/app/scodoc/sco_groups.py index ffee6a7e..4da76d31 100644 --- a/app/scodoc/sco_groups.py +++ b/app/scodoc/sco_groups.py @@ -308,9 +308,9 @@ def get_group_infos(group_id, etat=None): # was _getlisteetud # add human readable description of state: nbdem = 0 for t in members: - if t["etat"] == "I": + if t["etat"] == scu.INSCRIT: t["etath"] = "" # etudiant inscrit, ne l'indique pas dans la liste HTML - elif t["etat"] == "D": + elif t["etat"] == scu.DEMISSION: events = sco_etud.scolar_events_list( cnx, args={ diff --git a/app/scodoc/sco_import_etuds.py b/app/scodoc/sco_import_etuds.py index b9d7f0bf..89338e8b 100644 --- a/app/scodoc/sco_import_etuds.py +++ b/app/scodoc/sco_import_etuds.py @@ -1,833 +1,833 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -""" Importation des étudiants à partir de fichiers CSV -""" - -import collections -import io -import os -import re -import time - -from flask import g, url_for - -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app import log -from app.models import ScolarNews, GroupDescr - -from app.scodoc.sco_excel import COLORS -from app.scodoc.sco_formsemestre_inscriptions import ( - do_formsemestre_inscription_with_modules, -) -from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_exceptions import ( - AccessDenied, - ScoFormatError, - ScoException, - ScoValueError, - ScoInvalidDateError, - ScoLockedFormError, - ScoGenError, -) - -from app.scodoc import html_sco_header -from app.scodoc import sco_cache -from app.scodoc import sco_etud -from app.scodoc import sco_groups -from app.scodoc import sco_excel -from app.scodoc import sco_groups_view -from app.scodoc import sco_preferences - -# format description (in tools/) -FORMAT_FILE = "format_import_etudiants.txt" - -# Champs modifiables via "Import données admission" -ADMISSION_MODIFIABLE_FIELDS = ( - "code_nip", - "code_ine", - "date_naissance", - "lieu_naissance", - "bac", - "specialite", - "annee_bac", - "math", - "physique", - "anglais", - "francais", - "type_admission", - "boursier_prec", - "qualite", - "rapporteur", - "score", - "commentaire", - "classement", - "apb_groupe", - "apb_classement_gr", - "nomlycee", - "villelycee", - "codepostallycee", - "codelycee", - # Adresse: - "email", - "emailperso", - "domicile", - "codepostaldomicile", - "villedomicile", - "paysdomicile", - "telephone", - "telephonemobile", - # Groupes - "groupes", -) - -# ---- - - -def sco_import_format(with_codesemestre=True): - "returns tuples (Attribut, Type, Table, AllowNulls, Description)" - r = [] - for l in open(os.path.join(scu.SCO_TOOLS_DIR, FORMAT_FILE)): - l = l.strip() - if l and l[0] != "#": - fs = l.split(";") - if len(fs) < 5: - # Bug: invalid format file (fatal) - raise ScoException( - "file %s has invalid format (expected %d fields, got %d) (%s)" - % (FORMAT_FILE, 5, len(fs), l) - ) - fieldname = ( - fs[0].strip().lower().split()[0] - ) # titre attribut: normalize, 1er mot seulement (nom du champ en BD) - typ, table, allow_nulls, description = [x.strip() for x in fs[1:5]] - aliases = [x.strip() for x in fs[5:] if x.strip()] - if fieldname not in aliases: - aliases.insert(0, fieldname) # prepend - if with_codesemestre or fs[0] != "codesemestre": - r.append((fieldname, typ, table, allow_nulls, description, aliases)) - return r - - -def sco_import_format_dict(with_codesemestre=True): - """Attribut: { 'type': , 'table', 'allow_nulls' , 'description' }""" - fmt = sco_import_format(with_codesemestre=with_codesemestre) - R = collections.OrderedDict() - for l in fmt: - R[l[0]] = { - "type": l[1], - "table": l[2], - "allow_nulls": l[3], - "description": l[4], - "aliases": l[5], - } - return R - - -def sco_import_generate_excel_sample( - fmt, - with_codesemestre=True, - only_tables=None, - with_groups=True, - exclude_cols=(), - extra_cols=(), - group_ids=(), -): - """Generates an excel document based on format fmt - (format is the result of sco_import_format()) - If not None, only_tables can specify a list of sql table names - (only columns from these tables will be generated) - If group_ids, liste les etudiants de ces groupes - """ - style = sco_excel.excel_make_style(bold=True) - style_required = sco_excel.excel_make_style(bold=True, color=COLORS.RED) - titles = [] - titles_styles = [] - for l in fmt: - name = l[0].lower() - if (not with_codesemestre) and name == "codesemestre": - continue # pas de colonne codesemestre - if only_tables is not None and l[2].lower() not in only_tables: - continue # table non demandée - if name in exclude_cols: - continue # colonne exclue - if int(l[3]): - titles_styles.append(style) - else: - titles_styles.append(style_required) - titles.append(name) - if with_groups and "groupes" not in titles: - titles.append("groupes") - titles_styles.append(style) - titles += extra_cols - titles_styles += [style] * len(extra_cols) - if group_ids: - groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) - members = groups_infos.members - log( - "sco_import_generate_excel_sample: group_ids=%s %d members" - % (group_ids, len(members)) - ) - titles = ["etudid"] + titles - titles_styles = [style] + titles_styles - # rempli table avec données actuelles - lines = [] - for i in members: - etud = sco_etud.get_etud_info(etudid=i["etudid"], filled=True)[0] - l = [] - for field in titles: - if field == "groupes": - sco_groups.etud_add_group_infos( - etud, groups_infos.formsemestre_id, sep=";" - ) - l.append(etud["partitionsgroupes"]) - else: - key = field.lower().split()[0] - l.append(etud.get(key, "")) - lines.append(l) - else: - lines = [[]] # empty content, titles only - return sco_excel.excel_simple_table( - titles=titles, titles_styles=titles_styles, sheet_name="Étudiants", lines=lines - ) - - -def students_import_excel( - csvfile, - formsemestre_id=None, - check_homonyms=True, - require_ine=False, - return_html=True, -): - "import students from Excel file" - diag = scolars_import_excel_file( - csvfile, - formsemestre_id=formsemestre_id, - check_homonyms=check_homonyms, - require_ine=require_ine, - exclude_cols=["photo_filename"], - ) - if return_html: - if formsemestre_id: - dest = url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - else: - dest = url_for("notes.index_html", scodoc_dept=g.scodoc_dept) - H = [html_sco_header.sco_header(page_title="Import etudiants")] - H.append("
    ") - for d in diag: - H.append("
  • %s
  • " % d) - H.append("
") - H.append("

Import terminé !

") - H.append('

Continuer

' % dest) - return "\n".join(H) + html_sco_header.sco_footer() - - -def scolars_import_excel_file( - datafile: io.BytesIO, - formsemestre_id=None, - check_homonyms=True, - require_ine=False, - exclude_cols=(), -): - """Importe etudiants depuis fichier Excel - et les inscrit dans le semestre indiqué (et à TOUS ses modules) - """ - log("scolars_import_excel_file: formsemestre_id=%s" % formsemestre_id) - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - annee_courante = time.localtime()[0] - always_require_ine = sco_preferences.get_preference("always_require_ine") - exceldata = datafile.read() - if not exceldata: - raise ScoValueError("Ficher excel vide ou invalide") - diag, data = sco_excel.excel_bytes_to_list(exceldata) - if not data: # probably a bug - raise ScoException("scolars_import_excel_file: empty file !") - - formsemestre_to_invalidate = set() - # 1- --- check title line - titles = {} - fmt = sco_import_format() - for l in fmt: - tit = l[0].lower().split()[0] # titles in lowercase, and take 1st word - if ( - (not formsemestre_id) or (tit != "codesemestre") - ) and tit not in exclude_cols: - titles[tit] = l[1:] # title : (Type, Table, AllowNulls, Description) - - # log("titles=%s" % titles) - # remove quotes, downcase and keep only 1st word - try: - fs = [scu.stripquotes(s).lower().split()[0] for s in data[0]] - except: - raise ScoValueError("Titres de colonnes invalides (ou vides ?)") - # log("excel: fs='%s'\ndata=%s" % (str(fs), str(data))) - - # check columns titles - if len(fs) != len(titles): - missing = {}.fromkeys(list(titles.keys())) - unknown = [] - for f in fs: - if f in missing: - del missing[f] - else: - unknown.append(f) - raise ScoValueError( - """Nombre de colonnes incorrect (devrait être %d, et non %d)
- (colonnes manquantes: %s, colonnes invalides: %s)""" - % (len(titles), len(fs), list(missing.keys()), unknown) - ) - titleslist = [] - for t in fs: - if t not in titles: - raise ScoValueError('Colonne invalide: "%s"' % t) - titleslist.append(t) # - # ok, same titles - # Start inserting data, abort whole transaction in case of error - created_etudids = [] - np_imported_homonyms = 0 - GroupIdInferers = {} - try: # --- begin DB transaction - linenum = 0 - for line in data[1:]: - linenum += 1 - # Read fields, check and convert type - values = {} - fs = line - # remove quotes - for i in range(len(fs)): - if fs[i] and ( - (fs[i][0] == '"' and fs[i][-1] == '"') - or (fs[i][0] == "'" and fs[i][-1] == "'") - ): - fs[i] = fs[i][1:-1] - for i in range(len(fs)): - val = fs[i].strip() - typ, table, an, descr, aliases = tuple(titles[titleslist[i]]) - # log('field %s: %s %s %s %s'%(titleslist[i], table, typ, an, descr)) - if not val and not an: - raise ScoValueError( - "line %d: null value not allowed in column %s" - % (linenum, titleslist[i]) - ) - if val == "": - val = None - else: - if typ == "real": - val = val.replace(",", ".") # si virgule a la française - try: - val = float(val) - except: - raise ScoValueError( - "valeur nombre reel invalide (%s) sur line %d, colonne %s" - % (val, linenum, titleslist[i]) - ) - elif typ == "integer": - try: - # on doit accepter des valeurs comme "2006.0" - val = val.replace(",", ".") # si virgule a la française - val = float(val) - if val % 1.0 > 1e-4: - raise ValueError() - val = int(val) - except: - raise ScoValueError( - "valeur nombre entier invalide (%s) sur ligne %d, colonne %s" - % (val, linenum, titleslist[i]) - ) - # xxx Ad-hoc checks (should be in format description) - if titleslist[i].lower() == "sexe": - try: - val = sco_etud.input_civilite(val) - except: - raise ScoValueError( - "valeur invalide pour 'SEXE' (doit etre 'M', 'F', ou 'MME', 'H', 'X' ou vide, mais pas '%s') ligne %d, colonne %s" - % (val, linenum, titleslist[i]) - ) - # Excel date conversion: - if titleslist[i].lower() == "date_naissance": - if val: - try: - val = sco_excel.xldate_as_datetime(val) - except ValueError as exc: - raise ScoValueError( - f"date invalide ({val}) sur ligne {linenum}, colonne {titleslist[i]}" - ) from exc - # INE - if ( - titleslist[i].lower() == "code_ine" - and always_require_ine - and not val - ): - raise ScoValueError( - "Code INE manquant sur ligne %d, colonne %s" - % (linenum, titleslist[i]) - ) - - # -- - values[titleslist[i]] = val - skip = False - is_new_ine = values["code_ine"] and _is_new_ine(cnx, values["code_ine"]) - if require_ine and not is_new_ine: - log("skipping %s (code_ine=%s)" % (values["nom"], values["code_ine"])) - skip = True - - if not skip: - if values["code_ine"] and not is_new_ine: - raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"]) - # Check nom/prenom - ok = False - if "nom" in values and "prenom" in values: - ok, nb_homonyms = sco_etud.check_nom_prenom( - cnx, nom=values["nom"], prenom=values["prenom"] - ) - if not ok: - raise ScoValueError( - "nom ou prénom invalide sur la ligne %d" % (linenum) - ) - if nb_homonyms: - np_imported_homonyms += 1 - # Insert in DB tables - formsemestre_id_etud = _import_one_student( - cnx, - formsemestre_id, - values, - GroupIdInferers, - annee_courante, - created_etudids, - linenum, - ) - - # Verification proportion d'homonymes: si > 10%, abandonne - log("scolars_import_excel_file: detected %d homonyms" % np_imported_homonyms) - if check_homonyms and np_imported_homonyms > len(created_etudids) / 10: - log("scolars_import_excel_file: too many homonyms") - raise ScoValueError( - "Il y a trop d'homonymes (%d étudiants)" % np_imported_homonyms - ) - except: - cnx.rollback() - log("scolars_import_excel_file: aborting transaction !") - # Nota: db transaction is sometimes partly commited... - # here we try to remove all created students - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - for etudid in created_etudids: - log("scolars_import_excel_file: deleting etudid=%s" % etudid) - cursor.execute( - "delete from notes_moduleimpl_inscription where etudid=%(etudid)s", - {"etudid": etudid}, - ) - cursor.execute( - "delete from notes_formsemestre_inscription where etudid=%(etudid)s", - {"etudid": etudid}, - ) - cursor.execute( - "delete from scolar_events where etudid=%(etudid)s", {"etudid": etudid} - ) - cursor.execute( - "delete from adresse where etudid=%(etudid)s", {"etudid": etudid} - ) - cursor.execute( - "delete from admissions where etudid=%(etudid)s", {"etudid": etudid} - ) - cursor.execute( - "delete from group_membership where etudid=%(etudid)s", - {"etudid": etudid}, - ) - cursor.execute( - "delete from identite where id=%(etudid)s", {"etudid": etudid} - ) - cnx.commit() - log("scolars_import_excel_file: re-raising exception") - raise - - diag.append("Import et inscription de %s étudiants" % len(created_etudids)) - - ScolarNews.add( - typ=ScolarNews.NEWS_INSCR, - text="Inscription de %d étudiants" # peuvent avoir ete inscrits a des semestres differents - % len(created_etudids), - obj=formsemestre_id, - ) - - log("scolars_import_excel_file: completing transaction") - cnx.commit() - - # Invalide les caches des semestres dans lesquels on a inscrit des etudiants: - for formsemestre_id in formsemestre_to_invalidate: - sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) - - return diag - - -def students_import_admission( - csvfile, type_admission="", formsemestre_id=None, return_html=True -): - "import donnees admission from Excel file (v2016)" - diag = scolars_import_admission( - csvfile, - formsemestre_id=formsemestre_id, - type_admission=type_admission, - ) - if return_html: - H = [html_sco_header.sco_header(page_title="Import données admissions")] - H.append("

Import terminé !

") - H.append( - '

Continuer

' - % url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - if diag: - H.append("

Diagnostic:

  • %s

" % "
  • ".join(diag)) - - return "\n".join(H) + html_sco_header.sco_footer() - - -def _import_one_student( - cnx, - formsemestre_id, - values, - GroupIdInferers, - annee_courante, - created_etudids, - linenum, -) -> int: - """ - Import d'un étudiant et inscription dans le semestre. - Return: id du semestre dans lequel il a été inscrit. - """ - log( - "scolars_import_excel_file: formsemestre_id=%s values=%s" - % (formsemestre_id, str(values)) - ) - # Identite - args = values.copy() - etudid = sco_etud.identite_create(cnx, args) - created_etudids.append(etudid) - # Admissions - args["etudid"] = etudid - args["annee"] = annee_courante - _ = sco_etud.admission_create(cnx, args) - # Adresse - args["typeadresse"] = "domicile" - args["description"] = "(infos admission)" - _ = sco_etud.adresse_create(cnx, args) - # Inscription au semestre - args["etat"] = "I" # etat insc. semestre - if formsemestre_id: - args["formsemestre_id"] = formsemestre_id - else: - args["formsemestre_id"] = values["codesemestre"] - formsemestre_id = values["codesemestre"] - try: - formsemestre_id = int(formsemestre_id) - except (ValueError, TypeError) as exc: - raise ScoValueError( - f"valeur invalide ou manquante dans la colonne codesemestre, ligne {linenum+1}" - ) from exc - # recupere liste des groupes: - if formsemestre_id not in GroupIdInferers: - GroupIdInferers[formsemestre_id] = sco_groups.GroupIdInferer(formsemestre_id) - gi = GroupIdInferers[formsemestre_id] - if args["groupes"]: - groupes = args["groupes"].split(";") - else: - groupes = [] - group_ids = [gi[group_name] for group_name in groupes] - group_ids = list({}.fromkeys(group_ids).keys()) # uniq - if None in group_ids: - raise ScoValueError( - "groupe invalide sur la ligne %d (groupe %s)" % (linenum, groupes) - ) - - do_formsemestre_inscription_with_modules( - int(args["formsemestre_id"]), - etudid, - group_ids, - etat="I", - method="import_csv_file", - ) - return args["formsemestre_id"] - - -def _is_new_ine(cnx, code_ine): - "True if this code is not in DB" - etuds = sco_etud.identite_list(cnx, {"code_ine": code_ine}) - return not etuds - - -# ------ Fonction ré-écrite en nov 2016 pour lire des fichiers sans etudid (fichiers APB) -def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None): - """Importe données admission depuis un fichier Excel quelconque - par exemple ceux utilisés avec APB - - Cherche dans ce fichier les étudiants qui correspondent à des inscrits du - semestre formsemestre_id. - Le fichier n'a pas l'INE ni le NIP ni l'etudid, la correspondance se fait - via les noms/prénoms qui doivent être égaux (la casse, les accents et caractères spéciaux - étant ignorés). - - On tolère plusieurs variantes pour chaque nom de colonne (ici aussi, la casse, les espaces - et les caractères spéciaux sont ignorés. Ainsi, la colonne "Prénom:" sera considéré comme "prenom". - - Le parametre type_admission remplace les valeurs vides (dans la base ET dans le fichier importé) du champ type_admission. - Si une valeur existe ou est présente dans le fichier importé, ce paramètre est ignoré. - - TODO: - - choix onglet du classeur - """ - - log("scolars_import_admission: formsemestre_id=%s" % formsemestre_id) - members = sco_groups.get_group_members( - sco_groups.get_default_group(formsemestre_id) - ) - etuds_by_nomprenom = {} # { nomprenom : etud } - diag = [] - for m in members: - np = (adm_normalize_string(m["nom"]), adm_normalize_string(m["prenom"])) - if np in etuds_by_nomprenom: - msg = "Attention: hononymie pour %s %s" % (m["nom"], m["prenom"]) - log(msg) - diag.append(msg) - etuds_by_nomprenom[np] = m - - exceldata = datafile.read() - diag2, data = sco_excel.excel_bytes_to_list(exceldata) - if not data: - raise ScoException("scolars_import_admission: empty file !") - diag += diag2 - cnx = ndb.GetDBConnexion() - - titles = data[0] - # idx -> ('field', convertor) - fields = adm_get_fields(titles, formsemestre_id) - idx_nom = None - idx_prenom = None - for idx in fields: - if fields[idx][0] == "nom": - idx_nom = idx - if fields[idx][0] == "prenom": - idx_prenom = idx - if (idx_nom is None) or (idx_prenom is None): - log("fields indices=" + ", ".join([str(x) for x in fields])) - log("fields titles =" + ", ".join([fields[x][0] for x in fields])) - raise ScoFormatError( - "scolars_import_admission: colonnes nom et prenom requises", - dest_url=url_for( - "scolar.form_students_import_infos_admissions", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ), - ) - - modifiable_fields = set(ADMISSION_MODIFIABLE_FIELDS) - - nline = 2 # la premiere ligne de donnees du fichier excel est 2 - n_import = 0 - for line in data[1:]: - # Retrouve l'étudiant parmi ceux du semestre par (nom, prenom) - nom = adm_normalize_string(line[idx_nom]) - prenom = adm_normalize_string(line[idx_prenom]) - if not (nom, prenom) in etuds_by_nomprenom: - log( - "unable to find %s %s among members" % (line[idx_nom], line[idx_prenom]) - ) - else: - etud = etuds_by_nomprenom[(nom, prenom)] - cur_adm = sco_etud.admission_list(cnx, args={"etudid": etud["etudid"]})[0] - # peuple les champs presents dans le tableau - args = {} - for idx in fields: - field_name, convertor = fields[idx] - if field_name in modifiable_fields: - try: - val = convertor(line[idx]) - except ValueError: - raise ScoFormatError( - 'scolars_import_admission: valeur invalide, ligne %d colonne %s: "%s"' - % (nline, field_name, line[idx]), - dest_url=url_for( - "scolar.form_students_import_infos_admissions", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ), - ) - if val is not None: # note: ne peut jamais supprimer une valeur - args[field_name] = val - if args: - args["etudid"] = etud["etudid"] - args["adm_id"] = cur_adm["adm_id"] - # Type admission: traitement particulier - if not cur_adm["type_admission"] and not args.get("type_admission"): - args["type_admission"] = type_admission - sco_etud.etudident_edit(cnx, args, disable_notify=True) - adr = sco_etud.adresse_list(cnx, args={"etudid": etud["etudid"]}) - if adr: - args["adresse_id"] = adr[0]["adresse_id"] - sco_etud.adresse_edit( - cnx, args, disable_notify=True - ) # pas de notification ici - else: - args["typeadresse"] = "domicile" - args["description"] = "(infos admission)" - adresse_id = sco_etud.adresse_create(cnx, args) - # log('import_adm: %s' % args ) - # Change les groupes si nécessaire: - if "groupes" in args: - gi = sco_groups.GroupIdInferer(formsemestre_id) - groupes = args["groupes"].split(";") - group_ids = [gi[group_name] for group_name in groupes] - group_ids = list({}.fromkeys(group_ids).keys()) # uniq - if None in group_ids: - raise ScoValueError( - f"groupe invalide sur la ligne {nline} (groupes {groupes})" - ) - - for group_id in group_ids: - group = GroupDescr.query.get(group_id) - if group.partition.groups_editable: - sco_groups.change_etud_group_in_partition( - args["etudid"], group_id - ) - else: - log("scolars_import_admission: partition non editable") - diag.append( - f"Attention: partition {group.partition} non editable (ignorée)" - ) - - # - diag.append("import de %s" % (etud["nomprenom"])) - n_import += 1 - nline += 1 - diag.append("%d lignes importées" % n_import) - if n_import > 0: - sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) - return diag - - -_ADM_PATTERN = re.compile(r"[\W]+", re.UNICODE) # supprime tout sauf alphanum - - -def adm_normalize_string(s): - "normalize unicode title" - return scu.suppress_accents(_ADM_PATTERN.sub("", s.strip().lower())).replace( - "_", "" - ) - - -def adm_get_fields(titles, formsemestre_id): - """Cherche les colonnes importables dans les titres (ligne 1) du fichier excel - return: { idx : (field_name, convertor) } - """ - format_dict = sco_import_format_dict() - fields = {} - idx = 0 - for title in titles: - title_n = adm_normalize_string(title) - for k in format_dict: - for v in format_dict[k]["aliases"]: - if adm_normalize_string(v) == title_n: - typ = format_dict[k]["type"] - if typ == "real": - convertor = adm_convert_real - elif typ == "integer" or typ == "int": - convertor = adm_convert_int - else: - convertor = adm_convert_text - # doublons ? - if k in [x[0] for x in fields.values()]: - raise ScoFormatError( - f"""scolars_import_admission: titre "{title}" en double (ligne 1)""", - dest_url=url_for( - "scolar.form_students_import_infos_admissions", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ), - ) - fields[idx] = (k, convertor) - idx += 1 - - return fields - - -def adm_convert_text(v): - if isinstance(v, float): - return "{:g}".format(v) # evite "1.0" - return v - - -def adm_convert_int(v): - if type(v) != int and not v: - return None - return int(float(v)) # accept "10.0" - - -def adm_convert_real(v): - if type(v) != float and not v: - return None - return float(v) - - -def adm_table_description_format(): - """Table HTML (ou autre format) decrivant les donnees d'admissions importables""" - Fmt = sco_import_format_dict(with_codesemestre=False) - for k in Fmt: - Fmt[k]["attribute"] = k - Fmt[k]["aliases_str"] = ", ".join(Fmt[k]["aliases"]) - if not Fmt[k]["allow_nulls"]: - Fmt[k]["required"] = "*" - if k in ADMISSION_MODIFIABLE_FIELDS: - Fmt[k]["writable"] = "oui" - else: - Fmt[k]["writable"] = "non" - titles = { - "attribute": "Attribut", - "type": "Type", - "required": "Requis", - "writable": "Modifiable", - "description": "Description", - "aliases_str": "Titres (variantes)", - } - columns_ids = ("attribute", "type", "writable", "description", "aliases_str") - - tab = GenTable( - titles=titles, - columns_ids=columns_ids, - rows=list(Fmt.values()), - html_sortable=True, - html_class="table_leftalign", - preferences=sco_preferences.SemPreferences(), - ) - return tab +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" Importation des étudiants à partir de fichiers CSV +""" + +import collections +import io +import os +import re +import time + +from flask import g, url_for + +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app import log +from app.models import ScolarNews, GroupDescr + +from app.scodoc.sco_excel import COLORS +from app.scodoc.sco_formsemestre_inscriptions import ( + do_formsemestre_inscription_with_modules, +) +from app.scodoc.gen_tables import GenTable +from app.scodoc.sco_exceptions import ( + AccessDenied, + ScoFormatError, + ScoException, + ScoValueError, + ScoInvalidDateError, + ScoLockedFormError, + ScoGenError, +) + +from app.scodoc import html_sco_header +from app.scodoc import sco_cache +from app.scodoc import sco_etud +from app.scodoc import sco_groups +from app.scodoc import sco_excel +from app.scodoc import sco_groups_view +from app.scodoc import sco_preferences + +# format description (in tools/) +FORMAT_FILE = "format_import_etudiants.txt" + +# Champs modifiables via "Import données admission" +ADMISSION_MODIFIABLE_FIELDS = ( + "code_nip", + "code_ine", + "date_naissance", + "lieu_naissance", + "bac", + "specialite", + "annee_bac", + "math", + "physique", + "anglais", + "francais", + "type_admission", + "boursier_prec", + "qualite", + "rapporteur", + "score", + "commentaire", + "classement", + "apb_groupe", + "apb_classement_gr", + "nomlycee", + "villelycee", + "codepostallycee", + "codelycee", + # Adresse: + "email", + "emailperso", + "domicile", + "codepostaldomicile", + "villedomicile", + "paysdomicile", + "telephone", + "telephonemobile", + # Groupes + "groupes", +) + +# ---- + + +def sco_import_format(with_codesemestre=True): + "returns tuples (Attribut, Type, Table, AllowNulls, Description)" + r = [] + for l in open(os.path.join(scu.SCO_TOOLS_DIR, FORMAT_FILE)): + l = l.strip() + if l and l[0] != "#": + fs = l.split(";") + if len(fs) < 5: + # Bug: invalid format file (fatal) + raise ScoException( + "file %s has invalid format (expected %d fields, got %d) (%s)" + % (FORMAT_FILE, 5, len(fs), l) + ) + fieldname = ( + fs[0].strip().lower().split()[0] + ) # titre attribut: normalize, 1er mot seulement (nom du champ en BD) + typ, table, allow_nulls, description = [x.strip() for x in fs[1:5]] + aliases = [x.strip() for x in fs[5:] if x.strip()] + if fieldname not in aliases: + aliases.insert(0, fieldname) # prepend + if with_codesemestre or fs[0] != "codesemestre": + r.append((fieldname, typ, table, allow_nulls, description, aliases)) + return r + + +def sco_import_format_dict(with_codesemestre=True): + """Attribut: { 'type': , 'table', 'allow_nulls' , 'description' }""" + fmt = sco_import_format(with_codesemestre=with_codesemestre) + R = collections.OrderedDict() + for l in fmt: + R[l[0]] = { + "type": l[1], + "table": l[2], + "allow_nulls": l[3], + "description": l[4], + "aliases": l[5], + } + return R + + +def sco_import_generate_excel_sample( + fmt, + with_codesemestre=True, + only_tables=None, + with_groups=True, + exclude_cols=(), + extra_cols=(), + group_ids=(), +): + """Generates an excel document based on format fmt + (format is the result of sco_import_format()) + If not None, only_tables can specify a list of sql table names + (only columns from these tables will be generated) + If group_ids, liste les etudiants de ces groupes + """ + style = sco_excel.excel_make_style(bold=True) + style_required = sco_excel.excel_make_style(bold=True, color=COLORS.RED) + titles = [] + titles_styles = [] + for l in fmt: + name = l[0].lower() + if (not with_codesemestre) and name == "codesemestre": + continue # pas de colonne codesemestre + if only_tables is not None and l[2].lower() not in only_tables: + continue # table non demandée + if name in exclude_cols: + continue # colonne exclue + if int(l[3]): + titles_styles.append(style) + else: + titles_styles.append(style_required) + titles.append(name) + if with_groups and "groupes" not in titles: + titles.append("groupes") + titles_styles.append(style) + titles += extra_cols + titles_styles += [style] * len(extra_cols) + if group_ids: + groups_infos = sco_groups_view.DisplayedGroupsInfos(group_ids) + members = groups_infos.members + log( + "sco_import_generate_excel_sample: group_ids=%s %d members" + % (group_ids, len(members)) + ) + titles = ["etudid"] + titles + titles_styles = [style] + titles_styles + # rempli table avec données actuelles + lines = [] + for i in members: + etud = sco_etud.get_etud_info(etudid=i["etudid"], filled=True)[0] + l = [] + for field in titles: + if field == "groupes": + sco_groups.etud_add_group_infos( + etud, groups_infos.formsemestre_id, sep=";" + ) + l.append(etud["partitionsgroupes"]) + else: + key = field.lower().split()[0] + l.append(etud.get(key, "")) + lines.append(l) + else: + lines = [[]] # empty content, titles only + return sco_excel.excel_simple_table( + titles=titles, titles_styles=titles_styles, sheet_name="Étudiants", lines=lines + ) + + +def students_import_excel( + csvfile, + formsemestre_id=None, + check_homonyms=True, + require_ine=False, + return_html=True, +): + "import students from Excel file" + diag = scolars_import_excel_file( + csvfile, + formsemestre_id=formsemestre_id, + check_homonyms=check_homonyms, + require_ine=require_ine, + exclude_cols=["photo_filename"], + ) + if return_html: + if formsemestre_id: + dest = url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + else: + dest = url_for("notes.index_html", scodoc_dept=g.scodoc_dept) + H = [html_sco_header.sco_header(page_title="Import etudiants")] + H.append("
      ") + for d in diag: + H.append("
    • %s
    • " % d) + H.append("
    ") + H.append("

    Import terminé !

    ") + H.append('

    Continuer

    ' % dest) + return "\n".join(H) + html_sco_header.sco_footer() + + +def scolars_import_excel_file( + datafile: io.BytesIO, + formsemestre_id=None, + check_homonyms=True, + require_ine=False, + exclude_cols=(), +): + """Importe etudiants depuis fichier Excel + et les inscrit dans le semestre indiqué (et à TOUS ses modules) + """ + log("scolars_import_excel_file: formsemestre_id=%s" % formsemestre_id) + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + annee_courante = time.localtime()[0] + always_require_ine = sco_preferences.get_preference("always_require_ine") + exceldata = datafile.read() + if not exceldata: + raise ScoValueError("Ficher excel vide ou invalide") + diag, data = sco_excel.excel_bytes_to_list(exceldata) + if not data: # probably a bug + raise ScoException("scolars_import_excel_file: empty file !") + + formsemestre_to_invalidate = set() + # 1- --- check title line + titles = {} + fmt = sco_import_format() + for l in fmt: + tit = l[0].lower().split()[0] # titles in lowercase, and take 1st word + if ( + (not formsemestre_id) or (tit != "codesemestre") + ) and tit not in exclude_cols: + titles[tit] = l[1:] # title : (Type, Table, AllowNulls, Description) + + # log("titles=%s" % titles) + # remove quotes, downcase and keep only 1st word + try: + fs = [scu.stripquotes(s).lower().split()[0] for s in data[0]] + except: + raise ScoValueError("Titres de colonnes invalides (ou vides ?)") + # log("excel: fs='%s'\ndata=%s" % (str(fs), str(data))) + + # check columns titles + if len(fs) != len(titles): + missing = {}.fromkeys(list(titles.keys())) + unknown = [] + for f in fs: + if f in missing: + del missing[f] + else: + unknown.append(f) + raise ScoValueError( + """Nombre de colonnes incorrect (devrait être %d, et non %d)
    + (colonnes manquantes: %s, colonnes invalides: %s)""" + % (len(titles), len(fs), list(missing.keys()), unknown) + ) + titleslist = [] + for t in fs: + if t not in titles: + raise ScoValueError('Colonne invalide: "%s"' % t) + titleslist.append(t) # + # ok, same titles + # Start inserting data, abort whole transaction in case of error + created_etudids = [] + np_imported_homonyms = 0 + GroupIdInferers = {} + try: # --- begin DB transaction + linenum = 0 + for line in data[1:]: + linenum += 1 + # Read fields, check and convert type + values = {} + fs = line + # remove quotes + for i in range(len(fs)): + if fs[i] and ( + (fs[i][0] == '"' and fs[i][-1] == '"') + or (fs[i][0] == "'" and fs[i][-1] == "'") + ): + fs[i] = fs[i][1:-1] + for i in range(len(fs)): + val = fs[i].strip() + typ, table, an, descr, aliases = tuple(titles[titleslist[i]]) + # log('field %s: %s %s %s %s'%(titleslist[i], table, typ, an, descr)) + if not val and not an: + raise ScoValueError( + "line %d: null value not allowed in column %s" + % (linenum, titleslist[i]) + ) + if val == "": + val = None + else: + if typ == "real": + val = val.replace(",", ".") # si virgule a la française + try: + val = float(val) + except: + raise ScoValueError( + "valeur nombre reel invalide (%s) sur line %d, colonne %s" + % (val, linenum, titleslist[i]) + ) + elif typ == "integer": + try: + # on doit accepter des valeurs comme "2006.0" + val = val.replace(",", ".") # si virgule a la française + val = float(val) + if val % 1.0 > 1e-4: + raise ValueError() + val = int(val) + except: + raise ScoValueError( + "valeur nombre entier invalide (%s) sur ligne %d, colonne %s" + % (val, linenum, titleslist[i]) + ) + # xxx Ad-hoc checks (should be in format description) + if titleslist[i].lower() == "sexe": + try: + val = sco_etud.input_civilite(val) + except: + raise ScoValueError( + "valeur invalide pour 'SEXE' (doit etre 'M', 'F', ou 'MME', 'H', 'X' ou vide, mais pas '%s') ligne %d, colonne %s" + % (val, linenum, titleslist[i]) + ) + # Excel date conversion: + if titleslist[i].lower() == "date_naissance": + if val: + try: + val = sco_excel.xldate_as_datetime(val) + except ValueError as exc: + raise ScoValueError( + f"date invalide ({val}) sur ligne {linenum}, colonne {titleslist[i]}" + ) from exc + # INE + if ( + titleslist[i].lower() == "code_ine" + and always_require_ine + and not val + ): + raise ScoValueError( + "Code INE manquant sur ligne %d, colonne %s" + % (linenum, titleslist[i]) + ) + + # -- + values[titleslist[i]] = val + skip = False + is_new_ine = values["code_ine"] and _is_new_ine(cnx, values["code_ine"]) + if require_ine and not is_new_ine: + log("skipping %s (code_ine=%s)" % (values["nom"], values["code_ine"])) + skip = True + + if not skip: + if values["code_ine"] and not is_new_ine: + raise ScoValueError("Code INE dupliqué (%s)" % values["code_ine"]) + # Check nom/prenom + ok = False + if "nom" in values and "prenom" in values: + ok, nb_homonyms = sco_etud.check_nom_prenom( + cnx, nom=values["nom"], prenom=values["prenom"] + ) + if not ok: + raise ScoValueError( + "nom ou prénom invalide sur la ligne %d" % (linenum) + ) + if nb_homonyms: + np_imported_homonyms += 1 + # Insert in DB tables + formsemestre_id_etud = _import_one_student( + cnx, + formsemestre_id, + values, + GroupIdInferers, + annee_courante, + created_etudids, + linenum, + ) + + # Verification proportion d'homonymes: si > 10%, abandonne + log("scolars_import_excel_file: detected %d homonyms" % np_imported_homonyms) + if check_homonyms and np_imported_homonyms > len(created_etudids) / 10: + log("scolars_import_excel_file: too many homonyms") + raise ScoValueError( + "Il y a trop d'homonymes (%d étudiants)" % np_imported_homonyms + ) + except: + cnx.rollback() + log("scolars_import_excel_file: aborting transaction !") + # Nota: db transaction is sometimes partly commited... + # here we try to remove all created students + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + for etudid in created_etudids: + log("scolars_import_excel_file: deleting etudid=%s" % etudid) + cursor.execute( + "delete from notes_moduleimpl_inscription where etudid=%(etudid)s", + {"etudid": etudid}, + ) + cursor.execute( + "delete from notes_formsemestre_inscription where etudid=%(etudid)s", + {"etudid": etudid}, + ) + cursor.execute( + "delete from scolar_events where etudid=%(etudid)s", {"etudid": etudid} + ) + cursor.execute( + "delete from adresse where etudid=%(etudid)s", {"etudid": etudid} + ) + cursor.execute( + "delete from admissions where etudid=%(etudid)s", {"etudid": etudid} + ) + cursor.execute( + "delete from group_membership where etudid=%(etudid)s", + {"etudid": etudid}, + ) + cursor.execute( + "delete from identite where id=%(etudid)s", {"etudid": etudid} + ) + cnx.commit() + log("scolars_import_excel_file: re-raising exception") + raise + + diag.append("Import et inscription de %s étudiants" % len(created_etudids)) + + ScolarNews.add( + typ=ScolarNews.NEWS_INSCR, + text="Inscription de %d étudiants" # peuvent avoir ete inscrits a des semestres differents + % len(created_etudids), + obj=formsemestre_id, + ) + + log("scolars_import_excel_file: completing transaction") + cnx.commit() + + # Invalide les caches des semestres dans lesquels on a inscrit des etudiants: + for formsemestre_id in formsemestre_to_invalidate: + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) + + return diag + + +def students_import_admission( + csvfile, type_admission="", formsemestre_id=None, return_html=True +): + "import donnees admission from Excel file (v2016)" + diag = scolars_import_admission( + csvfile, + formsemestre_id=formsemestre_id, + type_admission=type_admission, + ) + if return_html: + H = [html_sco_header.sco_header(page_title="Import données admissions")] + H.append("

    Import terminé !

    ") + H.append( + '

    Continuer

    ' + % url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + if diag: + H.append("

    Diagnostic:

    • %s

    " % "
  • ".join(diag)) + + return "\n".join(H) + html_sco_header.sco_footer() + + +def _import_one_student( + cnx, + formsemestre_id, + values, + GroupIdInferers, + annee_courante, + created_etudids, + linenum, +) -> int: + """ + Import d'un étudiant et inscription dans le semestre. + Return: id du semestre dans lequel il a été inscrit. + """ + log( + "scolars_import_excel_file: formsemestre_id=%s values=%s" + % (formsemestre_id, str(values)) + ) + # Identite + args = values.copy() + etudid = sco_etud.identite_create(cnx, args) + created_etudids.append(etudid) + # Admissions + args["etudid"] = etudid + args["annee"] = annee_courante + _ = sco_etud.admission_create(cnx, args) + # Adresse + args["typeadresse"] = "domicile" + args["description"] = "(infos admission)" + _ = sco_etud.adresse_create(cnx, args) + # Inscription au semestre + args["etat"] = scu.INSCRIT # etat insc. semestre + if formsemestre_id: + args["formsemestre_id"] = formsemestre_id + else: + args["formsemestre_id"] = values["codesemestre"] + formsemestre_id = values["codesemestre"] + try: + formsemestre_id = int(formsemestre_id) + except (ValueError, TypeError) as exc: + raise ScoValueError( + f"valeur invalide ou manquante dans la colonne codesemestre, ligne {linenum+1}" + ) from exc + # recupere liste des groupes: + if formsemestre_id not in GroupIdInferers: + GroupIdInferers[formsemestre_id] = sco_groups.GroupIdInferer(formsemestre_id) + gi = GroupIdInferers[formsemestre_id] + if args["groupes"]: + groupes = args["groupes"].split(";") + else: + groupes = [] + group_ids = [gi[group_name] for group_name in groupes] + group_ids = list({}.fromkeys(group_ids).keys()) # uniq + if None in group_ids: + raise ScoValueError( + "groupe invalide sur la ligne %d (groupe %s)" % (linenum, groupes) + ) + + do_formsemestre_inscription_with_modules( + int(args["formsemestre_id"]), + etudid, + group_ids, + etat=scu.INSCRIT, + method="import_csv_file", + ) + return args["formsemestre_id"] + + +def _is_new_ine(cnx, code_ine): + "True if this code is not in DB" + etuds = sco_etud.identite_list(cnx, {"code_ine": code_ine}) + return not etuds + + +# ------ Fonction ré-écrite en nov 2016 pour lire des fichiers sans etudid (fichiers APB) +def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None): + """Importe données admission depuis un fichier Excel quelconque + par exemple ceux utilisés avec APB + + Cherche dans ce fichier les étudiants qui correspondent à des inscrits du + semestre formsemestre_id. + Le fichier n'a pas l'INE ni le NIP ni l'etudid, la correspondance se fait + via les noms/prénoms qui doivent être égaux (la casse, les accents et caractères spéciaux + étant ignorés). + + On tolère plusieurs variantes pour chaque nom de colonne (ici aussi, la casse, les espaces + et les caractères spéciaux sont ignorés. Ainsi, la colonne "Prénom:" sera considéré comme "prenom". + + Le parametre type_admission remplace les valeurs vides (dans la base ET dans le fichier importé) du champ type_admission. + Si une valeur existe ou est présente dans le fichier importé, ce paramètre est ignoré. + + TODO: + - choix onglet du classeur + """ + + log("scolars_import_admission: formsemestre_id=%s" % formsemestre_id) + members = sco_groups.get_group_members( + sco_groups.get_default_group(formsemestre_id) + ) + etuds_by_nomprenom = {} # { nomprenom : etud } + diag = [] + for m in members: + np = (adm_normalize_string(m["nom"]), adm_normalize_string(m["prenom"])) + if np in etuds_by_nomprenom: + msg = "Attention: hononymie pour %s %s" % (m["nom"], m["prenom"]) + log(msg) + diag.append(msg) + etuds_by_nomprenom[np] = m + + exceldata = datafile.read() + diag2, data = sco_excel.excel_bytes_to_list(exceldata) + if not data: + raise ScoException("scolars_import_admission: empty file !") + diag += diag2 + cnx = ndb.GetDBConnexion() + + titles = data[0] + # idx -> ('field', convertor) + fields = adm_get_fields(titles, formsemestre_id) + idx_nom = None + idx_prenom = None + for idx in fields: + if fields[idx][0] == "nom": + idx_nom = idx + if fields[idx][0] == "prenom": + idx_prenom = idx + if (idx_nom is None) or (idx_prenom is None): + log("fields indices=" + ", ".join([str(x) for x in fields])) + log("fields titles =" + ", ".join([fields[x][0] for x in fields])) + raise ScoFormatError( + "scolars_import_admission: colonnes nom et prenom requises", + dest_url=url_for( + "scolar.form_students_import_infos_admissions", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ), + ) + + modifiable_fields = set(ADMISSION_MODIFIABLE_FIELDS) + + nline = 2 # la premiere ligne de donnees du fichier excel est 2 + n_import = 0 + for line in data[1:]: + # Retrouve l'étudiant parmi ceux du semestre par (nom, prenom) + nom = adm_normalize_string(line[idx_nom]) + prenom = adm_normalize_string(line[idx_prenom]) + if not (nom, prenom) in etuds_by_nomprenom: + log( + "unable to find %s %s among members" % (line[idx_nom], line[idx_prenom]) + ) + else: + etud = etuds_by_nomprenom[(nom, prenom)] + cur_adm = sco_etud.admission_list(cnx, args={"etudid": etud["etudid"]})[0] + # peuple les champs presents dans le tableau + args = {} + for idx in fields: + field_name, convertor = fields[idx] + if field_name in modifiable_fields: + try: + val = convertor(line[idx]) + except ValueError: + raise ScoFormatError( + 'scolars_import_admission: valeur invalide, ligne %d colonne %s: "%s"' + % (nline, field_name, line[idx]), + dest_url=url_for( + "scolar.form_students_import_infos_admissions", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ), + ) + if val is not None: # note: ne peut jamais supprimer une valeur + args[field_name] = val + if args: + args["etudid"] = etud["etudid"] + args["adm_id"] = cur_adm["adm_id"] + # Type admission: traitement particulier + if not cur_adm["type_admission"] and not args.get("type_admission"): + args["type_admission"] = type_admission + sco_etud.etudident_edit(cnx, args, disable_notify=True) + adr = sco_etud.adresse_list(cnx, args={"etudid": etud["etudid"]}) + if adr: + args["adresse_id"] = adr[0]["adresse_id"] + sco_etud.adresse_edit( + cnx, args, disable_notify=True + ) # pas de notification ici + else: + args["typeadresse"] = "domicile" + args["description"] = "(infos admission)" + adresse_id = sco_etud.adresse_create(cnx, args) + # log('import_adm: %s' % args ) + # Change les groupes si nécessaire: + if "groupes" in args: + gi = sco_groups.GroupIdInferer(formsemestre_id) + groupes = args["groupes"].split(";") + group_ids = [gi[group_name] for group_name in groupes] + group_ids = list({}.fromkeys(group_ids).keys()) # uniq + if None in group_ids: + raise ScoValueError( + f"groupe invalide sur la ligne {nline} (groupes {groupes})" + ) + + for group_id in group_ids: + group = GroupDescr.query.get(group_id) + if group.partition.groups_editable: + sco_groups.change_etud_group_in_partition( + args["etudid"], group_id + ) + else: + log("scolars_import_admission: partition non editable") + diag.append( + f"Attention: partition {group.partition} non editable (ignorée)" + ) + + # + diag.append("import de %s" % (etud["nomprenom"])) + n_import += 1 + nline += 1 + diag.append("%d lignes importées" % n_import) + if n_import > 0: + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) + return diag + + +_ADM_PATTERN = re.compile(r"[\W]+", re.UNICODE) # supprime tout sauf alphanum + + +def adm_normalize_string(s): + "normalize unicode title" + return scu.suppress_accents(_ADM_PATTERN.sub("", s.strip().lower())).replace( + "_", "" + ) + + +def adm_get_fields(titles, formsemestre_id): + """Cherche les colonnes importables dans les titres (ligne 1) du fichier excel + return: { idx : (field_name, convertor) } + """ + format_dict = sco_import_format_dict() + fields = {} + idx = 0 + for title in titles: + title_n = adm_normalize_string(title) + for k in format_dict: + for v in format_dict[k]["aliases"]: + if adm_normalize_string(v) == title_n: + typ = format_dict[k]["type"] + if typ == "real": + convertor = adm_convert_real + elif typ == "integer" or typ == "int": + convertor = adm_convert_int + else: + convertor = adm_convert_text + # doublons ? + if k in [x[0] for x in fields.values()]: + raise ScoFormatError( + f"""scolars_import_admission: titre "{title}" en double (ligne 1)""", + dest_url=url_for( + "scolar.form_students_import_infos_admissions", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ), + ) + fields[idx] = (k, convertor) + idx += 1 + + return fields + + +def adm_convert_text(v): + if isinstance(v, float): + return "{:g}".format(v) # evite "1.0" + return v + + +def adm_convert_int(v): + if type(v) != int and not v: + return None + return int(float(v)) # accept "10.0" + + +def adm_convert_real(v): + if type(v) != float and not v: + return None + return float(v) + + +def adm_table_description_format(): + """Table HTML (ou autre format) decrivant les donnees d'admissions importables""" + Fmt = sco_import_format_dict(with_codesemestre=False) + for k in Fmt: + Fmt[k]["attribute"] = k + Fmt[k]["aliases_str"] = ", ".join(Fmt[k]["aliases"]) + if not Fmt[k]["allow_nulls"]: + Fmt[k]["required"] = "*" + if k in ADMISSION_MODIFIABLE_FIELDS: + Fmt[k]["writable"] = "oui" + else: + Fmt[k]["writable"] = "non" + titles = { + "attribute": "Attribut", + "type": "Type", + "required": "Requis", + "writable": "Modifiable", + "description": "Description", + "aliases_str": "Titres (variantes)", + } + columns_ids = ("attribute", "type", "writable", "description", "aliases_str") + + tab = GenTable( + titles=titles, + columns_ids=columns_ids, + rows=list(Fmt.values()), + html_sortable=True, + html_class="table_leftalign", + preferences=sco_preferences.SemPreferences(), + ) + return tab diff --git a/app/scodoc/sco_inscr_passage.py b/app/scodoc/sco_inscr_passage.py index bab021a7..2d3b644f 100644 --- a/app/scodoc/sco_inscr_passage.py +++ b/app/scodoc/sco_inscr_passage.py @@ -1,670 +1,670 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Form. pour inscription rapide des etudiants d'un semestre dans un autre - Utilise les autorisations d'inscription délivrées en jury. -""" -import datetime -from operator import itemgetter - -from flask import url_for, g, request - -import app.scodoc.notesdb as ndb -import app.scodoc.sco_utils as scu -from app import log -from app.scodoc.gen_tables import GenTable -from app.scodoc import html_sco_header -from app.scodoc import sco_codes_parcours -from app.scodoc import sco_preferences -from app.scodoc import sco_pvjury -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_formations -from app.scodoc import sco_groups -from app.scodoc import sco_etud -from app.scodoc.sco_exceptions import ScoValueError - - -def list_authorized_etuds_by_sem(sem, delai=274, ignore_jury=False): - """Liste des etudiants autorisés à s'inscrire dans sem. - delai = nb de jours max entre la date de l'autorisation et celle de debut du semestre cible. - ignore_jury: si vrai, considère tous les étudiants comem autorisés, même - s'ils n'ont pas de décision de jury. - """ - src_sems = list_source_sems(sem, delai=delai) - inscrits = list_inscrits(sem["formsemestre_id"]) - r = {} - candidats = {} # etudid : etud (tous les etudiants candidats) - nb = 0 # debug - for src in src_sems: - if ignore_jury: - # liste de tous les inscrits au semestre (sans dems) - liste = list_inscrits(src["formsemestre_id"]).values() - else: - # liste des étudiants autorisés par le jury à s'inscrire ici - liste = list_etuds_from_sem(src, sem) - liste_filtree = [] - for e in liste: - # Filtre ceux qui se sont déjà inscrit dans un semestre APRES le semestre src - auth_used = False # autorisation deja utilisée ? - etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0] - for isem in etud["sems"]: - if ndb.DateDMYtoISO(isem["date_debut"]) >= ndb.DateDMYtoISO( - src["date_fin"] - ): - auth_used = True - if not auth_used: - candidats[e["etudid"]] = etud - liste_filtree.append(e) - nb += 1 - r[src["formsemestre_id"]] = { - "etuds": liste_filtree, - "infos": { - "id": src["formsemestre_id"], - "title": src["titreannee"], - "title_target": "formsemestre_status?formsemestre_id=%s" - % src["formsemestre_id"], - "filename": "etud_autorises", - }, - } - # ajoute attribut inscrit qui indique si l'étudiant est déjà inscrit dans le semestre dest. - for e in r[src["formsemestre_id"]]["etuds"]: - e["inscrit"] = e["etudid"] in inscrits - - # Ajoute liste des etudiants actuellement inscrits - for e in inscrits.values(): - e["inscrit"] = True - r[sem["formsemestre_id"]] = { - "etuds": list(inscrits.values()), - "infos": { - "id": sem["formsemestre_id"], - "title": "Semestre cible: " + sem["titreannee"], - "title_target": "formsemestre_status?formsemestre_id=%s" - % sem["formsemestre_id"], - "comment": " actuellement inscrits dans ce semestre", - "help": "Ces étudiants sont actuellement inscrits dans ce semestre. Si vous les décochez, il seront désinscrits.", - "filename": "etud_inscrits", - }, - } - - return r, inscrits, candidats - - -def list_inscrits(formsemestre_id, with_dems=False): - """Étudiants déjà inscrits à ce semestre - { etudid : etud } - """ - if not with_dems: - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( - formsemestre_id - ) # optimized - else: - args = {"formsemestre_id": formsemestre_id} - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(args=args) - inscr = {} - for i in ins: - etudid = i["etudid"] - inscr[etudid] = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - return inscr - - -def list_etuds_from_sem(src, dst) -> list[dict]: - """Liste des etudiants du semestre src qui sont autorisés à passer dans le semestre dst.""" - target = dst["semestre_id"] - dpv = sco_pvjury.dict_pvjury(src["formsemestre_id"]) - if not dpv: - return [] - etuds = [ - x["identite"] - for x in dpv["decisions"] - if target in [a["semestre_id"] for a in x["autorisations"]] - ] - return etuds - - -def list_inscrits_date(sem): - """Liste les etudiants inscrits dans n'importe quel semestre - du même département - SAUF sem à la date de début de sem. - """ - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - sem["date_debut_iso"] = ndb.DateDMYtoISO(sem["date_debut"]) - cursor.execute( - """SELECT ins.etudid - FROM - notes_formsemestre_inscription ins, - notes_formsemestre S - WHERE ins.formsemestre_id = S.id - AND S.id != %(formsemestre_id)s - AND S.date_debut <= %(date_debut_iso)s - AND S.date_fin >= %(date_debut_iso)s - AND S.dept_id = %(dept_id)s - """, - sem, - ) - return [x[0] for x in cursor.fetchall()] - - -def do_inscrit(sem, etudids, inscrit_groupes=False): - """Inscrit ces etudiants dans ce semestre - (la liste doit avoir été vérifiée au préalable) - En option: inscrit aux mêmes groupes que dans le semestre origine - """ - log("do_inscrit (inscrit_groupes=%s): %s" % (inscrit_groupes, etudids)) - for etudid in etudids: - sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( - sem["formsemestre_id"], - etudid, - etat="I", - method="formsemestre_inscr_passage", - ) - if inscrit_groupes: - # Inscription dans les mêmes groupes que ceux du semestre d'origine, - # s'ils existent. - # (mise en correspondance à partir du nom du groupe, sans tenir compte - # du nom de la partition: évidemment, cela ne marche pas si on a les - # même noms de groupes dans des partitions différentes) - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - log("cherche groupes de %(nom)s" % etud) - - # recherche le semestre origine (il serait plus propre de l'avoir conservé!) - if len(etud["sems"]) < 2: - continue - prev_formsemestre = etud["sems"][1] - sco_groups.etud_add_group_infos( - etud, - prev_formsemestre["formsemestre_id"] if prev_formsemestre else None, - ) - - cursem_groups_by_name = dict( - [ - (g["group_name"], g) - for g in sco_groups.get_sem_groups(sem["formsemestre_id"]) - if g["group_name"] - ] - ) - - # forme la liste des groupes présents dans les deux semestres: - partition_groups = [] # [ partition+group ] (ds nouveau sem.) - for partition_id in etud["partitions"]: - prev_group_name = etud["partitions"][partition_id]["group_name"] - if prev_group_name in cursem_groups_by_name: - new_group = cursem_groups_by_name[prev_group_name] - partition_groups.append(new_group) - - # inscrit aux groupes - for partition_group in partition_groups: - if partition_group["groups_editable"]: - sco_groups.change_etud_group_in_partition( - etudid, - partition_group["group_id"], - partition_group, - ) - - -def do_desinscrit(sem, etudids): - log("do_desinscrit: %s" % etudids) - for etudid in etudids: - sco_formsemestre_inscriptions.do_formsemestre_desinscription( - etudid, sem["formsemestre_id"] - ) - - -def list_source_sems(sem, delai=None) -> list[dict]: - """Liste des semestres sources - sem est le semestre destination - """ - # liste des semestres débutant a moins - # de delai (en jours) de la date de fin du semestre d'origine. - sems = sco_formsemestre.do_formsemestre_list() - othersems = [] - d, m, y = [int(x) for x in sem["date_debut"].split("/")] - date_debut_dst = datetime.date(y, m, d) - - delais = datetime.timedelta(delai) - for s in sems: - if s["formsemestre_id"] == sem["formsemestre_id"]: - continue # saute le semestre destination - if s["date_fin"]: - d, m, y = [int(x) for x in s["date_fin"].split("/")] - date_fin = datetime.date(y, m, d) - if date_debut_dst - date_fin > delais: - continue # semestre trop ancien - if date_fin > date_debut_dst: - continue # semestre trop récent - # Elimine les semestres de formations speciales (sans parcours) - if s["semestre_id"] == sco_codes_parcours.NO_SEMESTRE_ID: - continue - # - F = sco_formations.formation_list(args={"formation_id": s["formation_id"]})[0] - parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"]) - if not parcours.ALLOW_SEM_SKIP: - if s["semestre_id"] < (sem["semestre_id"] - 1): - continue - othersems.append(s) - return othersems - - -def formsemestre_inscr_passage( - formsemestre_id, - etuds=[], - inscrit_groupes=False, - submitted=False, - dialog_confirmed=False, - ignore_jury=False, -): - """Form. pour inscription des etudiants d'un semestre dans un autre - (donné par formsemestre_id). - Permet de selectionner parmi les etudiants autorisés à s'inscrire. - Principe: - - trouver liste d'etud, par semestre - - afficher chaque semestre "boites" avec cases à cocher - - si l'étudiant est déjà inscrit, le signaler (gras, nom de groupes): il peut être désinscrit - - on peut choisir les groupes TD, TP, TA - - seuls les etudiants non inscrits changent (de groupe) - - les etudiants inscrit qui se trouvent décochés sont désinscrits - - Confirmation: indiquer les étudiants inscrits et ceux désinscrits, le total courant. - - """ - inscrit_groupes = int(inscrit_groupes) - ignore_jury = int(ignore_jury) - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - # -- check lock - if not sem["etat"]: - raise ScoValueError("opération impossible: semestre verrouille") - header = html_sco_header.sco_header(page_title="Passage des étudiants") - footer = html_sco_header.sco_footer() - H = [header] - if isinstance(etuds, str): - # list de strings, vient du form de confirmation - etuds = [int(x) for x in etuds.split(",") if x] - elif isinstance(etuds, int): - etuds = [etuds] - elif etuds and isinstance(etuds[0], str): - etuds = [int(x) for x in etuds] - - auth_etuds_by_sem, inscrits, candidats = list_authorized_etuds_by_sem( - sem, ignore_jury=ignore_jury - ) - etuds_set = set(etuds) - candidats_set = set(candidats) - inscrits_set = set(inscrits) - candidats_non_inscrits = candidats_set - inscrits_set - inscrits_ailleurs = set(list_inscrits_date(sem)) - - def set_to_sorted_etud_list(etudset): - etuds = [candidats[etudid] for etudid in etudset] - etuds.sort(key=itemgetter("nom")) - return etuds - - if submitted: - a_inscrire = etuds_set.intersection(candidats_set) - inscrits_set - a_desinscrire = inscrits_set - etuds_set - else: - a_inscrire = a_desinscrire = [] - # log('formsemestre_inscr_passage: a_inscrire=%s' % str(a_inscrire) ) - # log('formsemestre_inscr_passage: a_desinscrire=%s' % str(a_desinscrire) ) - - if not submitted: - H += build_page( - sem, - auth_etuds_by_sem, - inscrits, - candidats_non_inscrits, - inscrits_ailleurs, - inscrit_groupes=inscrit_groupes, - ignore_jury=ignore_jury, - ) - else: - if not dialog_confirmed: - # Confirmation - if a_inscrire: - H.append("

    Etudiants à inscrire

      ") - for etud in set_to_sorted_etud_list(a_inscrire): - H.append("
    1. %(nomprenom)s
    2. " % etud) - H.append("
    ") - a_inscrire_en_double = inscrits_ailleurs.intersection(a_inscrire) - if a_inscrire_en_double: - H.append("

    dont étudiants déjà inscrits:

      ") - for etud in set_to_sorted_etud_list(a_inscrire_en_double): - H.append('
    • %(nomprenom)s
    • ' % etud) - H.append("
    ") - if a_desinscrire: - H.append("

    Etudiants à désinscrire

      ") - for etudid in a_desinscrire: - H.append( - '
    1. %(nomprenom)s
    2. ' - % inscrits[etudid] - ) - H.append("
    ") - todo = a_inscrire or a_desinscrire - if not todo: - H.append("""

    Il n'y a rien à modifier !

    """) - H.append( - scu.confirm_dialog( - dest_url="formsemestre_inscr_passage" - if todo - else "formsemestre_status", - message="

    Confirmer ?

    " if todo else "", - add_headers=False, - cancel_url="formsemestre_inscr_passage?formsemestre_id=" - + str(formsemestre_id), - OK="Effectuer l'opération" if todo else "", - parameters={ - "formsemestre_id": formsemestre_id, - "etuds": ",".join([str(x) for x in etuds]), - "inscrit_groupes": inscrit_groupes, - "ignore_jury": ignore_jury, - "submitted": 1, - }, - ) - ) - else: - # Inscription des étudiants au nouveau semestre: - do_inscrit( - sem, - a_inscrire, - inscrit_groupes=inscrit_groupes, - ) - - # Desincriptions: - do_desinscrit(sem, a_desinscrire) - - H.append( - """

    Opération effectuée

    -
    • Continuer les inscriptions
    • -
    • Tableau de bord du semestre
    • """ - % (formsemestre_id, formsemestre_id) - ) - partition = sco_groups.formsemestre_get_main_partition(formsemestre_id) - if ( - partition["partition_id"] - != sco_groups.formsemestre_get_main_partition(formsemestre_id)[ - "partition_id" - ] - ): # il y a au moins une vraie partition - H.append( - f"""
    • Répartir les groupes de {partition["partition_name"]}
    • - """ - ) - - # - H.append(footer) - return "\n".join(H) - - -def build_page( - sem, - auth_etuds_by_sem, - inscrits, - candidats_non_inscrits, - inscrits_ailleurs, - inscrit_groupes=False, - ignore_jury=False, -): - inscrit_groupes = int(inscrit_groupes) - ignore_jury = int(ignore_jury) - if inscrit_groupes: - inscrit_groupes_checked = " checked" - else: - inscrit_groupes_checked = "" - if ignore_jury: - ignore_jury_checked = " checked" - else: - ignore_jury_checked = "" - H = [ - html_sco_header.html_sem_header( - "Passages dans le semestre", with_page_header=False - ), - """
      """ % request.base_url, - """ - -  aide - """ - % sem, # " - """inscrire aux mêmes groupes""" - % inscrit_groupes_checked, - """inclure tous les étudiants (même sans décision de jury)""" - % ignore_jury_checked, - """
      Actuellement %s inscrits - et %d candidats supplémentaires -
      """ - % (len(inscrits), len(candidats_non_inscrits)), - etuds_select_boxes(auth_etuds_by_sem, inscrits_ailleurs), - """

      """, - formsemestre_inscr_passage_help(sem), - """

      """, - ] - - # Semestres sans etudiants autorisés - empty_sems = [] - for formsemestre_id in auth_etuds_by_sem.keys(): - if not auth_etuds_by_sem[formsemestre_id]["etuds"]: - empty_sems.append(auth_etuds_by_sem[formsemestre_id]["infos"]) - if empty_sems: - H.append( - """

      Autres semestres sans candidats :

        """ - ) - for infos in empty_sems: - H.append("""
      • %(title)s
      • """ % infos) - H.append("""
      """) - - return H - - -def formsemestre_inscr_passage_help(sem): - return ( - """

      Explications

      -

      Cette page permet d'inscrire des étudiants dans le semestre destination - %(titreannee)s, - et d'en désincrire si besoin. -

      -

      Les étudiants sont groupés par semestres d'origines. Ceux qui sont en caractères - gras sont déjà inscrits dans le semestre destination. - Ceux qui sont en gras et en rouge sont inscrits - dans un autre semestre.

      -

      Au départ, les étudiants déjà inscrits sont sélectionnés; vous pouvez ajouter d'autres - étudiants à inscrire dans le semestre destination.

      -

      Si vous dé-selectionnez un étudiant déjà inscrit (en gras), il sera désinscrit.

      -

      Aucune action ne sera effectuée si vous n'appuyez pas sur le bouton "Appliquer les modifications" !

      -
      """ - % sem - ) - - -def etuds_select_boxes( - auth_etuds_by_cat, - inscrits_ailleurs={}, - sel_inscrits=True, - show_empty_boxes=False, - export_cat_xls=None, - base_url="", - read_only=False, -): - """Boites pour selection étudiants par catégorie - auth_etuds_by_cat = { category : { 'info' : {}, 'etuds' : ... } - inscrits_ailleurs = - sel_inscrits= - export_cat_xls = - """ - if export_cat_xls: - return etuds_select_box_xls(auth_etuds_by_cat[export_cat_xls]) - - H = [ - """ -
      """ - ] # " - # Élimine les boites vides: - auth_etuds_by_cat = { - k: auth_etuds_by_cat[k] - for k in auth_etuds_by_cat - if auth_etuds_by_cat[k]["etuds"] - } - for src_cat in auth_etuds_by_cat.keys(): - infos = auth_etuds_by_cat[src_cat]["infos"] - infos["comment"] = infos.get("comment", "") # commentaire dans sous-titre boite - help = infos.get("help", "") - etuds = auth_etuds_by_cat[src_cat]["etuds"] - etuds.sort(key=itemgetter("nom")) - with_checkbox = (not read_only) and auth_etuds_by_cat[src_cat]["infos"].get( - "with_checkbox", True - ) - checkbox_name = auth_etuds_by_cat[src_cat]["infos"].get( - "checkbox_name", "etuds" - ) - etud_key = auth_etuds_by_cat[src_cat]["infos"].get("etud_key", "etudid") - if etuds or show_empty_boxes: - infos["nbetuds"] = len(etuds) - H.append( - """
      - -
      (%(nbetuds)d étudiants%(comment)s)""" - % infos - ) - if with_checkbox: - H.append( - """ (Select. - tous - aucun""" # " - % infos - ) - if sel_inscrits: - H.append( - """inscrits""" - % infos - ) - if with_checkbox or sel_inscrits: - H.append(")") - if base_url and etuds: - url = scu.build_url_query(base_url, export_cat_xls=src_cat) - H.append(f'{scu.ICON_XLS} ') - H.append("
      ") - for etud in etuds: - if etud.get("inscrit", False): - c = " inscrit" - checked = 'checked="checked"' - else: - checked = "" - if etud["etudid"] in inscrits_ailleurs: - c = " inscrailleurs" - else: - c = "" - sco_etud.format_etud_ident(etud) - if etud["etudid"]: - elink = """%s""" % ( - c, - url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=etud["etudid"], - ), - etud["nomprenom"], - ) - else: - # ce n'est pas un etudiant ScoDoc - elink = etud["nomprenom"] - - if etud.get("datefinalisationinscription", None): - elink += ( - '' - + " : inscription finalisée le " - + etud["datefinalisationinscription"].strftime("%d/%m/%Y") - + "" - ) - - if not etud.get("paiementinscription", True): - elink += ' (non paiement)' - - H.append("""
      """ % c) - if "etape" in etud: - etape_str = etud["etape"] or "" - else: - etape_str = "" - H.append("""%s""" % etape_str) - if with_checkbox: - H.append( - """""" - % (checkbox_name, etud[etud_key], checked) - ) - H.append(elink) - if with_checkbox: - H.append("""""") - H.append("
      ") - H.append("
      ") - - H.append("
      ") - return "\n".join(H) - - -def etuds_select_box_xls(src_cat): - "export a box to excel" - etuds = src_cat["etuds"] - columns_ids = ["etudid", "civilite_str", "nom", "prenom", "etape"] - titles = {x: x for x in columns_ids} - - # Ajoute colonne paiement inscription - columns_ids.append("paiementinscription_str") - titles["paiementinscription_str"] = "paiement inscription" - for e in etuds: - if not e.get("paiementinscription", True): - e["paiementinscription_str"] = "NON" - else: - e["paiementinscription_str"] = "-" - tab = GenTable( - titles=titles, - columns_ids=columns_ids, - rows=etuds, - caption="%(title)s. %(help)s" % src_cat["infos"], - preferences=sco_preferences.SemPreferences(), - ) - return tab.excel() # tab.make_page(filename=src_cat["infos"]["filename"]) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Form. pour inscription rapide des etudiants d'un semestre dans un autre + Utilise les autorisations d'inscription délivrées en jury. +""" +import datetime +from operator import itemgetter + +from flask import url_for, g, request + +import app.scodoc.notesdb as ndb +import app.scodoc.sco_utils as scu +from app import log +from app.scodoc.gen_tables import GenTable +from app.scodoc import html_sco_header +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_preferences +from app.scodoc import sco_pvjury +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_formations +from app.scodoc import sco_groups +from app.scodoc import sco_etud +from app.scodoc.sco_exceptions import ScoValueError + + +def list_authorized_etuds_by_sem(sem, delai=274, ignore_jury=False): + """Liste des etudiants autorisés à s'inscrire dans sem. + delai = nb de jours max entre la date de l'autorisation et celle de debut du semestre cible. + ignore_jury: si vrai, considère tous les étudiants comem autorisés, même + s'ils n'ont pas de décision de jury. + """ + src_sems = list_source_sems(sem, delai=delai) + inscrits = list_inscrits(sem["formsemestre_id"]) + r = {} + candidats = {} # etudid : etud (tous les etudiants candidats) + nb = 0 # debug + for src in src_sems: + if ignore_jury: + # liste de tous les inscrits au semestre (sans dems) + liste = list_inscrits(src["formsemestre_id"]).values() + else: + # liste des étudiants autorisés par le jury à s'inscrire ici + liste = list_etuds_from_sem(src, sem) + liste_filtree = [] + for e in liste: + # Filtre ceux qui se sont déjà inscrit dans un semestre APRES le semestre src + auth_used = False # autorisation deja utilisée ? + etud = sco_etud.get_etud_info(etudid=e["etudid"], filled=True)[0] + for isem in etud["sems"]: + if ndb.DateDMYtoISO(isem["date_debut"]) >= ndb.DateDMYtoISO( + src["date_fin"] + ): + auth_used = True + if not auth_used: + candidats[e["etudid"]] = etud + liste_filtree.append(e) + nb += 1 + r[src["formsemestre_id"]] = { + "etuds": liste_filtree, + "infos": { + "id": src["formsemestre_id"], + "title": src["titreannee"], + "title_target": "formsemestre_status?formsemestre_id=%s" + % src["formsemestre_id"], + "filename": "etud_autorises", + }, + } + # ajoute attribut inscrit qui indique si l'étudiant est déjà inscrit dans le semestre dest. + for e in r[src["formsemestre_id"]]["etuds"]: + e["inscrit"] = e["etudid"] in inscrits + + # Ajoute liste des etudiants actuellement inscrits + for e in inscrits.values(): + e["inscrit"] = True + r[sem["formsemestre_id"]] = { + "etuds": list(inscrits.values()), + "infos": { + "id": sem["formsemestre_id"], + "title": "Semestre cible: " + sem["titreannee"], + "title_target": "formsemestre_status?formsemestre_id=%s" + % sem["formsemestre_id"], + "comment": " actuellement inscrits dans ce semestre", + "help": "Ces étudiants sont actuellement inscrits dans ce semestre. Si vous les décochez, il seront désinscrits.", + "filename": "etud_inscrits", + }, + } + + return r, inscrits, candidats + + +def list_inscrits(formsemestre_id, with_dems=False): + """Étudiants déjà inscrits à ce semestre + { etudid : etud } + """ + if not with_dems: + ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits( + formsemestre_id + ) # optimized + else: + args = {"formsemestre_id": formsemestre_id} + ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(args=args) + inscr = {} + for i in ins: + etudid = i["etudid"] + inscr[etudid] = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + return inscr + + +def list_etuds_from_sem(src, dst) -> list[dict]: + """Liste des etudiants du semestre src qui sont autorisés à passer dans le semestre dst.""" + target = dst["semestre_id"] + dpv = sco_pvjury.dict_pvjury(src["formsemestre_id"]) + if not dpv: + return [] + etuds = [ + x["identite"] + for x in dpv["decisions"] + if target in [a["semestre_id"] for a in x["autorisations"]] + ] + return etuds + + +def list_inscrits_date(sem): + """Liste les etudiants inscrits dans n'importe quel semestre + du même département + SAUF sem à la date de début de sem. + """ + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + sem["date_debut_iso"] = ndb.DateDMYtoISO(sem["date_debut"]) + cursor.execute( + """SELECT ins.etudid + FROM + notes_formsemestre_inscription ins, + notes_formsemestre S + WHERE ins.formsemestre_id = S.id + AND S.id != %(formsemestre_id)s + AND S.date_debut <= %(date_debut_iso)s + AND S.date_fin >= %(date_debut_iso)s + AND S.dept_id = %(dept_id)s + """, + sem, + ) + return [x[0] for x in cursor.fetchall()] + + +def do_inscrit(sem, etudids, inscrit_groupes=False): + """Inscrit ces etudiants dans ce semestre + (la liste doit avoir été vérifiée au préalable) + En option: inscrit aux mêmes groupes que dans le semestre origine + """ + log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}") + for etudid in etudids: + sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( + sem["formsemestre_id"], + etudid, + etat=scu.INSCRIT, + method="formsemestre_inscr_passage", + ) + if inscrit_groupes: + # Inscription dans les mêmes groupes que ceux du semestre d'origine, + # s'ils existent. + # (mise en correspondance à partir du nom du groupe, sans tenir compte + # du nom de la partition: évidemment, cela ne marche pas si on a les + # même noms de groupes dans des partitions différentes) + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + log("cherche groupes de %(nom)s" % etud) + + # recherche le semestre origine (il serait plus propre de l'avoir conservé!) + if len(etud["sems"]) < 2: + continue + prev_formsemestre = etud["sems"][1] + sco_groups.etud_add_group_infos( + etud, + prev_formsemestre["formsemestre_id"] if prev_formsemestre else None, + ) + + cursem_groups_by_name = dict( + [ + (g["group_name"], g) + for g in sco_groups.get_sem_groups(sem["formsemestre_id"]) + if g["group_name"] + ] + ) + + # forme la liste des groupes présents dans les deux semestres: + partition_groups = [] # [ partition+group ] (ds nouveau sem.) + for partition_id in etud["partitions"]: + prev_group_name = etud["partitions"][partition_id]["group_name"] + if prev_group_name in cursem_groups_by_name: + new_group = cursem_groups_by_name[prev_group_name] + partition_groups.append(new_group) + + # inscrit aux groupes + for partition_group in partition_groups: + if partition_group["groups_editable"]: + sco_groups.change_etud_group_in_partition( + etudid, + partition_group["group_id"], + partition_group, + ) + + +def do_desinscrit(sem, etudids): + log("do_desinscrit: %s" % etudids) + for etudid in etudids: + sco_formsemestre_inscriptions.do_formsemestre_desinscription( + etudid, sem["formsemestre_id"] + ) + + +def list_source_sems(sem, delai=None) -> list[dict]: + """Liste des semestres sources + sem est le semestre destination + """ + # liste des semestres débutant a moins + # de delai (en jours) de la date de fin du semestre d'origine. + sems = sco_formsemestre.do_formsemestre_list() + othersems = [] + d, m, y = [int(x) for x in sem["date_debut"].split("/")] + date_debut_dst = datetime.date(y, m, d) + + delais = datetime.timedelta(delai) + for s in sems: + if s["formsemestre_id"] == sem["formsemestre_id"]: + continue # saute le semestre destination + if s["date_fin"]: + d, m, y = [int(x) for x in s["date_fin"].split("/")] + date_fin = datetime.date(y, m, d) + if date_debut_dst - date_fin > delais: + continue # semestre trop ancien + if date_fin > date_debut_dst: + continue # semestre trop récent + # Elimine les semestres de formations speciales (sans parcours) + if s["semestre_id"] == sco_codes_parcours.NO_SEMESTRE_ID: + continue + # + F = sco_formations.formation_list(args={"formation_id": s["formation_id"]})[0] + parcours = sco_codes_parcours.get_parcours_from_code(F["type_parcours"]) + if not parcours.ALLOW_SEM_SKIP: + if s["semestre_id"] < (sem["semestre_id"] - 1): + continue + othersems.append(s) + return othersems + + +def formsemestre_inscr_passage( + formsemestre_id, + etuds=[], + inscrit_groupes=False, + submitted=False, + dialog_confirmed=False, + ignore_jury=False, +): + """Form. pour inscription des etudiants d'un semestre dans un autre + (donné par formsemestre_id). + Permet de selectionner parmi les etudiants autorisés à s'inscrire. + Principe: + - trouver liste d'etud, par semestre + - afficher chaque semestre "boites" avec cases à cocher + - si l'étudiant est déjà inscrit, le signaler (gras, nom de groupes): il peut être désinscrit + - on peut choisir les groupes TD, TP, TA + - seuls les etudiants non inscrits changent (de groupe) + - les etudiants inscrit qui se trouvent décochés sont désinscrits + - Confirmation: indiquer les étudiants inscrits et ceux désinscrits, le total courant. + + """ + inscrit_groupes = int(inscrit_groupes) + ignore_jury = int(ignore_jury) + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + # -- check lock + if not sem["etat"]: + raise ScoValueError("opération impossible: semestre verrouille") + header = html_sco_header.sco_header(page_title="Passage des étudiants") + footer = html_sco_header.sco_footer() + H = [header] + if isinstance(etuds, str): + # list de strings, vient du form de confirmation + etuds = [int(x) for x in etuds.split(",") if x] + elif isinstance(etuds, int): + etuds = [etuds] + elif etuds and isinstance(etuds[0], str): + etuds = [int(x) for x in etuds] + + auth_etuds_by_sem, inscrits, candidats = list_authorized_etuds_by_sem( + sem, ignore_jury=ignore_jury + ) + etuds_set = set(etuds) + candidats_set = set(candidats) + inscrits_set = set(inscrits) + candidats_non_inscrits = candidats_set - inscrits_set + inscrits_ailleurs = set(list_inscrits_date(sem)) + + def set_to_sorted_etud_list(etudset): + etuds = [candidats[etudid] for etudid in etudset] + etuds.sort(key=itemgetter("nom")) + return etuds + + if submitted: + a_inscrire = etuds_set.intersection(candidats_set) - inscrits_set + a_desinscrire = inscrits_set - etuds_set + else: + a_inscrire = a_desinscrire = [] + # log('formsemestre_inscr_passage: a_inscrire=%s' % str(a_inscrire) ) + # log('formsemestre_inscr_passage: a_desinscrire=%s' % str(a_desinscrire) ) + + if not submitted: + H += build_page( + sem, + auth_etuds_by_sem, + inscrits, + candidats_non_inscrits, + inscrits_ailleurs, + inscrit_groupes=inscrit_groupes, + ignore_jury=ignore_jury, + ) + else: + if not dialog_confirmed: + # Confirmation + if a_inscrire: + H.append("

      Etudiants à inscrire

        ") + for etud in set_to_sorted_etud_list(a_inscrire): + H.append("
      1. %(nomprenom)s
      2. " % etud) + H.append("
      ") + a_inscrire_en_double = inscrits_ailleurs.intersection(a_inscrire) + if a_inscrire_en_double: + H.append("

      dont étudiants déjà inscrits:

        ") + for etud in set_to_sorted_etud_list(a_inscrire_en_double): + H.append('
      • %(nomprenom)s
      • ' % etud) + H.append("
      ") + if a_desinscrire: + H.append("

      Etudiants à désinscrire

        ") + for etudid in a_desinscrire: + H.append( + '
      1. %(nomprenom)s
      2. ' + % inscrits[etudid] + ) + H.append("
      ") + todo = a_inscrire or a_desinscrire + if not todo: + H.append("""

      Il n'y a rien à modifier !

      """) + H.append( + scu.confirm_dialog( + dest_url="formsemestre_inscr_passage" + if todo + else "formsemestre_status", + message="

      Confirmer ?

      " if todo else "", + add_headers=False, + cancel_url="formsemestre_inscr_passage?formsemestre_id=" + + str(formsemestre_id), + OK="Effectuer l'opération" if todo else "", + parameters={ + "formsemestre_id": formsemestre_id, + "etuds": ",".join([str(x) for x in etuds]), + "inscrit_groupes": inscrit_groupes, + "ignore_jury": ignore_jury, + "submitted": 1, + }, + ) + ) + else: + # Inscription des étudiants au nouveau semestre: + do_inscrit( + sem, + a_inscrire, + inscrit_groupes=inscrit_groupes, + ) + + # Desincriptions: + do_desinscrit(sem, a_desinscrire) + + H.append( + """

      Opération effectuée

      +
      • Continuer les inscriptions
      • +
      • Tableau de bord du semestre
      • """ + % (formsemestre_id, formsemestre_id) + ) + partition = sco_groups.formsemestre_get_main_partition(formsemestre_id) + if ( + partition["partition_id"] + != sco_groups.formsemestre_get_main_partition(formsemestre_id)[ + "partition_id" + ] + ): # il y a au moins une vraie partition + H.append( + f"""
      • Répartir les groupes de {partition["partition_name"]}
      • + """ + ) + + # + H.append(footer) + return "\n".join(H) + + +def build_page( + sem, + auth_etuds_by_sem, + inscrits, + candidats_non_inscrits, + inscrits_ailleurs, + inscrit_groupes=False, + ignore_jury=False, +): + inscrit_groupes = int(inscrit_groupes) + ignore_jury = int(ignore_jury) + if inscrit_groupes: + inscrit_groupes_checked = " checked" + else: + inscrit_groupes_checked = "" + if ignore_jury: + ignore_jury_checked = " checked" + else: + ignore_jury_checked = "" + H = [ + html_sco_header.html_sem_header( + "Passages dans le semestre", with_page_header=False + ), + """
        """ % request.base_url, + """ + +  aide + """ + % sem, # " + """inscrire aux mêmes groupes""" + % inscrit_groupes_checked, + """inclure tous les étudiants (même sans décision de jury)""" + % ignore_jury_checked, + """
        Actuellement %s inscrits + et %d candidats supplémentaires +
        """ + % (len(inscrits), len(candidats_non_inscrits)), + etuds_select_boxes(auth_etuds_by_sem, inscrits_ailleurs), + """

        """, + formsemestre_inscr_passage_help(sem), + """

        """, + ] + + # Semestres sans etudiants autorisés + empty_sems = [] + for formsemestre_id in auth_etuds_by_sem.keys(): + if not auth_etuds_by_sem[formsemestre_id]["etuds"]: + empty_sems.append(auth_etuds_by_sem[formsemestre_id]["infos"]) + if empty_sems: + H.append( + """

        Autres semestres sans candidats :

          """ + ) + for infos in empty_sems: + H.append("""
        • %(title)s
        • """ % infos) + H.append("""
        """) + + return H + + +def formsemestre_inscr_passage_help(sem): + return ( + """

        Explications

        +

        Cette page permet d'inscrire des étudiants dans le semestre destination + %(titreannee)s, + et d'en désincrire si besoin. +

        +

        Les étudiants sont groupés par semestres d'origines. Ceux qui sont en caractères + gras sont déjà inscrits dans le semestre destination. + Ceux qui sont en gras et en rouge sont inscrits + dans un autre semestre.

        +

        Au départ, les étudiants déjà inscrits sont sélectionnés; vous pouvez ajouter d'autres + étudiants à inscrire dans le semestre destination.

        +

        Si vous dé-selectionnez un étudiant déjà inscrit (en gras), il sera désinscrit.

        +

        Aucune action ne sera effectuée si vous n'appuyez pas sur le bouton "Appliquer les modifications" !

        +
        """ + % sem + ) + + +def etuds_select_boxes( + auth_etuds_by_cat, + inscrits_ailleurs={}, + sel_inscrits=True, + show_empty_boxes=False, + export_cat_xls=None, + base_url="", + read_only=False, +): + """Boites pour selection étudiants par catégorie + auth_etuds_by_cat = { category : { 'info' : {}, 'etuds' : ... } + inscrits_ailleurs = + sel_inscrits= + export_cat_xls = + """ + if export_cat_xls: + return etuds_select_box_xls(auth_etuds_by_cat[export_cat_xls]) + + H = [ + """ +
        """ + ] # " + # Élimine les boites vides: + auth_etuds_by_cat = { + k: auth_etuds_by_cat[k] + for k in auth_etuds_by_cat + if auth_etuds_by_cat[k]["etuds"] + } + for src_cat in auth_etuds_by_cat.keys(): + infos = auth_etuds_by_cat[src_cat]["infos"] + infos["comment"] = infos.get("comment", "") # commentaire dans sous-titre boite + help = infos.get("help", "") + etuds = auth_etuds_by_cat[src_cat]["etuds"] + etuds.sort(key=itemgetter("nom")) + with_checkbox = (not read_only) and auth_etuds_by_cat[src_cat]["infos"].get( + "with_checkbox", True + ) + checkbox_name = auth_etuds_by_cat[src_cat]["infos"].get( + "checkbox_name", "etuds" + ) + etud_key = auth_etuds_by_cat[src_cat]["infos"].get("etud_key", "etudid") + if etuds or show_empty_boxes: + infos["nbetuds"] = len(etuds) + H.append( + """
        + +
        (%(nbetuds)d étudiants%(comment)s)""" + % infos + ) + if with_checkbox: + H.append( + """ (Select. + tous + aucun""" # " + % infos + ) + if sel_inscrits: + H.append( + """inscrits""" + % infos + ) + if with_checkbox or sel_inscrits: + H.append(")") + if base_url and etuds: + url = scu.build_url_query(base_url, export_cat_xls=src_cat) + H.append(f'{scu.ICON_XLS} ') + H.append("
        ") + for etud in etuds: + if etud.get("inscrit", False): + c = " inscrit" + checked = 'checked="checked"' + else: + checked = "" + if etud["etudid"] in inscrits_ailleurs: + c = " inscrailleurs" + else: + c = "" + sco_etud.format_etud_ident(etud) + if etud["etudid"]: + elink = """%s""" % ( + c, + url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etud["etudid"], + ), + etud["nomprenom"], + ) + else: + # ce n'est pas un etudiant ScoDoc + elink = etud["nomprenom"] + + if etud.get("datefinalisationinscription", None): + elink += ( + '' + + " : inscription finalisée le " + + etud["datefinalisationinscription"].strftime("%d/%m/%Y") + + "" + ) + + if not etud.get("paiementinscription", True): + elink += ' (non paiement)' + + H.append("""
        """ % c) + if "etape" in etud: + etape_str = etud["etape"] or "" + else: + etape_str = "" + H.append("""%s""" % etape_str) + if with_checkbox: + H.append( + """""" + % (checkbox_name, etud[etud_key], checked) + ) + H.append(elink) + if with_checkbox: + H.append("""""") + H.append("
        ") + H.append("
        ") + + H.append("
        ") + return "\n".join(H) + + +def etuds_select_box_xls(src_cat): + "export a box to excel" + etuds = src_cat["etuds"] + columns_ids = ["etudid", "civilite_str", "nom", "prenom", "etape"] + titles = {x: x for x in columns_ids} + + # Ajoute colonne paiement inscription + columns_ids.append("paiementinscription_str") + titles["paiementinscription_str"] = "paiement inscription" + for e in etuds: + if not e.get("paiementinscription", True): + e["paiementinscription_str"] = "NON" + else: + e["paiementinscription_str"] = "-" + tab = GenTable( + titles=titles, + columns_ids=columns_ids, + rows=etuds, + caption="%(title)s. %(help)s" % src_cat["infos"], + preferences=sco_preferences.SemPreferences(), + ) + return tab.excel() # tab.make_page(filename=src_cat["infos"]["filename"]) diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py index 03ca150e..b3aeba7a 100644 --- a/app/scodoc/sco_liste_notes.py +++ b/app/scodoc/sco_liste_notes.py @@ -1,921 +1,921 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Liste des notes d'une évaluation -""" -from collections import defaultdict -import numpy as np - -import flask -from flask import url_for, g, request - -from app import log -from app import models -from app.comp import res_sem -from app.comp import moy_mod -from app.comp.moy_mod import ModuleImplResults -from app.comp.res_compat import NotesTableCompat -from app.comp.res_but import ResultatsSemestreBUT -from app.models import FormSemestre -from app.models.etudiants import Identite -from app.models.evaluations import Evaluation -from app.models.moduleimpls import ModuleImpl -import app.scodoc.notesdb as ndb -from app.scodoc.TrivialFormulator import TrivialFormulator - -from app.scodoc.sco_etud import etud_sort_key -from app.scodoc import sco_evaluations -from app.scodoc import sco_evaluation_db -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups -from app.scodoc import sco_moduleimpl -from app.scodoc import sco_preferences -from app.scodoc import sco_users -import app.scodoc.sco_utils as scu -import sco_version -from app.scodoc.gen_tables import GenTable -from app.scodoc.htmlutils import histogram_notes - - -def do_evaluation_listenotes( - evaluation_id=None, moduleimpl_id=None, format="html" -) -> tuple[str, str]: - """ - Affichage des notes d'une évaluation (si evaluation_id) - ou de toutes les évaluations d'un module (si moduleimpl_id) - """ - mode = None - if moduleimpl_id: - mode = "module" - evals = sco_evaluation_db.do_evaluation_list({"moduleimpl_id": moduleimpl_id}) - elif evaluation_id: - mode = "eval" - evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) - else: - raise ValueError("missing argument: evaluation or module") - if not evals: - return "

        Aucune évaluation !

        ", f"ScoDoc" - - E = evals[0] # il y a au moins une evaluation - modimpl = ModuleImpl.query.get(E["moduleimpl_id"]) - # description de l'evaluation - if mode == "eval": - H = [sco_evaluations.evaluation_describe(evaluation_id=evaluation_id)] - page_title = f"Notes {E['description'] or modimpl.module.code}" - else: - H = [] - page_title = f"Notes {modimpl.module.code}" - # groupes - groups = sco_groups.do_evaluation_listegroupes( - E["evaluation_id"], include_default=True - ) - grlabs = [g["group_name"] or "tous" for g in groups] # legendes des boutons - grnams = [str(g["group_id"]) for g in groups] # noms des checkbox - - if len(evals) > 1: - descr = [ - ("moduleimpl_id", {"default": E["moduleimpl_id"], "input_type": "hidden"}) - ] - else: - descr = [ - ("evaluation_id", {"default": E["evaluation_id"], "input_type": "hidden"}) - ] - if len(grnams) > 1: - descr += [ - ( - "s", - { - "input_type": "separator", - "title": "Choix du ou des groupes d'étudiants:", - }, - ), - ( - "group_ids", - { - "input_type": "checkbox", - "title": "", - "allowed_values": grnams, - "labels": grlabs, - "attributes": ('onclick="document.tf.submit();"',), - }, - ), - ] - else: - if grnams: - def_nam = grnams[0] - else: - def_nam = "" - descr += [ - ( - "group_ids", - {"input_type": "hidden", "type": "list", "default": [def_nam]}, - ) - ] - descr += [ - ( - "anonymous_listing", - { - "input_type": "checkbox", - "title": "", - "allowed_values": ("yes",), - "labels": ('listing "anonyme"',), - "attributes": ('onclick="document.tf.submit();"',), - "template": '%(label)s%(elem)s   ', - }, - ), - ( - "note_sur_20", - { - "input_type": "checkbox", - "title": "", - "allowed_values": ("yes",), - "labels": ("notes sur 20",), - "attributes": ('onclick="document.tf.submit();"',), - "template": "%(elem)s   ", - }, - ), - ( - "hide_groups", - { - "input_type": "checkbox", - "title": "", - "allowed_values": ("yes",), - "labels": ("masquer les groupes",), - "attributes": ('onclick="document.tf.submit();"',), - "template": "%(elem)s   ", - }, - ), - ( - "with_emails", - { - "input_type": "checkbox", - "title": "", - "allowed_values": ("yes",), - "labels": ("montrer les e-mails",), - "attributes": ('onclick="document.tf.submit();"',), - "template": "%(elem)s", - }, - ), - ] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - descr, - cancelbutton=None, - submitbutton=None, - bottom_buttons=False, - method="GET", - cssclass="noprint", - name="tf", - is_submitted=True, # toujours "soumis" (démarre avec liste complète) - ) - if tf[0] == 0: - return "\n".join(H) + "\n" + tf[1], page_title - elif tf[0] == -1: - return ( - flask.redirect( - "%s/Notes/moduleimpl_status?moduleimpl_id=%s" - % (scu.ScoURL(), E["moduleimpl_id"]) - ), - "", - ) - else: - anonymous_listing = tf[2]["anonymous_listing"] - note_sur_20 = tf[2]["note_sur_20"] - hide_groups = tf[2]["hide_groups"] - with_emails = tf[2]["with_emails"] - group_ids = [x for x in tf[2]["group_ids"] if x != ""] - return ( - _make_table_notes( - tf[1], - evals, - format=format, - note_sur_20=note_sur_20, - anonymous_listing=anonymous_listing, - group_ids=group_ids, - hide_groups=hide_groups, - with_emails=with_emails, - mode=mode, - ), - page_title, - ) - - -def _make_table_notes( - html_form, - evals, - format="", - note_sur_20=False, - anonymous_listing=False, - hide_groups=False, - with_emails=False, - group_ids=[], - mode="module", # "eval" or "module" -): - """Table liste notes (une seule évaluation ou toutes celles d'un module)""" - # Code à ré-écrire ! - if not evals: - return "

        Aucune évaluation !

        " - E = evals[0] - moduleimpl_id = E["moduleimpl_id"] - modimpl_o = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] - module = models.Module.query.get(modimpl_o["module_id"]) - is_apc = module.formation.get_parcours().APC_SAE - if is_apc: - modimpl = ModuleImpl.query.get(moduleimpl_id) - is_conforme = modimpl.check_apc_conformity() - evals_poids, ues = moy_mod.load_evaluations_poids(moduleimpl_id) - if not ues: - is_apc = False - else: - evals_poids, ues = None, None - is_conforme = True - sem = sco_formsemestre.get_formsemestre(modimpl_o["formsemestre_id"]) - # (debug) check that all evals are in same module: - for e in evals: - if e["moduleimpl_id"] != moduleimpl_id: - raise ValueError("invalid evaluations list") - - if format == "xls": - keep_numeric = True # pas de conversion des notes en strings - else: - keep_numeric = False - # Si pas de groupe, affiche tout - if not group_ids: - group_ids = [sco_groups.get_default_group(modimpl_o["formsemestre_id"])] - groups = sco_groups.listgroups(group_ids) - - gr_title = sco_groups.listgroups_abbrev(groups) - gr_title_filename = sco_groups.listgroups_filename(groups) - - if anonymous_listing: - columns_ids = ["code"] # cols in table - else: - if format == "xls" or format == "xml": - columns_ids = ["nom", "prenom"] - else: - columns_ids = ["nomprenom"] - if not hide_groups: - columns_ids.append("group") - - titles = { - "code": "Code", - "group": "Groupe", - "nom": "Nom", - "prenom": "Prénom", - "nomprenom": "Nom", - "expl_key": "Rem.", - "email": "e-mail", - "emailperso": "e-mail perso", - "signatures": "Signatures", - } - rows = [] - - class KeyManager(dict): # comment : key (pour regrouper les comments a la fin) - def __init__(self): - self.lastkey = 1 - - def nextkey(self): - r = self.lastkey - self.lastkey += 1 - # self.lastkey = chr(ord(self.lastkey)+1) - return str(r) - - key_mgr = KeyManager() - - # code pour listings anonyme, à la place du nom - if sco_preferences.get_preference("anonymous_lst_code") == "INE": - anonymous_lst_key = "code_ine" - elif sco_preferences.get_preference("anonymous_lst_code") == "NIP": - anonymous_lst_key = "code_nip" - else: - anonymous_lst_key = "etudid" - - etudid_etats = sco_groups.do_evaluation_listeetuds_groups( - E["evaluation_id"], groups, include_demdef=True - ) - for etudid, etat in etudid_etats: - css_row_class = None - # infos identite etudiant - etud = Identite.query.get(etudid) - if etud is None: - continue - - if etat == "I": # si inscrit, indique groupe - groups = sco_groups.get_etud_groups(etudid, modimpl_o["formsemestre_id"]) - grc = sco_groups.listgroups_abbrev(groups) - else: - if etat == "D": - grc = "DEM" # attention: ce code est re-ecrit plus bas, ne pas le changer (?) - css_row_class = "etuddem" - else: - grc = etat - - code = getattr(etud, anonymous_lst_key) - if not code: # laisser le code vide n'aurait aucun sens, prenons l'etudid - code = etudid - - rows.append( - { - "code": str(code), # INE, NIP ou etudid - "_code_td_attrs": 'style="padding-left: 1em; padding-right: 2em;"', - "etudid": etudid, - "nom": etud.nom.upper(), - "_nomprenom_target": url_for( - "notes.formsemestre_bulletinetud", - scodoc_dept=g.scodoc_dept, - formsemestre_id=modimpl_o["formsemestre_id"], - etudid=etudid, - ), - "_nomprenom_td_attrs": f"""id="{etudid}" class="etudinfo" data-sort="{etud.sort_key}" """, - "prenom": etud.prenom.lower().capitalize(), - "nom_usuel": etud.nom_usuel, - "nomprenom": etud.nomprenom, - "group": grc, - "_group_td_attrs": 'class="group"', - "email": etud.get_first_email(), - "emailperso": etud.get_first_email("emailperso"), - "_css_row_class": css_row_class or "", - } - ) - - # Lignes en tête: - row_coefs = { - "nom": "", - "prenom": "", - "nomprenom": "", - "group": "", - "code": "", - "_css_row_class": "sorttop fontitalic", - "_table_part": "head", - } - row_poids = { - "nom": "", - "prenom": "", - "nomprenom": "", - "group": "", - "code": "", - "_css_row_class": "sorttop poids", - "_table_part": "head", - } - row_note_max = { - "nom": "", - "prenom": "", - "nomprenom": "", - "group": "", - "code": "", - "_css_row_class": "sorttop fontitalic", - "_table_part": "head", - } - row_moys = { - "_css_row_class": "moyenne sortbottom", - "_table_part": "foot", - #'_nomprenom_td_attrs' : 'colspan="2" ', - "nomprenom": "Moyenne :", - "comment": "", - } - # Ajoute les notes de chaque évaluation: - for e in evals: - e["eval_state"] = sco_evaluations.do_evaluation_etat(e["evaluation_id"]) - notes, nb_abs, nb_att = _add_eval_columns( - e, - evals_poids, - ues, - rows, - titles, - row_coefs, - row_poids, - row_note_max, - row_moys, - is_apc, - key_mgr, - note_sur_20, - keep_numeric, - format=format, - ) - columns_ids.append(e["evaluation_id"]) - # - if anonymous_listing: - rows.sort(key=lambda x: x["code"] or "") - else: - # sort by nom, prenom, sans accents - rows.sort(key=etud_sort_key) - - # Si module, ajoute la (les) "moyenne(s) du module: - if mode == "module": - if len(evals) > 1: - # Moyenne de l'étudiant dans le module - # Affichée même en APC à titre indicatif - _add_moymod_column( - sem["formsemestre_id"], - moduleimpl_id, - rows, - columns_ids, - titles, - row_coefs, - row_poids, - row_note_max, - row_moys, - is_apc, - keep_numeric, - ) - if is_apc: - # Ajoute une colonne par UE - _add_apc_columns( - modimpl, - evals_poids, - ues, - rows, - columns_ids, - titles, - is_conforme, - row_coefs, - row_poids, - row_note_max, - row_moys, - keep_numeric, - ) - - # Ajoute colonnes emails tout à droite: - if with_emails: - columns_ids += ["email", "emailperso"] - # Ajoute lignes en tête et moyennes - if len(evals) > 0 and format != "bordereau": - rows_head = [row_coefs] - if is_apc: - rows_head.append(row_poids) - rows_head.append(row_note_max) - rows = rows_head + rows - rows.append(row_moys) - # ajout liens HTMl vers affichage une evaluation: - if format == "html" and len(evals) > 1: - rlinks = {"_table_part": "head"} - for e in evals: - rlinks[e["evaluation_id"]] = "afficher" - rlinks[ - "_" + str(e["evaluation_id"]) + "_help" - ] = "afficher seulement les notes de cette évaluation" - rlinks["_" + str(e["evaluation_id"]) + "_target"] = url_for( - "notes.evaluation_listenotes", - scodoc_dept=g.scodoc_dept, - evaluation_id=e["evaluation_id"], - ) - rlinks["_" + str(e["evaluation_id"]) + "_td_attrs"] = ' class="tdlink" ' - rows.append(rlinks) - - if len(evals) == 1: # colonne "Rem." seulement si une eval - if format == "html": # pas d'indication d'origine en pdf (pour affichage) - columns_ids.append("expl_key") - elif format == "xls" or format == "xml": - columns_ids.append("comment") - elif format == "bordereau": - columns_ids.append("signatures") - - # titres divers: - gl = "".join(["&group_ids%3Alist=" + str(g) for g in group_ids]) - if note_sur_20: - gl = "¬e_sur_20%3Alist=yes" + gl - if anonymous_listing: - gl = "&anonymous_listing%3Alist=yes" + gl - if hide_groups: - gl = "&hide_groups%3Alist=yes" + gl - if with_emails: - gl = "&with_emails%3Alist=yes" + gl - if len(evals) == 1: - evalname = "%s-%s" % (module.code, ndb.DateDMYtoISO(E["jour"])) - hh = "%s, %s (%d étudiants)" % (E["description"], gr_title, len(etudid_etats)) - filename = scu.make_filename("notes_%s_%s" % (evalname, gr_title_filename)) - - if format == "bordereau": - hh = " %d étudiants" % (len(etudid_etats)) - hh += " %d absent" % (nb_abs) - if nb_abs > 1: - hh += "s" - hh += ", %d en attente." % (nb_att) - - pdf_title = "
        BORDEREAU DE SIGNATURES" - pdf_title += "

        %(titre)s" % sem - pdf_title += "
        (%(mois_debut)s - %(mois_fin)s)" % sem - pdf_title += " semestre %s %s" % ( - sem["semestre_id"], - sem.get("modalite", ""), - ) - pdf_title += f"
        Notes du module {module.code} - {module.titre}" - pdf_title += "
        Evaluation : %(description)s " % e - if len(e["jour"]) > 0: - pdf_title += " (%(jour)s)" % e - pdf_title += "(noté sur %(note_max)s )

        " % e - else: - hh = " %s, %s (%d étudiants)" % ( - E["description"], - gr_title, - len(etudid_etats), - ) - if len(e["jour"]) > 0: - pdf_title = "%(description)s (%(jour)s)" % e - else: - pdf_title = "%(description)s " % e - - caption = hh - html_title = "" - base_url = "evaluation_listenotes?evaluation_id=%s" % E["evaluation_id"] + gl - html_next_section = ( - '
        %d absents, %d en attente.
        ' - % (nb_abs, nb_att) - ) - else: - filename = scu.make_filename("notes_%s_%s" % (module.code, gr_title_filename)) - title = f"Notes {module.type_name()} {module.code} {module.titre}" - title += " semestre %(titremois)s" % sem - if gr_title and gr_title != "tous": - title += " %s" % gr_title - caption = title - html_next_section = "" - if format == "pdf" or format == "bordereau": - caption = "" # same as pdf_title - pdf_title = title - html_title = f"""

        Notes {module.type_name()} {module.code} {module.titre}

        - """ - if not is_conforme: - html_title += ( - """
        Poids des évaluations non conformes !
        """ - ) - base_url = "evaluation_listenotes?moduleimpl_id=%s" % moduleimpl_id + gl - # display - tab = GenTable( - titles=titles, - columns_ids=columns_ids, - rows=rows, - html_sortable=True, - base_url=base_url, - filename=filename, - origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}", - caption=caption, - html_next_section=html_next_section, - page_title="Notes de " + sem["titremois"], - html_title=html_title, - pdf_title=pdf_title, - html_class="notes_evaluation", - preferences=sco_preferences.SemPreferences(modimpl_o["formsemestre_id"]), - # html_generate_cells=False # la derniere ligne (moyennes) est incomplete - ) - if format == "bordereau": - format = "pdf" - t = tab.make_page(format=format, with_html_headers=False) - if format != "html": - return t - - if len(evals) > 1: - all_complete = True - for e in evals: - if not e["eval_state"]["evalcomplete"]: - all_complete = False - if all_complete: - eval_info = 'Evaluations prises en compte dans les moyennes.' - else: - eval_info = """ - Les évaluations en vert et orange sont prises en compte dans les moyennes. - Celles en rouge n'ont pas toutes leurs notes.""" - if is_apc: - eval_info += """ La moyenne indicative est la moyenne des moyennes d'UE, et n'est pas utilisée en BUT. - Les moyennes sur le groupe sont estimées sans les absents (sauf pour les moyennes des moyennes d'UE) ni les démissionnaires.""" - eval_info += """""" - return html_form + eval_info + t + "

        " - else: - # Une seule evaluation: ajoute histogramme - histo = histogram_notes(notes) - # 2 colonnes: histo, comments - C = [ - f'
        Bordereau de Signatures (version PDF)', - "\n", - '", + }, + ), + ] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + descr, + cancelbutton=None, + submitbutton=None, + bottom_buttons=False, + method="GET", + cssclass="noprint", + name="tf", + is_submitted=True, # toujours "soumis" (démarre avec liste complète) + ) + if tf[0] == 0: + return "\n".join(H) + "\n" + tf[1], page_title + elif tf[0] == -1: + return ( + flask.redirect( + "%s/Notes/moduleimpl_status?moduleimpl_id=%s" + % (scu.ScoURL(), E["moduleimpl_id"]) + ), + "", + ) + else: + anonymous_listing = tf[2]["anonymous_listing"] + note_sur_20 = tf[2]["note_sur_20"] + hide_groups = tf[2]["hide_groups"] + with_emails = tf[2]["with_emails"] + group_ids = [x for x in tf[2]["group_ids"] if x != ""] + return ( + _make_table_notes( + tf[1], + evals, + format=format, + note_sur_20=note_sur_20, + anonymous_listing=anonymous_listing, + group_ids=group_ids, + hide_groups=hide_groups, + with_emails=with_emails, + mode=mode, + ), + page_title, + ) + + +def _make_table_notes( + html_form, + evals, + format="", + note_sur_20=False, + anonymous_listing=False, + hide_groups=False, + with_emails=False, + group_ids=[], + mode="module", # "eval" or "module" +): + """Table liste notes (une seule évaluation ou toutes celles d'un module)""" + # Code à ré-écrire ! + if not evals: + return "

        Aucune évaluation !

        " + E = evals[0] + moduleimpl_id = E["moduleimpl_id"] + modimpl_o = sco_moduleimpl.moduleimpl_list(moduleimpl_id=moduleimpl_id)[0] + module = models.Module.query.get(modimpl_o["module_id"]) + is_apc = module.formation.get_parcours().APC_SAE + if is_apc: + modimpl = ModuleImpl.query.get(moduleimpl_id) + is_conforme = modimpl.check_apc_conformity() + evals_poids, ues = moy_mod.load_evaluations_poids(moduleimpl_id) + if not ues: + is_apc = False + else: + evals_poids, ues = None, None + is_conforme = True + sem = sco_formsemestre.get_formsemestre(modimpl_o["formsemestre_id"]) + # (debug) check that all evals are in same module: + for e in evals: + if e["moduleimpl_id"] != moduleimpl_id: + raise ValueError("invalid evaluations list") + + if format == "xls": + keep_numeric = True # pas de conversion des notes en strings + else: + keep_numeric = False + # Si pas de groupe, affiche tout + if not group_ids: + group_ids = [sco_groups.get_default_group(modimpl_o["formsemestre_id"])] + groups = sco_groups.listgroups(group_ids) + + gr_title = sco_groups.listgroups_abbrev(groups) + gr_title_filename = sco_groups.listgroups_filename(groups) + + if anonymous_listing: + columns_ids = ["code"] # cols in table + else: + if format == "xls" or format == "xml": + columns_ids = ["nom", "prenom"] + else: + columns_ids = ["nomprenom"] + if not hide_groups: + columns_ids.append("group") + + titles = { + "code": "Code", + "group": "Groupe", + "nom": "Nom", + "prenom": "Prénom", + "nomprenom": "Nom", + "expl_key": "Rem.", + "email": "e-mail", + "emailperso": "e-mail perso", + "signatures": "Signatures", + } + rows = [] + + class KeyManager(dict): # comment : key (pour regrouper les comments a la fin) + def __init__(self): + self.lastkey = 1 + + def nextkey(self): + r = self.lastkey + self.lastkey += 1 + # self.lastkey = chr(ord(self.lastkey)+1) + return str(r) + + key_mgr = KeyManager() + + # code pour listings anonyme, à la place du nom + if sco_preferences.get_preference("anonymous_lst_code") == "INE": + anonymous_lst_key = "code_ine" + elif sco_preferences.get_preference("anonymous_lst_code") == "NIP": + anonymous_lst_key = "code_nip" + else: + anonymous_lst_key = "etudid" + + etudid_etats = sco_groups.do_evaluation_listeetuds_groups( + E["evaluation_id"], groups, include_demdef=True + ) + for etudid, etat in etudid_etats: + css_row_class = None + # infos identite etudiant + etud = Identite.query.get(etudid) + if etud is None: + continue + + if etat == scu.INSCRIT: # si inscrit, indique groupe + groups = sco_groups.get_etud_groups(etudid, modimpl_o["formsemestre_id"]) + grc = sco_groups.listgroups_abbrev(groups) + else: + if etat == scu.DEMISSION: + grc = "DEM" # attention: ce code est re-ecrit plus bas, ne pas le changer (?) + css_row_class = "etuddem" + else: + grc = etat + + code = getattr(etud, anonymous_lst_key) + if not code: # laisser le code vide n'aurait aucun sens, prenons l'etudid + code = etudid + + rows.append( + { + "code": str(code), # INE, NIP ou etudid + "_code_td_attrs": 'style="padding-left: 1em; padding-right: 2em;"', + "etudid": etudid, + "nom": etud.nom.upper(), + "_nomprenom_target": url_for( + "notes.formsemestre_bulletinetud", + scodoc_dept=g.scodoc_dept, + formsemestre_id=modimpl_o["formsemestre_id"], + etudid=etudid, + ), + "_nomprenom_td_attrs": f"""id="{etudid}" class="etudinfo" data-sort="{etud.sort_key}" """, + "prenom": etud.prenom.lower().capitalize(), + "nom_usuel": etud.nom_usuel, + "nomprenom": etud.nomprenom, + "group": grc, + "_group_td_attrs": 'class="group"', + "email": etud.get_first_email(), + "emailperso": etud.get_first_email("emailperso"), + "_css_row_class": css_row_class or "", + } + ) + + # Lignes en tête: + row_coefs = { + "nom": "", + "prenom": "", + "nomprenom": "", + "group": "", + "code": "", + "_css_row_class": "sorttop fontitalic", + "_table_part": "head", + } + row_poids = { + "nom": "", + "prenom": "", + "nomprenom": "", + "group": "", + "code": "", + "_css_row_class": "sorttop poids", + "_table_part": "head", + } + row_note_max = { + "nom": "", + "prenom": "", + "nomprenom": "", + "group": "", + "code": "", + "_css_row_class": "sorttop fontitalic", + "_table_part": "head", + } + row_moys = { + "_css_row_class": "moyenne sortbottom", + "_table_part": "foot", + #'_nomprenom_td_attrs' : 'colspan="2" ', + "nomprenom": "Moyenne :", + "comment": "", + } + # Ajoute les notes de chaque évaluation: + for e in evals: + e["eval_state"] = sco_evaluations.do_evaluation_etat(e["evaluation_id"]) + notes, nb_abs, nb_att = _add_eval_columns( + e, + evals_poids, + ues, + rows, + titles, + row_coefs, + row_poids, + row_note_max, + row_moys, + is_apc, + key_mgr, + note_sur_20, + keep_numeric, + format=format, + ) + columns_ids.append(e["evaluation_id"]) + # + if anonymous_listing: + rows.sort(key=lambda x: x["code"] or "") + else: + # sort by nom, prenom, sans accents + rows.sort(key=etud_sort_key) + + # Si module, ajoute la (les) "moyenne(s) du module: + if mode == "module": + if len(evals) > 1: + # Moyenne de l'étudiant dans le module + # Affichée même en APC à titre indicatif + _add_moymod_column( + sem["formsemestre_id"], + moduleimpl_id, + rows, + columns_ids, + titles, + row_coefs, + row_poids, + row_note_max, + row_moys, + is_apc, + keep_numeric, + ) + if is_apc: + # Ajoute une colonne par UE + _add_apc_columns( + modimpl, + evals_poids, + ues, + rows, + columns_ids, + titles, + is_conforme, + row_coefs, + row_poids, + row_note_max, + row_moys, + keep_numeric, + ) + + # Ajoute colonnes emails tout à droite: + if with_emails: + columns_ids += ["email", "emailperso"] + # Ajoute lignes en tête et moyennes + if len(evals) > 0 and format != "bordereau": + rows_head = [row_coefs] + if is_apc: + rows_head.append(row_poids) + rows_head.append(row_note_max) + rows = rows_head + rows + rows.append(row_moys) + # ajout liens HTMl vers affichage une evaluation: + if format == "html" and len(evals) > 1: + rlinks = {"_table_part": "head"} + for e in evals: + rlinks[e["evaluation_id"]] = "afficher" + rlinks[ + "_" + str(e["evaluation_id"]) + "_help" + ] = "afficher seulement les notes de cette évaluation" + rlinks["_" + str(e["evaluation_id"]) + "_target"] = url_for( + "notes.evaluation_listenotes", + scodoc_dept=g.scodoc_dept, + evaluation_id=e["evaluation_id"], + ) + rlinks["_" + str(e["evaluation_id"]) + "_td_attrs"] = ' class="tdlink" ' + rows.append(rlinks) + + if len(evals) == 1: # colonne "Rem." seulement si une eval + if format == "html": # pas d'indication d'origine en pdf (pour affichage) + columns_ids.append("expl_key") + elif format == "xls" or format == "xml": + columns_ids.append("comment") + elif format == "bordereau": + columns_ids.append("signatures") + + # titres divers: + gl = "".join(["&group_ids%3Alist=" + str(g) for g in group_ids]) + if note_sur_20: + gl = "¬e_sur_20%3Alist=yes" + gl + if anonymous_listing: + gl = "&anonymous_listing%3Alist=yes" + gl + if hide_groups: + gl = "&hide_groups%3Alist=yes" + gl + if with_emails: + gl = "&with_emails%3Alist=yes" + gl + if len(evals) == 1: + evalname = "%s-%s" % (module.code, ndb.DateDMYtoISO(E["jour"])) + hh = "%s, %s (%d étudiants)" % (E["description"], gr_title, len(etudid_etats)) + filename = scu.make_filename("notes_%s_%s" % (evalname, gr_title_filename)) + + if format == "bordereau": + hh = " %d étudiants" % (len(etudid_etats)) + hh += " %d absent" % (nb_abs) + if nb_abs > 1: + hh += "s" + hh += ", %d en attente." % (nb_att) + + pdf_title = "
        BORDEREAU DE SIGNATURES" + pdf_title += "

        %(titre)s" % sem + pdf_title += "
        (%(mois_debut)s - %(mois_fin)s)" % sem + pdf_title += " semestre %s %s" % ( + sem["semestre_id"], + sem.get("modalite", ""), + ) + pdf_title += f"
        Notes du module {module.code} - {module.titre}" + pdf_title += "
        Evaluation : %(description)s " % e + if len(e["jour"]) > 0: + pdf_title += " (%(jour)s)" % e + pdf_title += "(noté sur %(note_max)s )

        " % e + else: + hh = " %s, %s (%d étudiants)" % ( + E["description"], + gr_title, + len(etudid_etats), + ) + if len(e["jour"]) > 0: + pdf_title = "%(description)s (%(jour)s)" % e + else: + pdf_title = "%(description)s " % e + + caption = hh + html_title = "" + base_url = "evaluation_listenotes?evaluation_id=%s" % E["evaluation_id"] + gl + html_next_section = ( + '
        %d absents, %d en attente.
        ' + % (nb_abs, nb_att) + ) + else: + filename = scu.make_filename("notes_%s_%s" % (module.code, gr_title_filename)) + title = f"Notes {module.type_name()} {module.code} {module.titre}" + title += " semestre %(titremois)s" % sem + if gr_title and gr_title != "tous": + title += " %s" % gr_title + caption = title + html_next_section = "" + if format == "pdf" or format == "bordereau": + caption = "" # same as pdf_title + pdf_title = title + html_title = f"""

        Notes {module.type_name()} {module.code} {module.titre}

        + """ + if not is_conforme: + html_title += ( + """
        Poids des évaluations non conformes !
        """ + ) + base_url = "evaluation_listenotes?moduleimpl_id=%s" % moduleimpl_id + gl + # display + tab = GenTable( + titles=titles, + columns_ids=columns_ids, + rows=rows, + html_sortable=True, + base_url=base_url, + filename=filename, + origin=f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}", + caption=caption, + html_next_section=html_next_section, + page_title="Notes de " + sem["titremois"], + html_title=html_title, + pdf_title=pdf_title, + html_class="notes_evaluation", + preferences=sco_preferences.SemPreferences(modimpl_o["formsemestre_id"]), + # html_generate_cells=False # la derniere ligne (moyennes) est incomplete + ) + if format == "bordereau": + format = "pdf" + t = tab.make_page(format=format, with_html_headers=False) + if format != "html": + return t + + if len(evals) > 1: + all_complete = True + for e in evals: + if not e["eval_state"]["evalcomplete"]: + all_complete = False + if all_complete: + eval_info = 'Evaluations prises en compte dans les moyennes.' + else: + eval_info = """ + Les évaluations en vert et orange sont prises en compte dans les moyennes. + Celles en rouge n'ont pas toutes leurs notes.""" + if is_apc: + eval_info += """ La moyenne indicative est la moyenne des moyennes d'UE, et n'est pas utilisée en BUT. + Les moyennes sur le groupe sont estimées sans les absents (sauf pour les moyennes des moyennes d'UE) ni les démissionnaires.""" + eval_info += """""" + return html_form + eval_info + t + "

        " + else: + # Une seule evaluation: ajoute histogramme + histo = histogram_notes(notes) + # 2 colonnes: histo, comments + C = [ + f'
        Bordereau de Signatures (version PDF)', + "

        Répartition des notes:

        " - + histo - + "

        ', - ] - commentkeys = list(key_mgr.items()) # [ (comment, key), ... ] - commentkeys.sort(key=lambda x: int(x[1])) - for (comment, key) in commentkeys: - C.append( - '(%s) %s
        ' % (key, comment) - ) - if commentkeys: - C.append( - 'Gérer les opérations
        ' - % E["evaluation_id"] - ) - eval_info = "xxx" - if E["eval_state"]["evalcomplete"]: - eval_info = 'Evaluation prise en compte dans les moyennes' - elif E["eval_state"]["evalattente"]: - eval_info = 'Il y a des notes en attente (les autres sont prises en compte)' - else: - eval_info = 'Notes incomplètes, évaluation non prise en compte dans les moyennes' - - return ( - sco_evaluations.evaluation_describe(evaluation_id=E["evaluation_id"]) - + eval_info - + html_form - + t - + "\n".join(C) - ) - - -def _add_eval_columns( - e, - evals_poids, - ues, - rows, - titles, - row_coefs, - row_poids, - row_note_max, - row_moys, - is_apc, - K, - note_sur_20, - keep_numeric, - format="html", -): - """Add eval e""" - nb_notes = 0 - nb_abs = 0 - nb_att = 0 - sum_notes = 0 - notes = [] # liste des notes numeriques, pour calcul histogramme uniquement - evaluation_id = e["evaluation_id"] - e_o = Evaluation.query.get(evaluation_id) # XXX en attendant ré-écriture - inscrits = e_o.moduleimpl.formsemestre.etudids_actifs # set d'etudids - notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) - - if len(e["jour"]) > 0: - titles[evaluation_id] = "%(description)s (%(jour)s)" % e - else: - titles[evaluation_id] = "%(description)s " % e - - if e["eval_state"]["evalcomplete"]: - klass = "eval_complete" - elif e["eval_state"]["evalattente"]: - klass = "eval_attente" - else: - klass = "eval_incomplete" - titles[evaluation_id] += " (non prise en compte)" - titles[f"_{evaluation_id}_td_attrs"] = f'class="{klass}"' - - for row in rows: - etudid = row["etudid"] - if etudid in notes_db: - val = notes_db[etudid]["value"] - if val is None: - nb_abs += 1 - if val == scu.NOTES_ATTENTE: - nb_att += 1 - # calcul moyenne SANS LES ABSENTS ni les DEMISSIONNAIRES - if ( - (etudid in inscrits) - and val is not None - and val != scu.NOTES_NEUTRALISE - and val != scu.NOTES_ATTENTE - ): - if e["note_max"] > 0: - valsur20 = val * 20.0 / e["note_max"] # remet sur 20 - else: - valsur20 = 0 - notes.append(valsur20) # toujours sur 20 pour l'histogramme - if note_sur_20: - val = valsur20 # affichage notes / 20 demandé - nb_notes = nb_notes + 1 - sum_notes += val - val_fmt = scu.fmt_note(val, keep_numeric=keep_numeric) - comment = notes_db[etudid]["comment"] - if comment is None: - comment = "" - explanation = "%s (%s) %s" % ( - notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M"), - sco_users.user_info(notes_db[etudid]["uid"])["nomcomplet"], - comment, - ) - else: - if (etudid in inscrits) and e["publish_incomplete"]: - # Note manquante mais prise en compte immédiate: affiche ATT - val = scu.NOTES_ATTENTE - val_fmt = "ATT" - explanation = "non saisie mais prise en compte immédiate" - else: - explanation = "" - val_fmt = "" - val = None - - cell_class = klass + {"ATT": " att", "ABS": " abs", "EXC": " exc"}.get( - val_fmt, "" - ) - - if val is None: - row[f"_{evaluation_id}_td_attrs"] = f'class="etudabs {cell_class}" ' - if not row.get("_css_row_class", ""): - row["_css_row_class"] = "etudabs" - else: - row[f"_{evaluation_id}_td_attrs"] = f'class="{cell_class}" ' - # regroupe les commentaires - if explanation: - if explanation in K: - expl_key = "(%s)" % K[explanation] - else: - K[explanation] = K.nextkey() - expl_key = "(%s)" % K[explanation] - else: - expl_key = "" - - row.update( - { - evaluation_id: val_fmt, - "_" + str(evaluation_id) + "_help": explanation, - # si plusieurs evals seront ecrasés et non affichés: - "comment": explanation, - "expl_key": expl_key, - "_expl_key_help": explanation, - } - ) - - row_coefs[evaluation_id] = "coef. %s" % e["coefficient"] - if is_apc: - if format == "html": - row_poids[evaluation_id] = _mini_table_eval_ue_poids( - evaluation_id, evals_poids, ues - ) - else: - row_poids[evaluation_id] = e_o.get_ue_poids_str() - if note_sur_20: - nmax = 20.0 - else: - nmax = e["note_max"] - if keep_numeric: - row_note_max[evaluation_id] = nmax - else: - row_note_max[evaluation_id] = "/ %s" % nmax - - if nb_notes > 0: - row_moys[evaluation_id] = scu.fmt_note( - sum_notes / nb_notes, keep_numeric=keep_numeric - ) - row_moys[ - "_" + str(evaluation_id) + "_help" - ] = "moyenne sur %d notes (%s le %s)" % ( - nb_notes, - e["description"], - e["jour"], - ) - else: - row_moys[evaluation_id] = "" - - return notes, nb_abs, nb_att # pour histogramme - - -def _mini_table_eval_ue_poids(evaluation_id, evals_poids, ues): - "contenu de la cellule: poids" - return ( - """" - + "
        """ - + "".join([f"{ue.acronyme}" for ue in ues]) - + "
        " - + "".join([f"{evals_poids[ue.id][evaluation_id]}" for ue in ues]) - + "
        " - ) - - -def _add_moymod_column( - formsemestre_id, - moduleimpl_id, - rows, - columns_ids, - titles, - row_coefs, - row_poids, - row_note_max, - row_moys, - is_apc, - keep_numeric, -): - """Ajoute la colonne moymod à rows""" - col_id = "moymod" - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - inscrits = formsemestre.etudids_actifs - - nb_notes = 0 - sum_notes = 0 - notes = [] # liste des notes numeriques, pour calcul histogramme uniquement - for row in rows: - etudid = row["etudid"] - val = nt.get_etud_mod_moy(moduleimpl_id, etudid) # note sur 20, ou 'NA','NI' - row[col_id] = scu.fmt_note(val, keep_numeric=keep_numeric) - row["_" + col_id + "_td_attrs"] = ' class="moyenne" ' - if etudid in inscrits and not isinstance(val, str): - notes.append(val) - nb_notes = nb_notes + 1 - sum_notes += val - row_coefs[col_id] = "(avec abs)" - if is_apc: - row_poids[col_id] = "à titre indicatif" - if keep_numeric: - row_note_max[col_id] = 20.0 - else: - row_note_max[col_id] = "/ 20" - titles[col_id] = "Moyenne module" - columns_ids.append(col_id) - if nb_notes > 0: - row_moys[col_id] = "%.3g" % (sum_notes / nb_notes) - row_moys["_" + col_id + "_help"] = "moyenne des moyennes" - else: - row_moys[col_id] = "" - - -def _add_apc_columns( - modimpl, - evals_poids, - ues, - rows, - columns_ids, - titles, - is_conforme: bool, - row_coefs, - row_poids, - row_note_max, - row_moys, - keep_numeric, -): - """Ajoute les colonnes moyennes vers les UE""" - # On raccorde ici les nouveaux calculs de notes (BUT 2021) - # sur l'ancien code ScoDoc - # => On recharge tout dans les nouveaux modèles - # rows est une liste de dict avec une clé "etudid" - # on va y ajouter une clé par UE du semestre - nt: ResultatsSemestreBUT = res_sem.load_formsemestre_results(modimpl.formsemestre) - modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl.id] - inscrits = modimpl.formsemestre.etudids_actifs - # les UE dans lesquelles ce module a un coef non nul: - ues_with_coef = nt.modimpl_coefs_df[modimpl.id][ - nt.modimpl_coefs_df[modimpl.id] > 0 - ].index - ues = [ue for ue in ues if ue.id in ues_with_coef] - sum_by_ue = defaultdict(float) - nb_notes_by_ue = defaultdict(int) - if is_conforme: - # valeur des moyennes vers les UEs: - for row in rows: - for ue in ues: - moy_ue = modimpl_results.etuds_moy_module[ue.id].get(row["etudid"], "?") - row[f"moy_ue_{ue.id}"] = scu.fmt_note(moy_ue, keep_numeric=keep_numeric) - row[f"_moy_ue_{ue.id}_class"] = "moy_ue" - if ( - isinstance(moy_ue, float) - and not np.isnan(moy_ue) - and row["etudid"] in inscrits - ): - sum_by_ue[ue.id] += moy_ue - nb_notes_by_ue[ue.id] += 1 - # Nom et coefs des UE (lignes titres): - ue_coefs = modimpl.module.ue_coefs - if is_conforme: - coef_class = "coef_mod_ue" - else: - coef_class = "coef_mod_ue_non_conforme" - for ue in ues: - col_id = f"moy_ue_{ue.id}" - titles[col_id] = ue.acronyme - columns_ids.append(col_id) - coefs = [uc for uc in ue_coefs if uc.ue_id == ue.id] - if coefs: - row_coefs[f"moy_ue_{ue.id}"] = coefs[0].coef - row_coefs[f"_moy_ue_{ue.id}_td_attrs"] = f' class="{coef_class}" ' - if nb_notes_by_ue[ue.id] > 0: - row_moys[col_id] = "%.3g" % (sum_by_ue[ue.id] / nb_notes_by_ue[ue.id]) - row_moys["_" + col_id + "_help"] = "moyenne des moyennes" - else: - row_moys[col_id] = "" +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Liste des notes d'une évaluation +""" +from collections import defaultdict +import numpy as np + +import flask +from flask import url_for, g, request + +from app import log +from app import models +from app.comp import res_sem +from app.comp import moy_mod +from app.comp.moy_mod import ModuleImplResults +from app.comp.res_compat import NotesTableCompat +from app.comp.res_but import ResultatsSemestreBUT +from app.models import FormSemestre +from app.models.etudiants import Identite +from app.models.evaluations import Evaluation +from app.models.moduleimpls import ModuleImpl +import app.scodoc.notesdb as ndb +from app.scodoc.TrivialFormulator import TrivialFormulator + +from app.scodoc.sco_etud import etud_sort_key +from app.scodoc import sco_evaluations +from app.scodoc import sco_evaluation_db +from app.scodoc import sco_formsemestre +from app.scodoc import sco_groups +from app.scodoc import sco_moduleimpl +from app.scodoc import sco_preferences +from app.scodoc import sco_users +import app.scodoc.sco_utils as scu +import sco_version +from app.scodoc.gen_tables import GenTable +from app.scodoc.htmlutils import histogram_notes + + +def do_evaluation_listenotes( + evaluation_id=None, moduleimpl_id=None, format="html" +) -> tuple[str, str]: + """ + Affichage des notes d'une évaluation (si evaluation_id) + ou de toutes les évaluations d'un module (si moduleimpl_id) + """ + mode = None + if moduleimpl_id: + mode = "module" + evals = sco_evaluation_db.do_evaluation_list({"moduleimpl_id": moduleimpl_id}) + elif evaluation_id: + mode = "eval" + evals = sco_evaluation_db.do_evaluation_list({"evaluation_id": evaluation_id}) + else: + raise ValueError("missing argument: evaluation or module") + if not evals: + return "

        Aucune évaluation !

        ", f"ScoDoc" + + E = evals[0] # il y a au moins une evaluation + modimpl = ModuleImpl.query.get(E["moduleimpl_id"]) + # description de l'evaluation + if mode == "eval": + H = [sco_evaluations.evaluation_describe(evaluation_id=evaluation_id)] + page_title = f"Notes {E['description'] or modimpl.module.code}" + else: + H = [] + page_title = f"Notes {modimpl.module.code}" + # groupes + groups = sco_groups.do_evaluation_listegroupes( + E["evaluation_id"], include_default=True + ) + grlabs = [g["group_name"] or "tous" for g in groups] # legendes des boutons + grnams = [str(g["group_id"]) for g in groups] # noms des checkbox + + if len(evals) > 1: + descr = [ + ("moduleimpl_id", {"default": E["moduleimpl_id"], "input_type": "hidden"}) + ] + else: + descr = [ + ("evaluation_id", {"default": E["evaluation_id"], "input_type": "hidden"}) + ] + if len(grnams) > 1: + descr += [ + ( + "s", + { + "input_type": "separator", + "title": "Choix du ou des groupes d'étudiants:", + }, + ), + ( + "group_ids", + { + "input_type": "checkbox", + "title": "", + "allowed_values": grnams, + "labels": grlabs, + "attributes": ('onclick="document.tf.submit();"',), + }, + ), + ] + else: + if grnams: + def_nam = grnams[0] + else: + def_nam = "" + descr += [ + ( + "group_ids", + {"input_type": "hidden", "type": "list", "default": [def_nam]}, + ) + ] + descr += [ + ( + "anonymous_listing", + { + "input_type": "checkbox", + "title": "", + "allowed_values": ("yes",), + "labels": ('listing "anonyme"',), + "attributes": ('onclick="document.tf.submit();"',), + "template": '
        %(label)s%(elem)s   ', + }, + ), + ( + "note_sur_20", + { + "input_type": "checkbox", + "title": "", + "allowed_values": ("yes",), + "labels": ("notes sur 20",), + "attributes": ('onclick="document.tf.submit();"',), + "template": "%(elem)s   ", + }, + ), + ( + "hide_groups", + { + "input_type": "checkbox", + "title": "", + "allowed_values": ("yes",), + "labels": ("masquer les groupes",), + "attributes": ('onclick="document.tf.submit();"',), + "template": "%(elem)s   ", + }, + ), + ( + "with_emails", + { + "input_type": "checkbox", + "title": "", + "allowed_values": ("yes",), + "labels": ("montrer les e-mails",), + "attributes": ('onclick="document.tf.submit();"',), + "template": "%(elem)s
        \n", + '' - % ( - etudid, - a["id"], - scu.icontag( - "delete_img", - border="0", - alt="suppress", - title="Supprimer cette annotation", - ), - ) - ) - author = sco_users.user_info(a["author"]) - alist.append( - f"""{a['dellink']} - """ - ) - info["liste_annotations"] = "\n".join(alist) - # fiche admission - has_adm_notes = ( - info["math"] or info["physique"] or info["anglais"] or info["francais"] - ) - has_bac_info = ( - info["bac"] - or info["specialite"] - or info["annee_bac"] - or info["rapporteur"] - or info["commentaire"] - or info["classement"] - or info["type_admission"] - ) - if has_bac_info or has_adm_notes: - adm_tmpl = """ -
        Informations admission
        -""" - if has_adm_notes: - adm_tmpl += """ -

        Répartition des notes:

        " + + histo + + "

        ', + ] + commentkeys = list(key_mgr.items()) # [ (comment, key), ... ] + commentkeys.sort(key=lambda x: int(x[1])) + for (comment, key) in commentkeys: + C.append( + '(%s) %s
        ' % (key, comment) + ) + if commentkeys: + C.append( + 'Gérer les opérations
        ' + % E["evaluation_id"] + ) + eval_info = "xxx" + if E["eval_state"]["evalcomplete"]: + eval_info = 'Evaluation prise en compte dans les moyennes' + elif E["eval_state"]["evalattente"]: + eval_info = 'Il y a des notes en attente (les autres sont prises en compte)' + else: + eval_info = 'Notes incomplètes, évaluation non prise en compte dans les moyennes' + + return ( + sco_evaluations.evaluation_describe(evaluation_id=E["evaluation_id"]) + + eval_info + + html_form + + t + + "\n".join(C) + ) + + +def _add_eval_columns( + e, + evals_poids, + ues, + rows, + titles, + row_coefs, + row_poids, + row_note_max, + row_moys, + is_apc, + K, + note_sur_20, + keep_numeric, + format="html", +): + """Add eval e""" + nb_notes = 0 + nb_abs = 0 + nb_att = 0 + sum_notes = 0 + notes = [] # liste des notes numeriques, pour calcul histogramme uniquement + evaluation_id = e["evaluation_id"] + e_o = Evaluation.query.get(evaluation_id) # XXX en attendant ré-écriture + inscrits = e_o.moduleimpl.formsemestre.etudids_actifs # set d'etudids + notes_db = sco_evaluation_db.do_evaluation_get_all_notes(evaluation_id) + + if len(e["jour"]) > 0: + titles[evaluation_id] = "%(description)s (%(jour)s)" % e + else: + titles[evaluation_id] = "%(description)s " % e + + if e["eval_state"]["evalcomplete"]: + klass = "eval_complete" + elif e["eval_state"]["evalattente"]: + klass = "eval_attente" + else: + klass = "eval_incomplete" + titles[evaluation_id] += " (non prise en compte)" + titles[f"_{evaluation_id}_td_attrs"] = f'class="{klass}"' + + for row in rows: + etudid = row["etudid"] + if etudid in notes_db: + val = notes_db[etudid]["value"] + if val is None: + nb_abs += 1 + if val == scu.NOTES_ATTENTE: + nb_att += 1 + # calcul moyenne SANS LES ABSENTS ni les DEMISSIONNAIRES + if ( + (etudid in inscrits) + and val is not None + and val != scu.NOTES_NEUTRALISE + and val != scu.NOTES_ATTENTE + ): + if e["note_max"] > 0: + valsur20 = val * 20.0 / e["note_max"] # remet sur 20 + else: + valsur20 = 0 + notes.append(valsur20) # toujours sur 20 pour l'histogramme + if note_sur_20: + val = valsur20 # affichage notes / 20 demandé + nb_notes = nb_notes + 1 + sum_notes += val + val_fmt = scu.fmt_note(val, keep_numeric=keep_numeric) + comment = notes_db[etudid]["comment"] + if comment is None: + comment = "" + explanation = "%s (%s) %s" % ( + notes_db[etudid]["date"].strftime("%d/%m/%y %Hh%M"), + sco_users.user_info(notes_db[etudid]["uid"])["nomcomplet"], + comment, + ) + else: + if (etudid in inscrits) and e["publish_incomplete"]: + # Note manquante mais prise en compte immédiate: affiche ATT + val = scu.NOTES_ATTENTE + val_fmt = "ATT" + explanation = "non saisie mais prise en compte immédiate" + else: + explanation = "" + val_fmt = "" + val = None + + cell_class = klass + {"ATT": " att", "ABS": " abs", "EXC": " exc"}.get( + val_fmt, "" + ) + + if val is None: + row[f"_{evaluation_id}_td_attrs"] = f'class="etudabs {cell_class}" ' + if not row.get("_css_row_class", ""): + row["_css_row_class"] = "etudabs" + else: + row[f"_{evaluation_id}_td_attrs"] = f'class="{cell_class}" ' + # regroupe les commentaires + if explanation: + if explanation in K: + expl_key = "(%s)" % K[explanation] + else: + K[explanation] = K.nextkey() + expl_key = "(%s)" % K[explanation] + else: + expl_key = "" + + row.update( + { + evaluation_id: val_fmt, + "_" + str(evaluation_id) + "_help": explanation, + # si plusieurs evals seront ecrasés et non affichés: + "comment": explanation, + "expl_key": expl_key, + "_expl_key_help": explanation, + } + ) + + row_coefs[evaluation_id] = "coef. %s" % e["coefficient"] + if is_apc: + if format == "html": + row_poids[evaluation_id] = _mini_table_eval_ue_poids( + evaluation_id, evals_poids, ues + ) + else: + row_poids[evaluation_id] = e_o.get_ue_poids_str() + if note_sur_20: + nmax = 20.0 + else: + nmax = e["note_max"] + if keep_numeric: + row_note_max[evaluation_id] = nmax + else: + row_note_max[evaluation_id] = "/ %s" % nmax + + if nb_notes > 0: + row_moys[evaluation_id] = scu.fmt_note( + sum_notes / nb_notes, keep_numeric=keep_numeric + ) + row_moys[ + "_" + str(evaluation_id) + "_help" + ] = "moyenne sur %d notes (%s le %s)" % ( + nb_notes, + e["description"], + e["jour"], + ) + else: + row_moys[evaluation_id] = "" + + return notes, nb_abs, nb_att # pour histogramme + + +def _mini_table_eval_ue_poids(evaluation_id, evals_poids, ues): + "contenu de la cellule: poids" + return ( + """" + + "
        """ + + "".join([f"{ue.acronyme}" for ue in ues]) + + "
        " + + "".join([f"{evals_poids[ue.id][evaluation_id]}" for ue in ues]) + + "
        " + ) + + +def _add_moymod_column( + formsemestre_id, + moduleimpl_id, + rows, + columns_ids, + titles, + row_coefs, + row_poids, + row_note_max, + row_moys, + is_apc, + keep_numeric, +): + """Ajoute la colonne moymod à rows""" + col_id = "moymod" + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + inscrits = formsemestre.etudids_actifs + + nb_notes = 0 + sum_notes = 0 + notes = [] # liste des notes numeriques, pour calcul histogramme uniquement + for row in rows: + etudid = row["etudid"] + val = nt.get_etud_mod_moy(moduleimpl_id, etudid) # note sur 20, ou 'NA','NI' + row[col_id] = scu.fmt_note(val, keep_numeric=keep_numeric) + row["_" + col_id + "_td_attrs"] = ' class="moyenne" ' + if etudid in inscrits and not isinstance(val, str): + notes.append(val) + nb_notes = nb_notes + 1 + sum_notes += val + row_coefs[col_id] = "(avec abs)" + if is_apc: + row_poids[col_id] = "à titre indicatif" + if keep_numeric: + row_note_max[col_id] = 20.0 + else: + row_note_max[col_id] = "/ 20" + titles[col_id] = "Moyenne module" + columns_ids.append(col_id) + if nb_notes > 0: + row_moys[col_id] = "%.3g" % (sum_notes / nb_notes) + row_moys["_" + col_id + "_help"] = "moyenne des moyennes" + else: + row_moys[col_id] = "" + + +def _add_apc_columns( + modimpl, + evals_poids, + ues, + rows, + columns_ids, + titles, + is_conforme: bool, + row_coefs, + row_poids, + row_note_max, + row_moys, + keep_numeric, +): + """Ajoute les colonnes moyennes vers les UE""" + # On raccorde ici les nouveaux calculs de notes (BUT 2021) + # sur l'ancien code ScoDoc + # => On recharge tout dans les nouveaux modèles + # rows est une liste de dict avec une clé "etudid" + # on va y ajouter une clé par UE du semestre + nt: ResultatsSemestreBUT = res_sem.load_formsemestre_results(modimpl.formsemestre) + modimpl_results: ModuleImplResults = nt.modimpls_results[modimpl.id] + inscrits = modimpl.formsemestre.etudids_actifs + # les UE dans lesquelles ce module a un coef non nul: + ues_with_coef = nt.modimpl_coefs_df[modimpl.id][ + nt.modimpl_coefs_df[modimpl.id] > 0 + ].index + ues = [ue for ue in ues if ue.id in ues_with_coef] + sum_by_ue = defaultdict(float) + nb_notes_by_ue = defaultdict(int) + if is_conforme: + # valeur des moyennes vers les UEs: + for row in rows: + for ue in ues: + moy_ue = modimpl_results.etuds_moy_module[ue.id].get(row["etudid"], "?") + row[f"moy_ue_{ue.id}"] = scu.fmt_note(moy_ue, keep_numeric=keep_numeric) + row[f"_moy_ue_{ue.id}_class"] = "moy_ue" + if ( + isinstance(moy_ue, float) + and not np.isnan(moy_ue) + and row["etudid"] in inscrits + ): + sum_by_ue[ue.id] += moy_ue + nb_notes_by_ue[ue.id] += 1 + # Nom et coefs des UE (lignes titres): + ue_coefs = modimpl.module.ue_coefs + if is_conforme: + coef_class = "coef_mod_ue" + else: + coef_class = "coef_mod_ue_non_conforme" + for ue in ues: + col_id = f"moy_ue_{ue.id}" + titles[col_id] = ue.acronyme + columns_ids.append(col_id) + coefs = [uc for uc in ue_coefs if uc.ue_id == ue.id] + if coefs: + row_coefs[f"moy_ue_{ue.id}"] = coefs[0].coef + row_coefs[f"_moy_ue_{ue.id}_td_attrs"] = f' class="{coef_class}" ' + if nb_notes_by_ue[ue.id] > 0: + row_moys[col_id] = "%.3g" % (sum_by_ue[ue.id] / nb_notes_by_ue[ue.id]) + row_moys["_" + col_id + "_help"] = "moyenne des moyennes" + else: + row_moys[col_id] = "" diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 6fe13316..12828e87 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -1,631 +1,631 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""ScoDoc ficheEtud - - Fiche description d'un étudiant et de son parcours - -""" -from flask import url_for, g, request -from flask_login import current_user - -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app import log -from app.but import jury_but_view -from app.models.etudiants import make_etud_args -from app.scodoc import html_sco_header -from app.scodoc import htmlutils -from app.scodoc import sco_archives_etud -from app.scodoc import sco_bac -from app.scodoc import sco_codes_parcours -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_status -from app.scodoc import sco_groups -from app.scodoc import sco_cursus -from app.scodoc import sco_permissions_check -from app.scodoc import sco_photos -from app.scodoc import sco_users -from app.scodoc import sco_report -from app.scodoc import sco_etud -from app.scodoc.sco_bulletins import etud_descr_situation_semestre -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table -from app.scodoc.sco_permissions import Permission - - -def _menuScolarite(authuser, sem, etudid): - """HTML pour menu "scolarite" pour un etudiant dans un semestre. - Le contenu du menu depend des droits de l'utilisateur et de l'état de l'étudiant. - """ - locked = not sem["etat"] - if locked: - lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") - return lockicon # no menu - if not authuser.has_permission( - Permission.ScoEtudInscrit - ) and not authuser.has_permission(Permission.ScoEtudChangeGroups): - return "" # no menu - ins = sem["ins"] - args = {"etudid": etudid, "formsemestre_id": ins["formsemestre_id"]} - - if ins["etat"] != "D": - dem_title = "Démission" - dem_url = "scolar.formDem" - else: - dem_title = "Annuler la démission" - dem_url = "scolar.doCancelDem" - - # Note: seul un etudiant inscrit (I) peut devenir défaillant. - if ins["etat"] != sco_codes_parcours.DEF: - def_title = "Déclarer défaillance" - def_url = "scolar.formDef" - elif ins["etat"] == sco_codes_parcours.DEF: - def_title = "Annuler la défaillance" - def_url = "scolar.doCancelDef" - def_enabled = ( - (ins["etat"] != "D") - and authuser.has_permission(Permission.ScoEtudInscrit) - and not locked - ) - items = [ - { - "title": dem_title, - "endpoint": dem_url, - "args": args, - "enabled": authuser.has_permission(Permission.ScoEtudInscrit) - and not locked, - }, - { - "title": "Validation du semestre (jury)", - "endpoint": "notes.formsemestre_validation_etud_form", - "args": args, - "enabled": authuser.has_permission(Permission.ScoEtudInscrit) - and not locked, - }, - { - "title": def_title, - "endpoint": def_url, - "args": args, - "enabled": def_enabled, - }, - { - "title": "Inscrire à un module optionnel (ou au sport)", - "endpoint": "notes.formsemestre_inscription_option", - "args": args, - "enabled": authuser.has_permission(Permission.ScoEtudInscrit) - and not locked, - }, - { - "title": "Désinscrire (en cas d'erreur)", - "endpoint": "notes.formsemestre_desinscription", - "args": args, - "enabled": authuser.has_permission(Permission.ScoEtudInscrit) - and not locked, - }, - { - "title": "Inscrire à un autre semestre", - "endpoint": "notes.formsemestre_inscription_with_modules_form", - "args": {"etudid": etudid}, - "enabled": authuser.has_permission(Permission.ScoEtudInscrit), - }, - { - "title": "Enregistrer un semestre effectué ailleurs", - "endpoint": "notes.formsemestre_ext_create_form", - "args": args, - "enabled": authuser.has_permission(Permission.ScoImplement), - }, - ] - - return htmlutils.make_menu( - "Scolarité", items, css_class="direction_etud", alone=True - ) - - -def ficheEtud(etudid=None): - "fiche d'informations sur un etudiant" - authuser = current_user - cnx = ndb.GetDBConnexion() - if etudid: - try: # pour les bookmarks avec d'anciens ids... - etudid = int(etudid) - except ValueError: - raise ScoValueError("id invalide !") from ValueError - # la sidebar est differente s'il y a ou pas un etudid - # voir html_sidebar.sidebar() - g.etudid = etudid - args = make_etud_args(etudid=etudid) - etuds = sco_etud.etudident_list(cnx, args) - if not etuds: - log(f"ficheEtud: etudid={etudid!r} request.args={request.args!r}") - raise ScoValueError("Étudiant inexistant !") - etud = etuds[0] - etudid = etud["etudid"] - sco_etud.fill_etuds_info([etud]) - # - info = etud - info["ScoURL"] = scu.ScoURL() - info["authuser"] = authuser - info["info_naissance"] = info["date_naissance"] - if info["lieu_naissance"]: - info["info_naissance"] += " à " + info["lieu_naissance"] - if info["dept_naissance"]: - info["info_naissance"] += f" ({info['dept_naissance']})" - info["etudfoto"] = sco_photos.etud_photo_html(etud) - if ( - (not info["domicile"]) - and (not info["codepostaldomicile"]) - and (not info["villedomicile"]) - ): - info["domicile"] = "inconnue" - if info["paysdomicile"]: - pays = sco_etud.format_pays(info["paysdomicile"]) - if pays: - info["paysdomicile"] = "(%s)" % pays - else: - info["paysdomicile"] = "" - if info["telephone"] or info["telephonemobile"]: - info["telephones"] = "
        %s    %s" % ( - info["telephonestr"], - info["telephonemobilestr"], - ) - else: - info["telephones"] = "" - # e-mail: - if info["email_default"]: - info["emaillink"] = ", ".join( - [ - '%s' % (m, m) - for m in [etud["email"], etud["emailperso"]] - if m - ] - ) - else: - info["emaillink"] = "(pas d'adresse e-mail)" - # Champ dépendant des permissions: - if authuser.has_permission(Permission.ScoEtudChangeAdr): - info[ - "modifadresse" - ] = f"""modifier adresse""" - else: - info["modifadresse"] = "" - - # Groupes: - sco_groups.etud_add_group_infos( - info, - info["cursem"]["formsemestre_id"] if info["cursem"] else None, - only_to_show=True, - ) - # Parcours de l'étudiant - if info["sems"]: - info["last_formsemestre_id"] = info["sems"][0]["formsemestre_id"] - else: - info["last_formsemestre_id"] = "" - sem_info = {} - for sem in info["sems"]: - if sem["ins"]["etat"] != "I": - descr, _ = etud_descr_situation_semestre( - etudid, - sem["formsemestre_id"], - info["ne"], - show_date_inscr=False, - ) - grlink = f"""{descr["situation"]}""" - else: - e = {"etudid": etudid} - sco_groups.etud_add_group_infos( - e, - sem["formsemestre_id"], - only_to_show=True, - ) - - grlinks = [] - for partition in e["partitions"].values(): - if partition["partition_name"]: - gr_name = partition["group_name"] - else: - gr_name = "tous" - - grlinks.append( - f"""{gr_name} - """ - ) - grlink = ", ".join(grlinks) - # infos ajoutées au semestre dans le parcours (groupe, menu) - menu = _menuScolarite(authuser, sem, etudid) - if menu: - sem_info[sem["formsemestre_id"]] = ( - "
        " + grlink + "" + menu + "
        " - ) - else: - sem_info[sem["formsemestre_id"]] = grlink - - if info["sems"]: - Se = sco_cursus.get_situation_etud_cursus(etud, info["last_formsemestre_id"]) - info["liste_inscriptions"] = formsemestre_recap_parcours_table( - Se, - etudid, - with_links=False, - sem_info=sem_info, - with_all_columns=False, - a_url="Notes/", - ) - info[ - "link_bul_pdf" - ] = f"""tous les bulletins""" - if authuser.has_permission(Permission.ScoEtudInscrit): - info[ - "link_inscrire_ailleurs" - ] = f"""inscrire à un autre semestre""" - else: - info["link_inscrire_ailleurs"] = "" - else: - # non inscrit - l = [f"""

        Étudiant{info["ne"]} non inscrit{info["ne"]}"""] - if authuser.has_permission(Permission.ScoEtudInscrit): - l.append( - f"""inscrire""" - ) - l.append("") - info["liste_inscriptions"] = "\n".join(l) - info["link_bul_pdf"] = "" - info["link_inscrire_ailleurs"] = "" - - # Liste des annotations - alist = [] - annos = sco_etud.etud_annotations_list(cnx, args={"etudid": etudid}) - for a in annos: - if not sco_permissions_check.can_suppress_annotation(a["id"]): - a["dellink"] = "" - else: - a["dellink"] = ( - '

        %s
        Le {a['date']} par {author['prenomnom']} : - {a['comment']}
        - - - - - - - - -
        BacAnnéeRgMathPhysiqueAnglaisFrançais
        %(bac)s (%(specialite)s)%(annee_bac)s %(classement)s%(math)s%(physique)s%(anglais)s%(francais)s
        -""" - adm_tmpl += """ -
        Bac %(bac)s (%(specialite)s) obtenu en %(annee_bac)s
        -
        %(ilycee)s
        """ - if info["type_admission"] or info["classement"]: - adm_tmpl += """
        """ - if info["type_admission"]: - adm_tmpl += """Voie d'admission: %(type_admission)s """ - if info["classement"]: - adm_tmpl += """Rang admission: %(classement)s""" - if info["type_admission"] or info["classement"]: - adm_tmpl += "
        " - if info["rap"]: - adm_tmpl += """
        %(rap)s
        """ - adm_tmpl += """""" - else: - adm_tmpl = "" # pas de boite "info admission" - info["adm_data"] = adm_tmpl % info - - # Fichiers archivés: - info["fichiers_archive_htm"] = ( - '
        Fichiers associés
        ' - + sco_archives_etud.etud_list_archives_html(etudid) - ) - - # Devenir de l'étudiant: - has_debouche = True - if sco_permissions_check.can_edit_suivi(): - suivi_readonly = "0" - link_add_suivi = """
      • - ajouter une ligne -
      • """ - else: - suivi_readonly = "1" - link_add_suivi = "" - if has_debouche: - info[ - "debouche_html" - ] = """
        - Devenir: -
        -
          - %s -
        -
        -
        """ % ( - suivi_readonly, - info["etudid"], - link_add_suivi, - ) - else: - info["debouche_html"] = "" # pas de boite "devenir" - # - if info["liste_annotations"]: - info["tit_anno"] = '
        Annotations
        ' - else: - info["tit_anno"] = "" - # Inscriptions - # if info["sems"]: # XXX rcl unused ? à voir - # rcl = ( - # """(récapitulatif parcours)""" - # % info - # ) - # else: - # rcl = "" - info[ - "inscriptions_mkup" - ] = """
        -
        Parcours
        %s -%s %s -
        """ % ( - info["liste_inscriptions"], - info["link_bul_pdf"], - info["link_inscrire_ailleurs"], - ) - - # - if info["groupes"].strip(): - info[ - "groupes_row" - ] = f""" - Groupes :{info['groupes']} - """ - else: - info["groupes_row"] = "" - info["menus_etud"] = menus_etud(etudid) - - # raccordement provisoire pour juillet 2022, avant refonte complète de cette fiche... - info["but_infos_mkup"] = jury_but_view.infos_fiche_etud_html(etudid) - - tmpl = """ -
        -
        -

        %(nomprenom)s (%(inscription)s)

        - -%(emaillink)s -
        -%(etudfoto)s -
        - -
        -
        - - -%(groupes_row)s - -
        Situation :%(situation)s
        Né%(ne)s le :%(info_naissance)s
        - - - -
        - -
        Adresse : %(domicile)s %(codepostaldomicile)s %(villedomicile)s %(paysdomicile)s -%(modifadresse)s -%(telephones)s -
        -
        -
        -
        - -%(inscriptions_mkup)s - -%(but_infos_mkup)s - -
        -%(adm_data)s - -%(fichiers_archive_htm)s -
        - -%(debouche_html)s - -
        -%(tit_anno)s -%(liste_annotations)s
        - -
        - -Ajouter une annotation sur %(nomprenom)s: - - - -
        -
        -Ces annotations sont lisibles par tous les enseignants et le secrétariat. -
        -L'annotation commençant par "PE:" est un avis de poursuite d'études. -
        -
        - -
        -
        -
        - -
        code NIP: %(code_nip)s
        - -
        - """ - header = html_sco_header.sco_header( - page_title="Fiche étudiant %(prenom)s %(nom)s" % info, - cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css", "css/jury_but.css"], - javascripts=[ - "libjs/jinplace-1.2.1.min.js", - "js/ue_list.js", - "libjs/jQuery-tagEditor/jquery.tag-editor.min.js", - "libjs/jQuery-tagEditor/jquery.caret.min.js", - "js/recap_parcours.js", - "js/etud_debouche.js", - ], - ) - return header + tmpl % info + html_sco_header.sco_footer() - - -def menus_etud(etudid): - """Menu etudiant (operations sur l'etudiant)""" - authuser = current_user - - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - - menuEtud = [ - { - "title": etud["nomprenom"], - "endpoint": "scolar.ficheEtud", - "args": {"etudid": etud["etudid"]}, - "enabled": True, - "helpmsg": "Fiche étudiant", - }, - { - "title": "Changer la photo", - "endpoint": "scolar.formChangePhoto", - "args": {"etudid": etud["etudid"]}, - "enabled": authuser.has_permission(Permission.ScoEtudChangeAdr), - }, - { - "title": "Changer les données identité/admission", - "endpoint": "scolar.etudident_edit_form", - "args": {"etudid": etud["etudid"]}, - "enabled": authuser.has_permission(Permission.ScoEtudInscrit), - }, - { - "title": "Supprimer cet étudiant...", - "endpoint": "scolar.etudident_delete", - "args": {"etudid": etud["etudid"]}, - "enabled": authuser.has_permission(Permission.ScoEtudInscrit), - }, - { - "title": "Voir le journal...", - "endpoint": "scolar.showEtudLog", - "args": {"etudid": etud["etudid"]}, - "enabled": True, - }, - ] - - return htmlutils.make_menu("Étudiant", menuEtud, alone=True) - - -def etud_info_html(etudid, with_photo="1", debug=False): - """An HTML div with basic information and links about this etud. - Used for popups information windows. - """ - formsemestre_id = sco_formsemestre_status.retreive_formsemestre_from_request() - with_photo = int(with_photo) - etud = sco_etud.get_etud_info(filled=True)[0] - photo_html = sco_photos.etud_photo_html(etud, title="fiche de " + etud["nom"]) - # experimental: may be too slow to be here - etud["codeparcours"], etud["decisions_jury"] = sco_report.get_codeparcoursetud( - etud, prefix="S", separator=", " - ) - - bac = sco_bac.Baccalaureat(etud["bac"], etud["specialite"]) - etud["bac_abbrev"] = bac.abbrev() - H = ( - """
        -
        -
        %(nomprenom)s
        -
        Bac: %(bac_abbrev)s
        -
        %(codeparcours)s
        - """ - % etud - ) - - # Informations sur l'etudiant dans le semestre courant: - sem = None - if formsemestre_id: # un semestre est spécifié par la page - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - elif etud["cursem"]: # le semestre "en cours" pour l'étudiant - sem = etud["cursem"] - if sem: - groups = sco_groups.get_etud_groups(etudid, formsemestre_id) - grc = sco_groups.listgroups_abbrev(groups) - H += '
        En S%d: %s
        ' % (sem["semestre_id"], grc) - H += "
        " # fin partie gauche (eid_left) - if with_photo: - H += '' + photo_html + "" - - H += "
        " - if debug: - return ( - html_sco_header.standard_html_header() - + H - + html_sco_header.standard_html_footer() - ) - else: - return H +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""ScoDoc ficheEtud + + Fiche description d'un étudiant et de son parcours + +""" +from flask import url_for, g, request +from flask_login import current_user + +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app import log +from app.but import jury_but_view +from app.models.etudiants import make_etud_args +from app.scodoc import html_sco_header +from app.scodoc import htmlutils +from app.scodoc import sco_archives_etud +from app.scodoc import sco_bac +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_status +from app.scodoc import sco_groups +from app.scodoc import sco_cursus +from app.scodoc import sco_permissions_check +from app.scodoc import sco_photos +from app.scodoc import sco_users +from app.scodoc import sco_report +from app.scodoc import sco_etud +from app.scodoc.sco_bulletins import etud_descr_situation_semestre +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_formsemestre_validation import formsemestre_recap_parcours_table +from app.scodoc.sco_permissions import Permission + + +def _menuScolarite(authuser, sem, etudid): + """HTML pour menu "scolarite" pour un etudiant dans un semestre. + Le contenu du menu depend des droits de l'utilisateur et de l'état de l'étudiant. + """ + locked = not sem["etat"] + if locked: + lockicon = scu.icontag("lock32_img", title="verrouillé", border="0") + return lockicon # no menu + if not authuser.has_permission( + Permission.ScoEtudInscrit + ) and not authuser.has_permission(Permission.ScoEtudChangeGroups): + return "" # no menu + ins = sem["ins"] + args = {"etudid": etudid, "formsemestre_id": ins["formsemestre_id"]} + + if ins["etat"] != "D": + dem_title = "Démission" + dem_url = "scolar.form_dem" + else: + dem_title = "Annuler la démission" + dem_url = "scolar.do_cancel_dem" + + # Note: seul un etudiant inscrit (I) peut devenir défaillant. + if ins["etat"] != sco_codes_parcours.DEF: + def_title = "Déclarer défaillance" + def_url = "scolar.form_def" + elif ins["etat"] == sco_codes_parcours.DEF: + def_title = "Annuler la défaillance" + def_url = "scolar.do_cancel_def" + def_enabled = ( + (ins["etat"] != "D") + and authuser.has_permission(Permission.ScoEtudInscrit) + and not locked + ) + items = [ + { + "title": dem_title, + "endpoint": dem_url, + "args": args, + "enabled": authuser.has_permission(Permission.ScoEtudInscrit) + and not locked, + }, + { + "title": "Validation du semestre (jury)", + "endpoint": "notes.formsemestre_validation_etud_form", + "args": args, + "enabled": authuser.has_permission(Permission.ScoEtudInscrit) + and not locked, + }, + { + "title": def_title, + "endpoint": def_url, + "args": args, + "enabled": def_enabled, + }, + { + "title": "Inscrire à un module optionnel (ou au sport)", + "endpoint": "notes.formsemestre_inscription_option", + "args": args, + "enabled": authuser.has_permission(Permission.ScoEtudInscrit) + and not locked, + }, + { + "title": "Désinscrire (en cas d'erreur)", + "endpoint": "notes.formsemestre_desinscription", + "args": args, + "enabled": authuser.has_permission(Permission.ScoEtudInscrit) + and not locked, + }, + { + "title": "Inscrire à un autre semestre", + "endpoint": "notes.formsemestre_inscription_with_modules_form", + "args": {"etudid": etudid}, + "enabled": authuser.has_permission(Permission.ScoEtudInscrit), + }, + { + "title": "Enregistrer un semestre effectué ailleurs", + "endpoint": "notes.formsemestre_ext_create_form", + "args": args, + "enabled": authuser.has_permission(Permission.ScoImplement), + }, + ] + + return htmlutils.make_menu( + "Scolarité", items, css_class="direction_etud", alone=True + ) + + +def ficheEtud(etudid=None): + "fiche d'informations sur un etudiant" + authuser = current_user + cnx = ndb.GetDBConnexion() + if etudid: + try: # pour les bookmarks avec d'anciens ids... + etudid = int(etudid) + except ValueError: + raise ScoValueError("id invalide !") from ValueError + # la sidebar est differente s'il y a ou pas un etudid + # voir html_sidebar.sidebar() + g.etudid = etudid + args = make_etud_args(etudid=etudid) + etuds = sco_etud.etudident_list(cnx, args) + if not etuds: + log(f"ficheEtud: etudid={etudid!r} request.args={request.args!r}") + raise ScoValueError("Étudiant inexistant !") + etud = etuds[0] + etudid = etud["etudid"] + sco_etud.fill_etuds_info([etud]) + # + info = etud + info["ScoURL"] = scu.ScoURL() + info["authuser"] = authuser + info["info_naissance"] = info["date_naissance"] + if info["lieu_naissance"]: + info["info_naissance"] += " à " + info["lieu_naissance"] + if info["dept_naissance"]: + info["info_naissance"] += f" ({info['dept_naissance']})" + info["etudfoto"] = sco_photos.etud_photo_html(etud) + if ( + (not info["domicile"]) + and (not info["codepostaldomicile"]) + and (not info["villedomicile"]) + ): + info["domicile"] = "inconnue" + if info["paysdomicile"]: + pays = sco_etud.format_pays(info["paysdomicile"]) + if pays: + info["paysdomicile"] = "(%s)" % pays + else: + info["paysdomicile"] = "" + if info["telephone"] or info["telephonemobile"]: + info["telephones"] = "
        %s    %s" % ( + info["telephonestr"], + info["telephonemobilestr"], + ) + else: + info["telephones"] = "" + # e-mail: + if info["email_default"]: + info["emaillink"] = ", ".join( + [ + '%s' % (m, m) + for m in [etud["email"], etud["emailperso"]] + if m + ] + ) + else: + info["emaillink"] = "(pas d'adresse e-mail)" + # Champ dépendant des permissions: + if authuser.has_permission(Permission.ScoEtudChangeAdr): + info[ + "modifadresse" + ] = f"""modifier adresse""" + else: + info["modifadresse"] = "" + + # Groupes: + sco_groups.etud_add_group_infos( + info, + info["cursem"]["formsemestre_id"] if info["cursem"] else None, + only_to_show=True, + ) + # Parcours de l'étudiant + if info["sems"]: + info["last_formsemestre_id"] = info["sems"][0]["formsemestre_id"] + else: + info["last_formsemestre_id"] = "" + sem_info = {} + for sem in info["sems"]: + if sem["ins"]["etat"] != scu.INSCRIT: + descr, _ = etud_descr_situation_semestre( + etudid, + sem["formsemestre_id"], + info["ne"], + show_date_inscr=False, + ) + grlink = f"""{descr["situation"]}""" + else: + e = {"etudid": etudid} + sco_groups.etud_add_group_infos( + e, + sem["formsemestre_id"], + only_to_show=True, + ) + + grlinks = [] + for partition in e["partitions"].values(): + if partition["partition_name"]: + gr_name = partition["group_name"] + else: + gr_name = "tous" + + grlinks.append( + f"""{gr_name} + """ + ) + grlink = ", ".join(grlinks) + # infos ajoutées au semestre dans le parcours (groupe, menu) + menu = _menuScolarite(authuser, sem, etudid) + if menu: + sem_info[sem["formsemestre_id"]] = ( + "
        " + grlink + "" + menu + "
        " + ) + else: + sem_info[sem["formsemestre_id"]] = grlink + + if info["sems"]: + Se = sco_cursus.get_situation_etud_cursus(etud, info["last_formsemestre_id"]) + info["liste_inscriptions"] = formsemestre_recap_parcours_table( + Se, + etudid, + with_links=False, + sem_info=sem_info, + with_all_columns=False, + a_url="Notes/", + ) + info[ + "link_bul_pdf" + ] = f"""tous les bulletins""" + if authuser.has_permission(Permission.ScoEtudInscrit): + info[ + "link_inscrire_ailleurs" + ] = f"""inscrire à un autre semestre""" + else: + info["link_inscrire_ailleurs"] = "" + else: + # non inscrit + l = [f"""

        Étudiant{info["ne"]} non inscrit{info["ne"]}"""] + if authuser.has_permission(Permission.ScoEtudInscrit): + l.append( + f"""inscrire""" + ) + l.append("") + info["liste_inscriptions"] = "\n".join(l) + info["link_bul_pdf"] = "" + info["link_inscrire_ailleurs"] = "" + + # Liste des annotations + alist = [] + annos = sco_etud.etud_annotations_list(cnx, args={"etudid": etudid}) + for a in annos: + if not sco_permissions_check.can_suppress_annotation(a["id"]): + a["dellink"] = "" + else: + a["dellink"] = ( + '%s' + % ( + etudid, + a["id"], + scu.icontag( + "delete_img", + border="0", + alt="suppress", + title="Supprimer cette annotation", + ), + ) + ) + author = sco_users.user_info(a["author"]) + alist.append( + f"""Le {a['date']} par {author['prenomnom']} : + {a['comment']}{a['dellink']} + """ + ) + info["liste_annotations"] = "\n".join(alist) + # fiche admission + has_adm_notes = ( + info["math"] or info["physique"] or info["anglais"] or info["francais"] + ) + has_bac_info = ( + info["bac"] + or info["specialite"] + or info["annee_bac"] + or info["rapporteur"] + or info["commentaire"] + or info["classement"] + or info["type_admission"] + ) + if has_bac_info or has_adm_notes: + adm_tmpl = """ +

        Informations admission
        +""" + if has_adm_notes: + adm_tmpl += """ + + + + + + + + + +
        BacAnnéeRgMathPhysiqueAnglaisFrançais
        %(bac)s (%(specialite)s)%(annee_bac)s %(classement)s%(math)s%(physique)s%(anglais)s%(francais)s
        +""" + adm_tmpl += """ +
        Bac %(bac)s (%(specialite)s) obtenu en %(annee_bac)s
        +
        %(ilycee)s
        """ + if info["type_admission"] or info["classement"]: + adm_tmpl += """
        """ + if info["type_admission"]: + adm_tmpl += """Voie d'admission: %(type_admission)s """ + if info["classement"]: + adm_tmpl += """Rang admission: %(classement)s""" + if info["type_admission"] or info["classement"]: + adm_tmpl += "
        " + if info["rap"]: + adm_tmpl += """
        %(rap)s
        """ + adm_tmpl += """""" + else: + adm_tmpl = "" # pas de boite "info admission" + info["adm_data"] = adm_tmpl % info + + # Fichiers archivés: + info["fichiers_archive_htm"] = ( + '
        Fichiers associés
        ' + + sco_archives_etud.etud_list_archives_html(etudid) + ) + + # Devenir de l'étudiant: + has_debouche = True + if sco_permissions_check.can_edit_suivi(): + suivi_readonly = "0" + link_add_suivi = """
      • + ajouter une ligne +
      • """ + else: + suivi_readonly = "1" + link_add_suivi = "" + if has_debouche: + info[ + "debouche_html" + ] = """
        + Devenir: +
        +
          + %s +
        +
        +
        """ % ( + suivi_readonly, + info["etudid"], + link_add_suivi, + ) + else: + info["debouche_html"] = "" # pas de boite "devenir" + # + if info["liste_annotations"]: + info["tit_anno"] = '
        Annotations
        ' + else: + info["tit_anno"] = "" + # Inscriptions + # if info["sems"]: # XXX rcl unused ? à voir + # rcl = ( + # """(récapitulatif parcours)""" + # % info + # ) + # else: + # rcl = "" + info[ + "inscriptions_mkup" + ] = """
        +
        Parcours
        %s +%s %s +
        """ % ( + info["liste_inscriptions"], + info["link_bul_pdf"], + info["link_inscrire_ailleurs"], + ) + + # + if info["groupes"].strip(): + info[ + "groupes_row" + ] = f""" + Groupes :{info['groupes']} + """ + else: + info["groupes_row"] = "" + info["menus_etud"] = menus_etud(etudid) + + # raccordement provisoire pour juillet 2022, avant refonte complète de cette fiche... + info["but_infos_mkup"] = jury_but_view.infos_fiche_etud_html(etudid) + + tmpl = """ +
        +
        +

        %(nomprenom)s (%(inscription)s)

        + +%(emaillink)s +
        +%(etudfoto)s +
        + +
        +
        + + +%(groupes_row)s + +
        Situation :%(situation)s
        Né%(ne)s le :%(info_naissance)s
        + + + +
        + +
        Adresse : %(domicile)s %(codepostaldomicile)s %(villedomicile)s %(paysdomicile)s +%(modifadresse)s +%(telephones)s +
        +
        +
        +
        + +%(inscriptions_mkup)s + +%(but_infos_mkup)s + +
        +%(adm_data)s + +%(fichiers_archive_htm)s +
        + +%(debouche_html)s + +
        +%(tit_anno)s +%(liste_annotations)s
        + +
        + +Ajouter une annotation sur %(nomprenom)s: + + + +
        +
        +Ces annotations sont lisibles par tous les enseignants et le secrétariat. +
        +L'annotation commençant par "PE:" est un avis de poursuite d'études. +
        +
        + +
        +
        +
        + +
        code NIP: %(code_nip)s
        + +
        + """ + header = html_sco_header.sco_header( + page_title="Fiche étudiant %(prenom)s %(nom)s" % info, + cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css", "css/jury_but.css"], + javascripts=[ + "libjs/jinplace-1.2.1.min.js", + "js/ue_list.js", + "libjs/jQuery-tagEditor/jquery.tag-editor.min.js", + "libjs/jQuery-tagEditor/jquery.caret.min.js", + "js/recap_parcours.js", + "js/etud_debouche.js", + ], + ) + return header + tmpl % info + html_sco_header.sco_footer() + + +def menus_etud(etudid): + """Menu etudiant (operations sur l'etudiant)""" + authuser = current_user + + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + + menuEtud = [ + { + "title": etud["nomprenom"], + "endpoint": "scolar.ficheEtud", + "args": {"etudid": etud["etudid"]}, + "enabled": True, + "helpmsg": "Fiche étudiant", + }, + { + "title": "Changer la photo", + "endpoint": "scolar.form_change_photo", + "args": {"etudid": etud["etudid"]}, + "enabled": authuser.has_permission(Permission.ScoEtudChangeAdr), + }, + { + "title": "Changer les données identité/admission", + "endpoint": "scolar.etudident_edit_form", + "args": {"etudid": etud["etudid"]}, + "enabled": authuser.has_permission(Permission.ScoEtudInscrit), + }, + { + "title": "Supprimer cet étudiant...", + "endpoint": "scolar.etudident_delete", + "args": {"etudid": etud["etudid"]}, + "enabled": authuser.has_permission(Permission.ScoEtudInscrit), + }, + { + "title": "Voir le journal...", + "endpoint": "scolar.showEtudLog", + "args": {"etudid": etud["etudid"]}, + "enabled": True, + }, + ] + + return htmlutils.make_menu("Étudiant", menuEtud, alone=True) + + +def etud_info_html(etudid, with_photo="1", debug=False): + """An HTML div with basic information and links about this etud. + Used for popups information windows. + """ + formsemestre_id = sco_formsemestre_status.retreive_formsemestre_from_request() + with_photo = int(with_photo) + etud = sco_etud.get_etud_info(filled=True)[0] + photo_html = sco_photos.etud_photo_html(etud, title="fiche de " + etud["nom"]) + # experimental: may be too slow to be here + etud["codeparcours"], etud["decisions_jury"] = sco_report.get_codeparcoursetud( + etud, prefix="S", separator=", " + ) + + bac = sco_bac.Baccalaureat(etud["bac"], etud["specialite"]) + etud["bac_abbrev"] = bac.abbrev() + H = ( + """
        +
        +
        %(nomprenom)s
        +
        Bac: %(bac_abbrev)s
        +
        %(codeparcours)s
        + """ + % etud + ) + + # Informations sur l'etudiant dans le semestre courant: + sem = None + if formsemestre_id: # un semestre est spécifié par la page + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + elif etud["cursem"]: # le semestre "en cours" pour l'étudiant + sem = etud["cursem"] + if sem: + groups = sco_groups.get_etud_groups(etudid, formsemestre_id) + grc = sco_groups.listgroups_abbrev(groups) + H += '
        En S%d: %s
        ' % (sem["semestre_id"], grc) + H += "
        " # fin partie gauche (eid_left) + if with_photo: + H += '' + photo_html + "" + + H += "
        " + if debug: + return ( + html_sco_header.standard_html_header() + + H + + html_sco_header.standard_html_footer() + ) + else: + return H diff --git a/app/scodoc/sco_poursuite_dut.py b/app/scodoc/sco_poursuite_dut.py index f00774c1..36e9ca0e 100644 --- a/app/scodoc/sco_poursuite_dut.py +++ b/app/scodoc/sco_poursuite_dut.py @@ -1,235 +1,235 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@gmail.com -# -############################################################################## - -"""Extraction de données pour poursuites d'études - -Recapitule tous les semestres validés dans une feuille excel. -""" -import collections - -from flask import url_for, g, request - -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre -import app.scodoc.sco_utils as scu -from app.scodoc import sco_abs -from app.scodoc import sco_cache -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups -from app.scodoc import sco_preferences -from app.scodoc import sco_etud -import sco_version -from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_codes_parcours import code_semestre_validant, code_semestre_attente - - -def etud_get_poursuite_info(sem, etud): - """{ 'nom' : ..., 'semlist' : [ { 'semestre_id': , 'moy' : ... }, {}, ...] }""" - I = {} - I.update(etud) # copie nom, prenom, civilite, ... - - # Now add each semester, starting from the first one - semlist = [] - current_id = sem["semestre_id"] - for sem_id in range(1, current_id + 1): - sem_descr = None - for s in etud["sems"]: - if s["semestre_id"] == sem_id: - etudid = etud["etudid"] - formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - dec = nt.get_etud_decision_sem(etudid) - # Moyennes et rangs des UE - ues = nt.get_ues_stat_dict(filter_sport=True) - moy_ues = [] - for ue in ues: - ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) - if ue_status: - moy_ues.append( - ( - ue["acronyme"], - scu.fmt_note( - nt.get_etud_ue_status(etudid, ue["ue_id"])["moy"] - ), - ) - ) - else: - moy_ues.append((ue["acronyme"], "")) - rg_ues = [ - ("rang_" + ue["acronyme"], nt.ue_rangs[ue["ue_id"]][0][etudid]) - for ue in ues - ] - - # Moyennes et rang des modules - modimpls = nt.get_modimpls_dict() # recupération des modules - modules = [] - rangs = [] - for ue in ues: # on parcourt chaque UE - for modimpl in modimpls: # dans chaque UE les modules - if modimpl["module"]["ue_id"] == ue["ue_id"]: - codeModule = modimpl["module"]["code"] or "" - noteModule = scu.fmt_note( - nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) - ) - if noteModule != "NI": # si étudiant inscrit au module - if nt.mod_rangs is not None: - rangModule = nt.mod_rangs[modimpl["moduleimpl_id"]][ - 0 - ][etudid] - else: - rangModule = "" - modules.append([codeModule, noteModule]) - rangs.append(["rang_" + codeModule, rangModule]) - - # Absences - nbabs, nbabsjust = sco_abs.get_abs_count(etudid, nt.sem) - if ( - dec - and not sem_descr # not sem_descr pour ne prendre que le semestre validé le plus récent - and ( - code_semestre_validant(dec["code"]) - or code_semestre_attente(dec["code"]) - ) - and nt.get_etud_etat(etudid) == "I" - ): - d = [ - ("moy", scu.fmt_note(nt.get_etud_moy_gen(etudid))), - ("moy_promo", scu.fmt_note(nt.moy_moy)), - ("rang", nt.get_etud_rang(etudid)), - ("effectif", len(nt.T)), - ("date_debut", s["date_debut"]), - ("date_fin", s["date_fin"]), - ("periode", "%s - %s" % (s["mois_debut"], s["mois_fin"])), - ("AbsNonJust", nbabs - nbabsjust), - ("AbsJust", nbabsjust), - ] - d += ( - moy_ues + rg_ues + modules + rangs - ) # ajout des 2 champs notes des modules et classement dans chaque module - sem_descr = collections.OrderedDict(d) - if not sem_descr: - sem_descr = collections.OrderedDict( - [ - ("moy", ""), - ("moy_promo", ""), - ("rang", ""), - ("effectif", ""), - ("date_debut", ""), - ("date_fin", ""), - ("periode", ""), - ] - ) - sem_descr["semestre_id"] = sem_id - semlist.append(sem_descr) - - I["semlist"] = semlist - return I - - -def _flatten_info(info): - # met la liste des infos semestres "a plat" - # S1_moy, S1_rang, ..., S2_moy, ... - ids = [] - for s in info["semlist"]: - for k, v in s.items(): - if k != "semestre_id": - label = "S%s_%s" % (s["semestre_id"], k) - info[label] = v - ids.append(label) - return ids - - -def _getEtudInfoGroupes(group_ids, etat=None): - """liste triée d'infos (dict) sur les etudiants du groupe indiqué. - Attention: lent, car plusieurs requetes SQL par etudiant ! - """ - etuds = [] - for group_id in group_ids: - members = sco_groups.get_group_members(group_id, etat=etat) - for m in members: - etud = sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] - etuds.append(etud) - - return etuds - - -def formsemestre_poursuite_report(formsemestre_id, format="html"): - """Table avec informations "poursuite" """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - etuds = _getEtudInfoGroupes([sco_groups.get_default_group(formsemestre_id)]) - - infos = [] - ids = [] - for etud in etuds: - fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] - ) - etud["_nom_target"] = fiche_url - etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) - info = etud_get_poursuite_info(sem, etud) - idd = _flatten_info(info) - # On recupere la totalite des UEs dans ids - for id in idd: - if id not in ids: - ids += [id] - infos.append(info) - # - column_ids = ( - ("civilite_str", "nom", "prenom", "annee", "date_naissance") - + tuple(ids) - + ("debouche",) - ) - titles = {} - for c in column_ids: - titles[c] = c - tab = GenTable( - titles=titles, - columns_ids=column_ids, - rows=infos, - # html_col_width='4em', - html_sortable=True, - html_class="table_leftalign table_listegroupe", - pdf_link=False, # pas d'export pdf - preferences=sco_preferences.SemPreferences(formsemestre_id), - ) - tab.filename = scu.make_filename("poursuite " + sem["titreannee"]) - - tab.origin = ( - "Généré par %s le " % sco_version.SCONAME + scu.timedate_human_repr() + "" - ) - tab.caption = "Récapitulatif %s." % sem["titreannee"] - tab.html_caption = "Récapitulatif %s." % sem["titreannee"] - tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) - return tab.make_page( - title="""

        Poursuite d'études

        """, - init_qtip=True, - javascripts=["js/etud_info.js"], - format=format, - with_html_headers=True, - ) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@gmail.com +# +############################################################################## + +"""Extraction de données pour poursuites d'études + +Recapitule tous les semestres validés dans une feuille excel. +""" +import collections + +from flask import url_for, g, request + +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre +import app.scodoc.sco_utils as scu +from app.scodoc import sco_abs +from app.scodoc import sco_cache +from app.scodoc import sco_formsemestre +from app.scodoc import sco_groups +from app.scodoc import sco_preferences +from app.scodoc import sco_etud +import sco_version +from app.scodoc.gen_tables import GenTable +from app.scodoc.sco_codes_parcours import code_semestre_validant, code_semestre_attente + + +def etud_get_poursuite_info(sem, etud): + """{ 'nom' : ..., 'semlist' : [ { 'semestre_id': , 'moy' : ... }, {}, ...] }""" + I = {} + I.update(etud) # copie nom, prenom, civilite, ... + + # Now add each semester, starting from the first one + semlist = [] + current_id = sem["semestre_id"] + for sem_id in range(1, current_id + 1): + sem_descr = None + for s in etud["sems"]: + if s["semestre_id"] == sem_id: + etudid = etud["etudid"] + formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + dec = nt.get_etud_decision_sem(etudid) + # Moyennes et rangs des UE + ues = nt.get_ues_stat_dict(filter_sport=True) + moy_ues = [] + for ue in ues: + ue_status = nt.get_etud_ue_status(etudid, ue["ue_id"]) + if ue_status: + moy_ues.append( + ( + ue["acronyme"], + scu.fmt_note( + nt.get_etud_ue_status(etudid, ue["ue_id"])["moy"] + ), + ) + ) + else: + moy_ues.append((ue["acronyme"], "")) + rg_ues = [ + ("rang_" + ue["acronyme"], nt.ue_rangs[ue["ue_id"]][0][etudid]) + for ue in ues + ] + + # Moyennes et rang des modules + modimpls = nt.get_modimpls_dict() # recupération des modules + modules = [] + rangs = [] + for ue in ues: # on parcourt chaque UE + for modimpl in modimpls: # dans chaque UE les modules + if modimpl["module"]["ue_id"] == ue["ue_id"]: + codeModule = modimpl["module"]["code"] or "" + noteModule = scu.fmt_note( + nt.get_etud_mod_moy(modimpl["moduleimpl_id"], etudid) + ) + if noteModule != "NI": # si étudiant inscrit au module + if nt.mod_rangs is not None: + rangModule = nt.mod_rangs[modimpl["moduleimpl_id"]][ + 0 + ][etudid] + else: + rangModule = "" + modules.append([codeModule, noteModule]) + rangs.append(["rang_" + codeModule, rangModule]) + + # Absences + nbabs, nbabsjust = sco_abs.get_abs_count(etudid, nt.sem) + if ( + dec + and not sem_descr # not sem_descr pour ne prendre que le semestre validé le plus récent + and ( + code_semestre_validant(dec["code"]) + or code_semestre_attente(dec["code"]) + ) + and nt.get_etud_etat(etudid) == scu.INSCRIT + ): + d = [ + ("moy", scu.fmt_note(nt.get_etud_moy_gen(etudid))), + ("moy_promo", scu.fmt_note(nt.moy_moy)), + ("rang", nt.get_etud_rang(etudid)), + ("effectif", len(nt.T)), + ("date_debut", s["date_debut"]), + ("date_fin", s["date_fin"]), + ("periode", "%s - %s" % (s["mois_debut"], s["mois_fin"])), + ("AbsNonJust", nbabs - nbabsjust), + ("AbsJust", nbabsjust), + ] + d += ( + moy_ues + rg_ues + modules + rangs + ) # ajout des 2 champs notes des modules et classement dans chaque module + sem_descr = collections.OrderedDict(d) + if not sem_descr: + sem_descr = collections.OrderedDict( + [ + ("moy", ""), + ("moy_promo", ""), + ("rang", ""), + ("effectif", ""), + ("date_debut", ""), + ("date_fin", ""), + ("periode", ""), + ] + ) + sem_descr["semestre_id"] = sem_id + semlist.append(sem_descr) + + I["semlist"] = semlist + return I + + +def _flatten_info(info): + # met la liste des infos semestres "a plat" + # S1_moy, S1_rang, ..., S2_moy, ... + ids = [] + for s in info["semlist"]: + for k, v in s.items(): + if k != "semestre_id": + label = "S%s_%s" % (s["semestre_id"], k) + info[label] = v + ids.append(label) + return ids + + +def _getEtudInfoGroupes(group_ids, etat=None): + """liste triée d'infos (dict) sur les etudiants du groupe indiqué. + Attention: lent, car plusieurs requetes SQL par etudiant ! + """ + etuds = [] + for group_id in group_ids: + members = sco_groups.get_group_members(group_id, etat=etat) + for m in members: + etud = sco_etud.get_etud_info(etudid=m["etudid"], filled=True)[0] + etuds.append(etud) + + return etuds + + +def formsemestre_poursuite_report(formsemestre_id, format="html"): + """Table avec informations "poursuite" """ + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + etuds = _getEtudInfoGroupes([sco_groups.get_default_group(formsemestre_id)]) + + infos = [] + ids = [] + for etud in etuds: + fiche_url = url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + ) + etud["_nom_target"] = fiche_url + etud["_prenom_target"] = fiche_url + etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + info = etud_get_poursuite_info(sem, etud) + idd = _flatten_info(info) + # On recupere la totalite des UEs dans ids + for id in idd: + if id not in ids: + ids += [id] + infos.append(info) + # + column_ids = ( + ("civilite_str", "nom", "prenom", "annee", "date_naissance") + + tuple(ids) + + ("debouche",) + ) + titles = {} + for c in column_ids: + titles[c] = c + tab = GenTable( + titles=titles, + columns_ids=column_ids, + rows=infos, + # html_col_width='4em', + html_sortable=True, + html_class="table_leftalign table_listegroupe", + pdf_link=False, # pas d'export pdf + preferences=sco_preferences.SemPreferences(formsemestre_id), + ) + tab.filename = scu.make_filename("poursuite " + sem["titreannee"]) + + tab.origin = ( + "Généré par %s le " % sco_version.SCONAME + scu.timedate_human_repr() + "" + ) + tab.caption = "Récapitulatif %s." % sem["titreannee"] + tab.html_caption = "Récapitulatif %s." % sem["titreannee"] + tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) + return tab.make_page( + title="""

        Poursuite d'études

        """, + init_qtip=True, + javascripts=["js/etud_info.js"], + format=format, + with_html_headers=True, + ) diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pvjury.py index 6e26302f..3edcf660 100644 --- a/app/scodoc/sco_pvjury.py +++ b/app/scodoc/sco_pvjury.py @@ -1,934 +1,936 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Edition des PV de jury - -PV Jury IUTV 2006: on détaillait 8 cas: -Jury de semestre n - On a 8 types de décisions: - Passages: - 1. passage de ceux qui ont validés Sn-1 - 2. passage avec compensation Sn-1, Sn - 3. passage sans validation de Sn avec validation d'UE - 4. passage sans validation de Sn sans validation d'UE - - Redoublements: - 5. redoublement de Sn-1 et Sn sans validation d'UE pour Sn - 6. redoublement de Sn-1 et Sn avec validation d'UE pour Sn - - Reports - 7. report sans validation d'UE - - 8. non validation de Sn-1 et Sn et non redoublement -""" - -import time -from operator import itemgetter -from reportlab.platypus import Paragraph -from reportlab.lib import styles - -import flask -from flask import flash, redirect, url_for -from flask import g, request - -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import ( - FormSemestre, - UniteEns, - ScolarAutorisationInscription, - but_validations, -) -from app.models.etudiants import Identite - -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app import log -from app.scodoc import html_sco_header -from app.scodoc import sco_codes_parcours -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_formations -from app.scodoc import sco_formsemestre -from app.scodoc import sco_groups -from app.scodoc import sco_groups_view -from app.scodoc import sco_pdf -from app.scodoc import sco_preferences -from app.scodoc import sco_pvpdf -from app.scodoc.gen_tables import GenTable -from app.scodoc.sco_codes_parcours import NO_SEMESTRE_ID -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 ( - sco_codes_parcours.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 sco_codes_parcours.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 = sco_codes_parcours.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": - decision = "Démission" - else: - if decision_sem: - decision = decision_sem["code"] - else: - decision = "" - 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 sco_codes_parcours.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_parcours_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("I", 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, - anonymous=False, - with_parcours_decisions=False, - with_paragraph_nom=False, # cellule paragraphe avec nom, date, code NIP -): - """idem mais rend list de dicts - Si only_diplome, n'extrait que les etudiants qui valident leur diplome. - """ - sem = dpv["formsemestre"] - formsemestre_id = sem["formsemestre_id"] - sem_id_txt_sp = sem["sem_id_txt"] - if sem_id_txt_sp: - sem_id_txt_sp = " " + sem_id_txt_sp - titles = { - "etudid": "etudid", - "code_nip": "NIP", - "nomprenom": "Nom", # si with_paragraph_nom, sera un Paragraph - "parcours": "Parcours", - "decision": "Décision" + sem_id_txt_sp, - "mention": "Mention", - "ue_cap": "UE" + sem_id_txt_sp + " capitalisées", - "ects": "ECTS", - "devenir": "Devenir", - "validation_parcours_code": "Résultat au diplôme", - "observations": "Observations", - } - if anonymous: - titles["nomprenom"] = "Code" - columns_ids = ["nomprenom", "parcours"] - - if with_parcours_decisions: - all_idx = set() - for e in dpv["decisions"]: - all_idx |= set(e["parcours_decisions"].keys()) - sem_ids = sorted(all_idx) - for i in sem_ids: - if i != NO_SEMESTRE_ID: - titles[i] = "S%d" % i - else: - titles[i] = "S" # pas très parlant ? - columns_ids += [i] - - if dpv["has_prev"]: - id_prev = sem["semestre_id"] - 1 # numero du semestre precedent - titles["prev_decision"] = "Décision S%s" % id_prev - columns_ids += ["prev_decision"] - - if not dpv["is_apc"]: - # Décision de jury sur le semestre, sauf en BUT - columns_ids += ["decision"] - - if sco_preferences.get_preference("bul_show_mention", formsemestre_id): - columns_ids += ["mention"] - columns_ids += ["ue_cap"] - if sco_preferences.get_preference("bul_show_ects", formsemestre_id): - columns_ids += ["ects"] - - # XXX if not dpv["semestre_non_terminal"]: - # La colonne doit être présente: redoublants validant leur diplome - # en répétant un semestre ancien: exemple: S1 (ADM), S2 (ADM), S3 (AJ), S4 (ADM), S3 (ADM)=> diplôme - columns_ids += ["validation_parcours_code"] - columns_ids += ["devenir"] - columns_ids += ["observations"] - - lines = [] - for e in dpv["decisions"]: - sco_etud.format_etud_ident(e["identite"]) - l = { - "etudid": e["identite"]["etudid"], - "code_nip": e["identite"]["code_nip"], - "nomprenom": e["identite"]["nomprenom"], - "_nomprenom_target": url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=e["identite"]["etudid"], - ), - "_nomprenom_td_attrs": f"""id="{e['identite']['etudid']}" class="etudinfo" """, - "parcours": e["parcours"], - "decision": _descr_decision_sem_abbrev(e["etat"], e["decision_sem"]), - "ue_cap": e["decisions_ue_descr"], - "validation_parcours_code": "ADM" if e["validation_parcours"] else "", - "devenir": e["autorisations_descr"], - "observations": ndb.unquote(e["observation"]), - "mention": e["mention"], - "ects": str(e["sum_ects"]), - } - if with_paragraph_nom: - 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 - i = e["identite"] - l["nomprenom"] = [ - Paragraph(sco_pdf.SU(i["nomprenom"]), cell_style), - Paragraph(sco_pdf.SU(i["code_nip"]), cell_style), - Paragraph( - sco_pdf.SU( - "Né le %s" % i["date_naissance"] - + (" à %s" % i["lieu_naissance"] if i["lieu_naissance"] else "") - + (" (%s)" % i["dept_naissance"] if i["dept_naissance"] else "") - ), - cell_style, - ), - ] - if anonymous: - # Mode anonyme: affiche INE ou sinon NIP, ou id - l["nomprenom"] = ( - e["identite"]["code_ine"] - or e["identite"]["code_nip"] - or e["identite"]["etudid"] - ) - if with_parcours_decisions: - for i in e[ - "parcours_decisions" - ]: # or equivalently: l.update(e['parcours_decisions']) - l[i] = e["parcours_decisions"][i] - - if e["validation_parcours"]: - l["devenir"] = "Diplôme obtenu" - if dpv["has_prev"]: - l["prev_decision"] = _descr_decision_sem_abbrev( - None, e["prev_decision_sem"] - ) - if e["validation_parcours"] or not only_diplome: - lines.append(l) - return lines, titles, columns_ids - - -def formsemestre_pvjury(formsemestre_id, format="html", publish=True): - """Page récapitulant les décisions de jury""" - - # Bretelle provisoire pour BUT 9.3.0 - # XXX TODO - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - is_apc = formsemestre.formation.is_apc() - if format == "html" and is_apc and formsemestre.semestre_id % 2 == 0: - from app.but import jury_but_recap - - return jury_but_recap.formsemestre_saisie_jury_but( - formsemestre, read_only=True, mode="recap" - ) - # /XXX - footer = html_sco_header.sco_footer() - - dpv = dict_pvjury(formsemestre_id, with_prev=True) - if not dpv: - if format == "html": - return ( - html_sco_header.sco_header() - + "

        Aucune information disponible !

        " - + footer - ) - else: - return None - sem = dpv["formsemestre"] - formsemestre_id = sem["formsemestre_id"] - - rows, titles, columns_ids = pvjury_table(dpv) - if format != "html" and format != "pdf": - columns_ids = ["etudid", "code_nip"] + columns_ids - - tab = GenTable( - rows=rows, - titles=titles, - columns_ids=columns_ids, - filename=scu.make_filename("decisions " + sem["titreannee"]), - origin="Généré par %s le " % scu.sco_version.SCONAME - + scu.timedate_human_repr() - + "", - caption="Décisions jury pour " + sem["titreannee"], - html_class="table_leftalign", - html_sortable=True, - preferences=sco_preferences.SemPreferences(formsemestre_id), - ) - if format != "html": - return tab.make_page( - format=format, - with_html_headers=False, - publish=publish, - ) - tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) - H = [ - html_sco_header.html_sem_header( - "Décisions du jury pour le semestre", - init_qtip=True, - javascripts=["js/etud_info.js"], - ), - """

        (dernière modif le %s)

        """ % dpv["date"], - ] - - H.append( - '' - % formsemestre_id - ) - - H.append(tab.html()) - - # Count number of cases for each decision - counts = scu.DictDefault() - for row in rows: - counts[row["decision"]] += 1 - # add codes for previous (for explanation, without count) - if "prev_decision" in row and row["prev_decision"]: - counts[row["prev_decision"]] += 0 - # Légende des codes - codes = list(counts.keys()) - codes.sort() - H.append("

        Explication des codes

        ") - lines = [] - for code in codes: - lines.append( - { - "code": code, - "count": counts[code], - "expl": sco_codes_parcours.CODES_EXPL.get(code, ""), - } - ) - - H.append( - GenTable( - rows=lines, - titles={"code": "Code", "count": "Nombre", "expl": ""}, - columns_ids=("code", "count", "expl"), - html_class="table_leftalign", - html_sortable=True, - preferences=sco_preferences.SemPreferences(formsemestre_id), - ).html() - ) - H.append("

        ") # force space at bottom - return "\n".join(H) + footer - - -# --------------------------------------------------------------------------- - - -def formsemestre_pvjury_pdf(formsemestre_id, group_ids=[], etudid=None): - """Generation PV jury en PDF: saisie des paramètres - Si etudid, PV pour un seul etudiant. Sinon, tout les inscrits au groupe indiqué. - """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - # Mise à jour des groupes d'étapes: - sco_groups.create_etapes_partition(formsemestre_id) - groups_infos = None - if etudid: - # PV pour ce seul étudiant: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - etuddescr = '%s' % ( - etudid, - etud["nomprenom"], - ) - etudids = [etudid] - else: - etuddescr = "" - if not group_ids: - # tous les inscrits du semestre - group_ids = [sco_groups.get_default_group(formsemestre_id)] - - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids, formsemestre_id=formsemestre_id - ) - etudids = [m["etudid"] for m in groups_infos.members] - - H = [ - html_sco_header.html_sem_header( - "Edition du PV de jury %s" % etuddescr, - javascripts=sco_groups_view.JAVASCRIPTS, - cssstyles=sco_groups_view.CSSSTYLES, - init_qtip=True, - ), - """

        Utiliser cette page pour éditer des versions provisoires des PV. - Il est recommandé d'archiver les versions définitives: voir cette page -

        """ - % formsemestre_id, - ] - F = [ - """

        Voir aussi si besoin les réglages sur la page "Paramétrage" (accessible à l'administrateur du département). -

        """, - html_sco_header.sco_footer(), - ] - descr = descrform_pvjury(sem) - if etudid: - descr.append(("etudid", {"input_type": "hidden"})) - - if groups_infos: - menu_choix_groupe = ( - """
        Groupes d'étudiants à lister sur le PV: """ - + sco_groups_view.menu_groups_choice(groups_infos) - + """
        """ - ) - else: - menu_choix_groupe = "" # un seul etudiant à editer - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - descr, - cancelbutton="Annuler", - method="get", - submitlabel="Générer document", - name="tf", - formid="group_selector", - html_foot_markup=menu_choix_groupe, - ) - if tf[0] == 0: - return "\n".join(H) + "\n" + tf[1] + "\n".join(F) - elif tf[0] == -1: - return flask.redirect( - "formsemestre_pvjury?formsemestre_id=%s" % (formsemestre_id) - ) - else: - # submit - dpv = dict_pvjury(formsemestre_id, etudids=etudids, with_prev=True) - if tf[2]["showTitle"]: - tf[2]["showTitle"] = True - else: - tf[2]["showTitle"] = False - if tf[2]["anonymous"]: - tf[2]["anonymous"] = True - else: - tf[2]["anonymous"] = False - try: - PDFLOCK.acquire() - pdfdoc = sco_pvpdf.pvjury_pdf( - dpv, - numeroArrete=tf[2]["numeroArrete"], - VDICode=tf[2]["VDICode"], - date_commission=tf[2]["date_commission"], - date_jury=tf[2]["date_jury"], - showTitle=tf[2]["showTitle"], - pv_title=tf[2]["pv_title"], - with_paragraph_nom=tf[2]["with_paragraph_nom"], - anonymous=tf[2]["anonymous"], - ) - finally: - PDFLOCK.release() - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - dt = time.strftime("%Y-%m-%d") - if groups_infos: - groups_filename = "-" + groups_infos.groups_filename - else: - groups_filename = "" - filename = "PV-%s%s-%s.pdf" % (sem["titre_num"], groups_filename, dt) - return scu.sendPDFFile(pdfdoc, filename) - - -def descrform_pvjury(sem): - """Définition de formulaire pour PV jury PDF""" - F = sco_formations.formation_list(formation_id=sem["formation_id"])[0] - return [ - ( - "date_commission", - { - "input_type": "text", - "size": 50, - "title": "Date de la commission", - "explanation": "(format libre)", - }, - ), - ( - "date_jury", - { - "input_type": "text", - "size": 50, - "title": "Date du Jury", - "explanation": "(si le jury a eu lieu)", - }, - ), - ( - "numeroArrete", - { - "input_type": "text", - "size": 50, - "title": "Numéro de l'arrêté du président", - "explanation": "le président de l'Université prend chaque année un arrêté formant les jurys", - }, - ), - ( - "VDICode", - { - "input_type": "text", - "size": 15, - "title": "VDI et Code", - "explanation": "VDI et code du diplôme Apogée (format libre, n'est pas vérifié par ScoDoc)", - }, - ), - ( - "pv_title", - { - "input_type": "text", - "size": 64, - "title": "Titre du PV", - "explanation": "par défaut, titre officiel de la formation", - "default": F["titre_officiel"], - }, - ), - ( - "showTitle", - { - "input_type": "checkbox", - "title": "Indiquer en plus le titre du semestre sur le PV", - "explanation": '(le titre est "%s")' % sem["titre"], - "labels": [""], - "allowed_values": ("1",), - }, - ), - ( - "with_paragraph_nom", - { - "input_type": "boolcheckbox", - "title": "Avec date naissance et code", - "explanation": "ajoute informations sous le nom", - "default": True, - }, - ), - ( - "anonymous", - { - "input_type": "checkbox", - "title": "PV anonyme", - "explanation": "remplace nom par code étudiant (INE ou NIP)", - "labels": [""], - "allowed_values": ("1",), - }, - ), - ("formsemestre_id", {"input_type": "hidden"}), - ] - - -def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]): - "Lettres avis jury en PDF" - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - if not group_ids: - # tous les inscrits du semestre - group_ids = [sco_groups.get_default_group(formsemestre_id)] - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids, formsemestre_id=formsemestre_id - ) - etudids = [m["etudid"] for m in groups_infos.members] - - H = [ - html_sco_header.html_sem_header( - "Édition des lettres individuelles", - javascripts=sco_groups_view.JAVASCRIPTS, - cssstyles=sco_groups_view.CSSSTYLES, - init_qtip=True, - ), - f"""

        Utiliser cette page pour éditer des versions provisoires des PV. - Il est recommandé d'archiver les versions définitives: voir cette page

        - """, - ] - F = html_sco_header.sco_footer() - descr = descrform_lettres_individuelles() - menu_choix_groupe = ( - """
        Groupes d'étudiants à lister: """ - + sco_groups_view.menu_groups_choice(groups_infos) - + """
        """ - ) - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - descr, - cancelbutton="Annuler", - method="POST", - submitlabel="Générer document", - name="tf", - formid="group_selector", - html_foot_markup=menu_choix_groupe, - ) - if tf[0] == 0: - return "\n".join(H) + "\n" + tf[1] + F - elif tf[0] == -1: - return flask.redirect( - url_for( - "notes.formsemestre_pvjury", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - else: - # submit - sf = tf[2]["signature"] - signature = sf.read() # image of signature - try: - PDFLOCK.acquire() - pdfdoc = sco_pvpdf.pdf_lettres_individuelles( - formsemestre_id, - etudids=etudids, - date_jury=tf[2]["date_jury"], - date_commission=tf[2]["date_commission"], - signature=signature, - ) - finally: - PDFLOCK.release() - if not pdfdoc: - flash("Aucun étudiant n'a de décision de jury !") - return flask.redirect( - url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - - groups_filename = "-" + groups_infos.groups_filename - filename = f"""lettres-{formsemestre.titre_num()}{groups_filename}-{time.strftime("%Y-%m-%d")}.pdf""" - return scu.sendPDFFile(pdfdoc, filename) - - -def descrform_lettres_individuelles(): - return [ - ( - "date_commission", - { - "input_type": "text", - "size": 50, - "title": "Date de la commission", - "explanation": "(format libre)", - }, - ), - ( - "date_jury", - { - "input_type": "text", - "size": 50, - "title": "Date du Jury", - "explanation": "(si le jury a eu lieu)", - }, - ), - ( - "signature", - { - "input_type": "file", - "size": 30, - "explanation": "optionnel: image scannée de la signature", - }, - ), - ("formsemestre_id", {"input_type": "hidden"}), - ] +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Edition des PV de jury + +PV Jury IUTV 2006: on détaillait 8 cas: +Jury de semestre n + On a 8 types de décisions: + Passages: + 1. passage de ceux qui ont validés Sn-1 + 2. passage avec compensation Sn-1, Sn + 3. passage sans validation de Sn avec validation d'UE + 4. passage sans validation de Sn sans validation d'UE + + Redoublements: + 5. redoublement de Sn-1 et Sn sans validation d'UE pour Sn + 6. redoublement de Sn-1 et Sn avec validation d'UE pour Sn + + Reports + 7. report sans validation d'UE + + 8. non validation de Sn-1 et Sn et non redoublement +""" + +import time +from operator import itemgetter +from reportlab.platypus import Paragraph +from reportlab.lib import styles + +import flask +from flask import flash, redirect, url_for +from flask import g, request + +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import ( + FormSemestre, + UniteEns, + ScolarAutorisationInscription, + but_validations, +) +from app.models.etudiants import Identite + +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app import log +from app.scodoc import html_sco_header +from app.scodoc import sco_codes_parcours +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_formations +from app.scodoc import sco_formsemestre +from app.scodoc import sco_groups +from app.scodoc import sco_groups_view +from app.scodoc import sco_pdf +from app.scodoc import sco_preferences +from app.scodoc import sco_pvpdf +from app.scodoc.gen_tables import GenTable +from app.scodoc.sco_codes_parcours import NO_SEMESTRE_ID +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 ( + sco_codes_parcours.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 sco_codes_parcours.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 = sco_codes_parcours.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": + decision = "Démission" + else: + if decision_sem: + decision = decision_sem["code"] + else: + decision = "" + 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 sco_codes_parcours.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_parcours_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, + anonymous=False, + with_parcours_decisions=False, + with_paragraph_nom=False, # cellule paragraphe avec nom, date, code NIP +): + """idem mais rend list de dicts + Si only_diplome, n'extrait que les etudiants qui valident leur diplome. + """ + sem = dpv["formsemestre"] + formsemestre_id = sem["formsemestre_id"] + sem_id_txt_sp = sem["sem_id_txt"] + if sem_id_txt_sp: + sem_id_txt_sp = " " + sem_id_txt_sp + titles = { + "etudid": "etudid", + "code_nip": "NIP", + "nomprenom": "Nom", # si with_paragraph_nom, sera un Paragraph + "parcours": "Parcours", + "decision": "Décision" + sem_id_txt_sp, + "mention": "Mention", + "ue_cap": "UE" + sem_id_txt_sp + " capitalisées", + "ects": "ECTS", + "devenir": "Devenir", + "validation_parcours_code": "Résultat au diplôme", + "observations": "Observations", + } + if anonymous: + titles["nomprenom"] = "Code" + columns_ids = ["nomprenom", "parcours"] + + if with_parcours_decisions: + all_idx = set() + for e in dpv["decisions"]: + all_idx |= set(e["parcours_decisions"].keys()) + sem_ids = sorted(all_idx) + for i in sem_ids: + if i != NO_SEMESTRE_ID: + titles[i] = "S%d" % i + else: + titles[i] = "S" # pas très parlant ? + columns_ids += [i] + + if dpv["has_prev"]: + id_prev = sem["semestre_id"] - 1 # numero du semestre precedent + titles["prev_decision"] = "Décision S%s" % id_prev + columns_ids += ["prev_decision"] + + if not dpv["is_apc"]: + # Décision de jury sur le semestre, sauf en BUT + columns_ids += ["decision"] + + if sco_preferences.get_preference("bul_show_mention", formsemestre_id): + columns_ids += ["mention"] + columns_ids += ["ue_cap"] + if sco_preferences.get_preference("bul_show_ects", formsemestre_id): + columns_ids += ["ects"] + + # XXX if not dpv["semestre_non_terminal"]: + # La colonne doit être présente: redoublants validant leur diplome + # en répétant un semestre ancien: exemple: S1 (ADM), S2 (ADM), S3 (AJ), S4 (ADM), S3 (ADM)=> diplôme + columns_ids += ["validation_parcours_code"] + columns_ids += ["devenir"] + columns_ids += ["observations"] + + lines = [] + for e in dpv["decisions"]: + sco_etud.format_etud_ident(e["identite"]) + l = { + "etudid": e["identite"]["etudid"], + "code_nip": e["identite"]["code_nip"], + "nomprenom": e["identite"]["nomprenom"], + "_nomprenom_target": url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=e["identite"]["etudid"], + ), + "_nomprenom_td_attrs": f"""id="{e['identite']['etudid']}" class="etudinfo" """, + "parcours": e["parcours"], + "decision": _descr_decision_sem_abbrev(e["etat"], e["decision_sem"]), + "ue_cap": e["decisions_ue_descr"], + "validation_parcours_code": "ADM" if e["validation_parcours"] else "", + "devenir": e["autorisations_descr"], + "observations": ndb.unquote(e["observation"]), + "mention": e["mention"], + "ects": str(e["sum_ects"]), + } + if with_paragraph_nom: + 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 + i = e["identite"] + l["nomprenom"] = [ + Paragraph(sco_pdf.SU(i["nomprenom"]), cell_style), + Paragraph(sco_pdf.SU(i["code_nip"]), cell_style), + Paragraph( + sco_pdf.SU( + "Né le %s" % i["date_naissance"] + + (" à %s" % i["lieu_naissance"] if i["lieu_naissance"] else "") + + (" (%s)" % i["dept_naissance"] if i["dept_naissance"] else "") + ), + cell_style, + ), + ] + if anonymous: + # Mode anonyme: affiche INE ou sinon NIP, ou id + l["nomprenom"] = ( + e["identite"]["code_ine"] + or e["identite"]["code_nip"] + or e["identite"]["etudid"] + ) + if with_parcours_decisions: + for i in e[ + "parcours_decisions" + ]: # or equivalently: l.update(e['parcours_decisions']) + l[i] = e["parcours_decisions"][i] + + if e["validation_parcours"]: + l["devenir"] = "Diplôme obtenu" + if dpv["has_prev"]: + l["prev_decision"] = _descr_decision_sem_abbrev( + None, e["prev_decision_sem"] + ) + if e["validation_parcours"] or not only_diplome: + lines.append(l) + return lines, titles, columns_ids + + +def formsemestre_pvjury(formsemestre_id, format="html", publish=True): + """Page récapitulant les décisions de jury""" + + # Bretelle provisoire pour BUT 9.3.0 + # XXX TODO + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + is_apc = formsemestre.formation.is_apc() + if format == "html" and is_apc and formsemestre.semestre_id % 2 == 0: + from app.but import jury_but_recap + + return jury_but_recap.formsemestre_saisie_jury_but( + formsemestre, read_only=True, mode="recap" + ) + # /XXX + footer = html_sco_header.sco_footer() + + dpv = dict_pvjury(formsemestre_id, with_prev=True) + if not dpv: + if format == "html": + return ( + html_sco_header.sco_header() + + "

        Aucune information disponible !

        " + + footer + ) + else: + return None + sem = dpv["formsemestre"] + formsemestre_id = sem["formsemestre_id"] + + rows, titles, columns_ids = pvjury_table(dpv) + if format != "html" and format != "pdf": + columns_ids = ["etudid", "code_nip"] + columns_ids + + tab = GenTable( + rows=rows, + titles=titles, + columns_ids=columns_ids, + filename=scu.make_filename("decisions " + sem["titreannee"]), + origin="Généré par %s le " % scu.sco_version.SCONAME + + scu.timedate_human_repr() + + "", + caption="Décisions jury pour " + sem["titreannee"], + html_class="table_leftalign", + html_sortable=True, + preferences=sco_preferences.SemPreferences(formsemestre_id), + ) + if format != "html": + return tab.make_page( + format=format, + with_html_headers=False, + publish=publish, + ) + tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) + H = [ + html_sco_header.html_sem_header( + "Décisions du jury pour le semestre", + init_qtip=True, + javascripts=["js/etud_info.js"], + ), + """

        (dernière modif le %s)

        """ % dpv["date"], + ] + + H.append( + '' + % formsemestre_id + ) + + H.append(tab.html()) + + # Count number of cases for each decision + counts = scu.DictDefault() + for row in rows: + counts[row["decision"]] += 1 + # add codes for previous (for explanation, without count) + if "prev_decision" in row and row["prev_decision"]: + counts[row["prev_decision"]] += 0 + # Légende des codes + codes = list(counts.keys()) + codes.sort() + H.append("

        Explication des codes

        ") + lines = [] + for code in codes: + lines.append( + { + "code": code, + "count": counts[code], + "expl": sco_codes_parcours.CODES_EXPL.get(code, ""), + } + ) + + H.append( + GenTable( + rows=lines, + titles={"code": "Code", "count": "Nombre", "expl": ""}, + columns_ids=("code", "count", "expl"), + html_class="table_leftalign", + html_sortable=True, + preferences=sco_preferences.SemPreferences(formsemestre_id), + ).html() + ) + H.append("

        ") # force space at bottom + return "\n".join(H) + footer + + +# --------------------------------------------------------------------------- + + +def formsemestre_pvjury_pdf(formsemestre_id, group_ids=[], etudid=None): + """Generation PV jury en PDF: saisie des paramètres + Si etudid, PV pour un seul etudiant. Sinon, tout les inscrits au groupe indiqué. + """ + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + # Mise à jour des groupes d'étapes: + sco_groups.create_etapes_partition(formsemestre_id) + groups_infos = None + if etudid: + # PV pour ce seul étudiant: + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + etuddescr = '%s' % ( + etudid, + etud["nomprenom"], + ) + etudids = [etudid] + else: + etuddescr = "" + if not group_ids: + # tous les inscrits du semestre + group_ids = [sco_groups.get_default_group(formsemestre_id)] + + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, formsemestre_id=formsemestre_id + ) + etudids = [m["etudid"] for m in groups_infos.members] + + H = [ + html_sco_header.html_sem_header( + "Edition du PV de jury %s" % etuddescr, + javascripts=sco_groups_view.JAVASCRIPTS, + cssstyles=sco_groups_view.CSSSTYLES, + init_qtip=True, + ), + """

        Utiliser cette page pour éditer des versions provisoires des PV. + Il est recommandé d'archiver les versions définitives: voir cette page +

        """ + % formsemestre_id, + ] + F = [ + """

        Voir aussi si besoin les réglages sur la page "Paramétrage" (accessible à l'administrateur du département). +

        """, + html_sco_header.sco_footer(), + ] + descr = descrform_pvjury(sem) + if etudid: + descr.append(("etudid", {"input_type": "hidden"})) + + if groups_infos: + menu_choix_groupe = ( + """
        Groupes d'étudiants à lister sur le PV: """ + + sco_groups_view.menu_groups_choice(groups_infos) + + """
        """ + ) + else: + menu_choix_groupe = "" # un seul etudiant à editer + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + descr, + cancelbutton="Annuler", + method="get", + submitlabel="Générer document", + name="tf", + formid="group_selector", + html_foot_markup=menu_choix_groupe, + ) + if tf[0] == 0: + return "\n".join(H) + "\n" + tf[1] + "\n".join(F) + elif tf[0] == -1: + return flask.redirect( + "formsemestre_pvjury?formsemestre_id=%s" % (formsemestre_id) + ) + else: + # submit + dpv = dict_pvjury(formsemestre_id, etudids=etudids, with_prev=True) + if tf[2]["showTitle"]: + tf[2]["showTitle"] = True + else: + tf[2]["showTitle"] = False + if tf[2]["anonymous"]: + tf[2]["anonymous"] = True + else: + tf[2]["anonymous"] = False + try: + PDFLOCK.acquire() + pdfdoc = sco_pvpdf.pvjury_pdf( + dpv, + numeroArrete=tf[2]["numeroArrete"], + VDICode=tf[2]["VDICode"], + date_commission=tf[2]["date_commission"], + date_jury=tf[2]["date_jury"], + showTitle=tf[2]["showTitle"], + pv_title=tf[2]["pv_title"], + with_paragraph_nom=tf[2]["with_paragraph_nom"], + anonymous=tf[2]["anonymous"], + ) + finally: + PDFLOCK.release() + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + dt = time.strftime("%Y-%m-%d") + if groups_infos: + groups_filename = "-" + groups_infos.groups_filename + else: + groups_filename = "" + filename = "PV-%s%s-%s.pdf" % (sem["titre_num"], groups_filename, dt) + return scu.sendPDFFile(pdfdoc, filename) + + +def descrform_pvjury(sem): + """Définition de formulaire pour PV jury PDF""" + F = sco_formations.formation_list(formation_id=sem["formation_id"])[0] + return [ + ( + "date_commission", + { + "input_type": "text", + "size": 50, + "title": "Date de la commission", + "explanation": "(format libre)", + }, + ), + ( + "date_jury", + { + "input_type": "text", + "size": 50, + "title": "Date du Jury", + "explanation": "(si le jury a eu lieu)", + }, + ), + ( + "numeroArrete", + { + "input_type": "text", + "size": 50, + "title": "Numéro de l'arrêté du président", + "explanation": "le président de l'Université prend chaque année un arrêté formant les jurys", + }, + ), + ( + "VDICode", + { + "input_type": "text", + "size": 15, + "title": "VDI et Code", + "explanation": "VDI et code du diplôme Apogée (format libre, n'est pas vérifié par ScoDoc)", + }, + ), + ( + "pv_title", + { + "input_type": "text", + "size": 64, + "title": "Titre du PV", + "explanation": "par défaut, titre officiel de la formation", + "default": F["titre_officiel"], + }, + ), + ( + "showTitle", + { + "input_type": "checkbox", + "title": "Indiquer en plus le titre du semestre sur le PV", + "explanation": '(le titre est "%s")' % sem["titre"], + "labels": [""], + "allowed_values": ("1",), + }, + ), + ( + "with_paragraph_nom", + { + "input_type": "boolcheckbox", + "title": "Avec date naissance et code", + "explanation": "ajoute informations sous le nom", + "default": True, + }, + ), + ( + "anonymous", + { + "input_type": "checkbox", + "title": "PV anonyme", + "explanation": "remplace nom par code étudiant (INE ou NIP)", + "labels": [""], + "allowed_values": ("1",), + }, + ), + ("formsemestre_id", {"input_type": "hidden"}), + ] + + +def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]): + "Lettres avis jury en PDF" + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + if not group_ids: + # tous les inscrits du semestre + group_ids = [sco_groups.get_default_group(formsemestre_id)] + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, formsemestre_id=formsemestre_id + ) + etudids = [m["etudid"] for m in groups_infos.members] + + H = [ + html_sco_header.html_sem_header( + "Édition des lettres individuelles", + javascripts=sco_groups_view.JAVASCRIPTS, + cssstyles=sco_groups_view.CSSSTYLES, + init_qtip=True, + ), + f"""

        Utiliser cette page pour éditer des versions provisoires des PV. + Il est recommandé d'archiver les versions définitives: voir cette page

        + """, + ] + F = html_sco_header.sco_footer() + descr = descrform_lettres_individuelles() + menu_choix_groupe = ( + """
        Groupes d'étudiants à lister: """ + + sco_groups_view.menu_groups_choice(groups_infos) + + """
        """ + ) + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + descr, + cancelbutton="Annuler", + method="POST", + submitlabel="Générer document", + name="tf", + formid="group_selector", + html_foot_markup=menu_choix_groupe, + ) + if tf[0] == 0: + return "\n".join(H) + "\n" + tf[1] + F + elif tf[0] == -1: + return flask.redirect( + url_for( + "notes.formsemestre_pvjury", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + else: + # submit + sf = tf[2]["signature"] + signature = sf.read() # image of signature + try: + PDFLOCK.acquire() + pdfdoc = sco_pvpdf.pdf_lettres_individuelles( + formsemestre_id, + etudids=etudids, + date_jury=tf[2]["date_jury"], + date_commission=tf[2]["date_commission"], + signature=signature, + ) + finally: + PDFLOCK.release() + if not pdfdoc: + flash("Aucun étudiant n'a de décision de jury !") + return flask.redirect( + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + + groups_filename = "-" + groups_infos.groups_filename + filename = f"""lettres-{formsemestre.titre_num()}{groups_filename}-{time.strftime("%Y-%m-%d")}.pdf""" + return scu.sendPDFFile(pdfdoc, filename) + + +def descrform_lettres_individuelles(): + return [ + ( + "date_commission", + { + "input_type": "text", + "size": 50, + "title": "Date de la commission", + "explanation": "(format libre)", + }, + ), + ( + "date_jury", + { + "input_type": "text", + "size": 50, + "title": "Date du Jury", + "explanation": "(si le jury a eu lieu)", + }, + ), + ( + "signature", + { + "input_type": "file", + "size": 30, + "explanation": "optionnel: image scannée de la signature", + }, + ), + ("formsemestre_id", {"input_type": "hidden"}), + ] diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py index 42db7bb4..31e9d90e 100644 --- a/app/scodoc/sco_report.py +++ b/app/scodoc/sco_report.py @@ -1,1624 +1,1624 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Rapports suivi: - - statistiques decisions - - suivi cohortes -""" -import os -import tempfile -import re -import time -import datetime -from operator import itemgetter - -from flask import url_for, g, request -import pydot - -from app.but import jury_but -from app.comp import res_sem -from app.comp.res_compat import NotesTableCompat -from app.models import FormSemestre, ScolarAutorisationInscription -from app.models import FormationModalite -from app.models.etudiants import Identite - -import app.scodoc.sco_utils as scu -from app.scodoc import notesdb as ndb -from app.scodoc import html_sco_header -from app.scodoc import sco_codes_parcours -from app.scodoc import sco_etud -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_preferences -from app.scodoc import sco_pvjury -import sco_version -from app.scodoc.gen_tables import GenTable -from app import log -from app.scodoc.sco_codes_parcours import code_semestre_validant - -MAX_ETUD_IN_DESCR = 20 - -LEGENDES_CODES_BUT = { - "Nb_rcue_valides": "nb RCUE validés", - "decision_annee": "code jury annuel BUT", -} - - -def formsemestre_etuds_stats(sem: dict, only_primo=False): - """Récupère liste d'etudiants avec etat et decision.""" - formsemestre: FormSemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - T = nt.get_table_moyennes_triees() - - # Décisions de jury BUT pour les semestres pairs seulement - jury_but_mode = ( - formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0 - ) - # Construit liste d'étudiants du semestre avec leur decision - etuds = [] - for t in T: - etudid = t[-1] - etudiant: Identite = Identite.query.get(etudid) - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - decision = nt.get_etud_decision_sem(etudid) - if decision: - etud["codedecision"] = decision["code"] - etud["etat"] = nt.get_etud_etat(etudid) - if etud["etat"] == "D": - etud["codedecision"] = "DEM" - if "codedecision" not in etud: - etud["codedecision"] = "(nd)" # pas de decision jury - # Ajout devenir (autorisations inscriptions), utile pour stats passage - aut_list = ScolarAutorisationInscription.query.filter_by( - etudid=etudid, origin_formsemestre_id=sem["formsemestre_id"] - ).all() - autorisations = [f"S{a.semestre_id}" for a in aut_list] - autorisations.sort() - autorisations_str = ", ".join(autorisations) - etud["devenir"] = autorisations_str - # Décisions de jury BUT (APC) - if jury_but_mode: - deca = jury_but.DecisionsProposeesAnnee(etudiant, formsemestre) - etud["nb_rcue_valides"] = deca.nb_rcue_valides - etud["decision_annee"] = deca.code_valide - # Ajout clé 'bac-specialite' - bs = [] - if etud["bac"]: - bs.append(etud["bac"]) - if etud["specialite"]: - bs.append(etud["specialite"]) - etud["bac-specialite"] = " ".join(bs) - # - if (not only_primo) or is_primo_etud(etud, sem): - etuds.append(etud) - return etuds - - -def is_primo_etud(etud: dict, sem: dict): - """Determine si un (filled) etud a été inscrit avant ce semestre. - Regarde la liste des semestres dans lesquels l'étudiant est inscrit. - Si semestre pair, considère comme primo-entrants ceux qui étaient - primo dans le précédent (S_{2n-1}). - """ - debut_cur = sem["date_debut_iso"] - # si semestre impair et sem. précédent contigu, recule date debut - if ( - (len(etud["sems"]) > 1) - and (sem["semestre_id"] % 2 == 0) - and (etud["sems"][1]["semestre_id"] == (sem["semestre_id"] - 1)) - ): - debut_cur = etud["sems"][1]["date_debut_iso"] - for s in etud["sems"]: # le + recent d'abord - if s["date_debut_iso"] < debut_cur: - return False - return True - - -def _categories_and_results(etuds, category, result): - categories = {} - results = {} - for etud in etuds: - categories[etud[category]] = True - results[etud[result]] = True - categories = list(categories.keys()) - categories.sort(key=scu.heterogeneous_sorting_key) - results = list(results.keys()) - results.sort(key=scu.heterogeneous_sorting_key) - return categories, results - - -def _results_by_category( - etuds, - category="", - result="", - category_name=None, - formsemestre_id=None, -): - """Construit table: categories (eg types de bacs) en ligne, décisions jury en colonnes - - etuds est une liste d'etuds (dicts). - category et result sont des clés de etud (category définie les lignes, result les colonnes). - - Retourne une table. - """ - if category_name is None: - category_name = category - # types de bacs differents: - categories, results = _categories_and_results(etuds, category, result) - # - Count = {} # { bac : { decision : nb_avec_ce_bac_et_ce_code } } - results = {} # { result_value : True } - for etud in etuds: - results[etud[result]] = True - if etud[category] in Count: - Count[etud[category]][etud[result]] += 1 - else: - Count[etud[category]] = scu.DictDefault(kv_dict={etud[result]: 1}) - # conversion en liste de dict - C = [Count[cat] for cat in categories] - # Totaux par lignes et colonnes - tot = 0 - for l in [Count[cat] for cat in categories]: - l["sum"] = sum(l.values()) - tot += l["sum"] - # pourcentages sur chaque total de ligne - for l in C: - l["sumpercent"] = "%2.1f%%" % ((100.0 * l["sum"]) / tot) - # - codes = list(results.keys()) - codes.sort(key=scu.heterogeneous_sorting_key) - - bottom_titles = [] - if C: # ligne du bas avec totaux: - bottom_titles = {} - for code in codes: - bottom_titles[code] = sum([l[code] for l in C]) - bottom_titles["sum"] = tot - bottom_titles["sumpercent"] = "100%" - bottom_titles["row_title"] = "Total" - - # ajout titre ligne: - for (cat, l) in zip(categories, C): - l["row_title"] = cat if cat is not None else "?" - - # - codes.append("sum") - codes.append("sumpercent") - - # on veut { ADM : ADM, ... } - titles = {x: x for x in codes} - # sauf pour - titles.update(LEGENDES_CODES_BUT) - titles["sum"] = "Total" - titles["sumpercent"] = "%" - titles["DEM"] = "Dém." # démissions - titles["row_title"] = titles.get(category_name, category_name) - return GenTable( - titles=titles, - columns_ids=codes, - rows=C, - bottom_titles=bottom_titles, - html_col_width="4em", - html_sortable=True, - preferences=sco_preferences.SemPreferences(formsemestre_id), - ) - - -# pages -def formsemestre_report( - formsemestre_id, - etuds, - category="bac", - result="codedecision", - category_name="", - result_name="", - title="Statistiques", - only_primo=None, -): - """ - Tableau sur résultats (result) par type de category bac - """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - if not category_name: - category_name = category - if not result_name: - result_name = result - if result_name == "codedecision": - result_name = "résultats" - # - tab = _results_by_category( - etuds, - category=category, - category_name=category_name, - result=result, - formsemestre_id=formsemestre_id, - ) - # - tab.filename = scu.make_filename("stats " + sem["titreannee"]) - - tab.origin = f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}" - tab.caption = ( - f"Répartition des résultats par {category_name}, semestre {sem['titreannee']}" - ) - tab.html_caption = f"Répartition des résultats par {category_name}." - tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) - if only_primo: - tab.base_url += "&only_primo=on" - return tab - - -# def formsemestre_report_bacs(formsemestre_id, format='html'): -# """ -# Tableau sur résultats par type de bac -# """ -# sem = sco_formsemestre.get_formsemestre( formsemestre_id) -# title = 'Statistiques bacs ' + sem['titreannee'] -# etuds = formsemestre_etuds_stats(sem) -# tab = formsemestre_report(formsemestre_id, etuds, -# category='bac', result='codedecision', -# category_name='Bac', -# title=title) -# return tab.make_page( -# title = """

        Résultats de %(titreannee)s

        """ % sem, -# format=format, page_title = title) - - -def formsemestre_report_counts( - formsemestre_id: int, - format="html", - category: str = "bac", - result: str = None, - allkeys: bool = False, - only_primo: bool = False, -): - """ - Tableau comptage avec choix des categories - category: attribut en lignes - result: attribut en colonnes - only_primo: restreint aux primo-entrants (= non redoublants) - allkeys: pour le menu du choix de l'attribut en colonnes: - si vrai, toutes les valeurs présentes dans les données - sinon liste prédéfinie (voir ci-dessous) - """ - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - # Décisions de jury BUT pour les semestres pairs seulement - jury_but_mode = ( - formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0 - ) - - if result is None: - result = "statut" if formsemestre.formation.is_apc() else "codedecision" - - category_name = category.capitalize() - title = "Comptages " + category_name - etuds = formsemestre_etuds_stats(sem, only_primo=only_primo) - tab = formsemestre_report( - formsemestre_id, - etuds, - category=category, - result=result, - category_name=category_name, - title=title, - only_primo=only_primo, - ) - if not etuds: - F = ["""

        Aucun étudiant

        """] - else: - if allkeys: - keys = list(etuds[0].keys()) - else: - # clés présentées à l'utilisateur: - keys = [ - "annee_bac", - "annee_naissance", - "bac", - "specialite", - "bac-specialite", - "codedecision", - "devenir", - "etat", - "civilite", - "qualite", - "villelycee", - "statut", - "type_admission", - "boursier_prec", - ] - if jury_but_mode: - keys += ["nb_rcue_valides", "decision_annee"] - keys.sort(key=scu.heterogeneous_sorting_key) - F = [ - """

        - Colonnes: ") - F.append(' Lignes: ") - if only_primo: - checked = 'checked="1"' - else: - checked = "" - F.append( - '
        Restreindre aux primo-entrants' - % checked - ) - F.append( - '' % formsemestre_id - ) - F.append("

        ") - - t = tab.make_page( - title="""

        Comptes croisés

        """, - format=format, - with_html_headers=False, - ) - if format != "html": - return t - H = [ - html_sco_header.sco_header(page_title=title), - t, - "\n".join(F), - """

        Le tableau affiche le nombre d'étudiants de ce semestre dans chacun - des cas choisis: à l'aide des deux menus, vous pouvez choisir les catégories utilisées - pour les lignes et les colonnes. Le codedecision est le code de la décision - du jury. -

        """, - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -# -------------------------------------------------------------------------- -def table_suivi_cohorte( - formsemestre_id, - percent=False, - bac="", # selection sur type de bac - bacspecialite="", - annee_bac="", - civilite=None, - statut="", - only_primo=False, -): - """ - Tableau indiquant le nombre d'etudiants de la cohorte dans chaque état: - Etat date_debut_Sn date1 date2 ... - S_n #inscrits en Sn - S_n+1 - ... - S_last - Diplome - Sorties - - Determination des dates: on regroupe les semestres commençant à des dates proches - - """ - sem = sco_formsemestre.get_formsemestre( - formsemestre_id - ) # sem est le semestre origine - t0 = time.time() - - def logt(op): - if 0: # debug, set to 0 in production - log("%s: %s" % (op, time.time() - t0)) - - logt("table_suivi_cohorte: start") - # 1-- Liste des semestres posterieurs dans lesquels ont été les etudiants de sem - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - etudids = nt.get_etudids() - - logt("A: orig etuds set") - S = {formsemestre_id: sem} # ensemble de formsemestre_id - orig_set = set() # ensemble d'etudid du semestre d'origine - bacs = set() - bacspecialites = set() - annee_bacs = set() - civilites = set() - statuts = set() - for etudid in etudids: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - bacspe = etud["bac"] + " / " + etud["specialite"] - # sélection sur bac: - if ( - (not bac or (bac == etud["bac"])) - and (not bacspecialite or (bacspecialite == bacspe)) - and (not annee_bac or (annee_bac == str(etud["annee_bac"]))) - and (not civilite or (civilite == etud["civilite"])) - and (not statut or (statut == etud["statut"])) - and (not only_primo or is_primo_etud(etud, sem)) - ): - orig_set.add(etudid) - # semestres suivants: - for s in etud["sems"]: - if ndb.DateDMYtoISO(s["date_debut"]) > ndb.DateDMYtoISO( - sem["date_debut"] - ): - S[s["formsemestre_id"]] = s - bacs.add(etud["bac"]) - bacspecialites.add(bacspe) - annee_bacs.add(str(etud["annee_bac"])) - civilites.add(etud["civilite"]) - if etud["statut"]: # ne montre pas les statuts non renseignés - statuts.add(etud["statut"]) - sems = list(S.values()) - # tri les semestres par date de debut - for s in sems: - d, m, y = [int(x) for x in s["date_debut"].split("/")] - s["date_debut_dt"] = datetime.datetime(y, m, d) - sems.sort(key=itemgetter("date_debut_dt")) - - # 2-- Pour chaque semestre, trouve l'ensemble des etudiants venant de sem - logt("B: etuds sets") - sem["members"] = orig_set - for s in sems: - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - args={"formsemestre_id": s["formsemestre_id"]} - ) # sans dems - inset = set([i["etudid"] for i in ins]) - s["members"] = orig_set.intersection(inset) - nb_dipl = 0 # combien de diplomes dans ce semestre ? - if s["semestre_id"] == nt.parcours.NB_SEM: - s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) - for etudid in s["members"]: - dec = nt.get_etud_decision_sem(etudid) - if dec and code_semestre_validant(dec["code"]): - nb_dipl += 1 - s["nb_dipl"] = nb_dipl - - # 3-- Regroupe les semestres par date de debut - P = [] # liste de periodsem - - class periodsem(object): - pass - - # semestre de depart: - porigin = periodsem() - d, m, y = [int(x) for x in sem["date_debut"].split("/")] - porigin.datedebut = datetime.datetime(y, m, d) - porigin.sems = [sem] - - # - tolerance = datetime.timedelta(days=45) - for s in sems: - merged = False - for p in P: - if abs(s["date_debut_dt"] - p.datedebut) < tolerance: - p.sems.append(s) - merged = True - break - if not merged: - p = periodsem() - p.datedebut = s["date_debut_dt"] - p.sems = [s] - P.append(p) - - # 4-- regroupe par indice de semestre S_i - indices_sems = list({s["semestre_id"] for s in sems}) - indices_sems.sort() - for p in P: - p.nb_etuds = 0 # nombre total d'etudiants dans la periode - p.sems_by_id = scu.DictDefault(defaultvalue=[]) - for s in p.sems: - p.sems_by_id[s["semestre_id"]].append(s) - p.nb_etuds += len(s["members"]) - - # 5-- Contruit table - logt("C: build table") - nb_initial = len(sem["members"]) - - def fmtval(x): - if not x: - return "" # ne montre pas les 0 - if percent: - return "%2.1f%%" % (100.0 * x / nb_initial) - else: - return x - - L = [ - { - "row_title": "Origine: S%s" % sem["semestre_id"], - porigin.datedebut: nb_initial, - "_css_row_class": "sorttop", - } - ] - if nb_initial <= MAX_ETUD_IN_DESCR: - etud_descr = _descr_etud_set(sem["members"]) - L[0]["_%s_help" % porigin.datedebut] = etud_descr - for idx_sem in indices_sems: - if idx_sem >= 0: - d = {"row_title": "S%s" % idx_sem} - else: - d = {"row_title": "Autre semestre"} - - for p in P: - etuds_period = set() - for s in p.sems: - if s["semestre_id"] == idx_sem: - etuds_period = etuds_period.union(s["members"]) - nbetuds = len(etuds_period) - if nbetuds: - d[p.datedebut] = fmtval(nbetuds) - if nbetuds <= MAX_ETUD_IN_DESCR: # si peu d'etudiants, indique la liste - etud_descr = _descr_etud_set(etuds_period) - d["_%s_help" % p.datedebut] = etud_descr - L.append(d) - # Compte nb de démissions et de ré-orientation par période - logt("D: cout dems reos") - sem["dems"], sem["reos"] = _count_dem_reo(formsemestre_id, sem["members"]) - for p in P: - p.dems = set() - p.reos = set() - for s in p.sems: - d, r = _count_dem_reo(s["formsemestre_id"], s["members"]) - p.dems.update(d) - p.reos.update(r) - # Nombre total d'etudiants par periode - l = { - "row_title": "Inscrits", - "row_title_help": "Nombre d'étudiants inscrits", - "_table_part": "foot", - porigin.datedebut: fmtval(nb_initial), - } - for p in P: - l[p.datedebut] = fmtval(p.nb_etuds) - L.append(l) - # Nombre de démissions par période - l = { - "row_title": "Démissions", - "row_title_help": "Nombre de démissions pendant la période", - "_table_part": "foot", - porigin.datedebut: fmtval(len(sem["dems"])), - } - if len(sem["dems"]) <= MAX_ETUD_IN_DESCR: - etud_descr = _descr_etud_set(sem["dems"]) - l["_%s_help" % porigin.datedebut] = etud_descr - for p in P: - l[p.datedebut] = fmtval(len(p.dems)) - if len(p.dems) <= MAX_ETUD_IN_DESCR: - etud_descr = _descr_etud_set(p.dems) - l["_%s_help" % p.datedebut] = etud_descr - L.append(l) - # Nombre de réorientations par période - l = { - "row_title": "Echecs", - "row_title_help": "Ré-orientations (décisions NAR)", - "_table_part": "foot", - porigin.datedebut: fmtval(len(sem["reos"])), - } - if len(sem["reos"]) < 10: - etud_descr = _descr_etud_set(sem["reos"]) - l["_%s_help" % porigin.datedebut] = etud_descr - for p in P: - l[p.datedebut] = fmtval(len(p.reos)) - if len(p.reos) <= MAX_ETUD_IN_DESCR: - etud_descr = _descr_etud_set(p.reos) - l["_%s_help" % p.datedebut] = etud_descr - L.append(l) - # derniere ligne: nombre et pourcentage de diplomes - l = { - "row_title": "Diplômes", - "row_title_help": "Nombre de diplômés à la fin de la période", - "_table_part": "foot", - } - for p in P: - nb_dipl = 0 - for s in p.sems: - nb_dipl += s["nb_dipl"] - l[p.datedebut] = fmtval(nb_dipl) - L.append(l) - - columns_ids = [p.datedebut for p in P] - titles = dict([(p.datedebut, p.datedebut.strftime("%d/%m/%y")) for p in P]) - titles[porigin.datedebut] = porigin.datedebut.strftime("%d/%m/%y") - if percent: - pp = "(en % de la population initiale) " - titles["row_title"] = "%" - else: - pp = "" - titles["row_title"] = "" - if only_primo: - pp += "(restreint aux primo-entrants) " - if bac: - dbac = " (bacs %s)" % bac - else: - dbac = "" - if bacspecialite: - dbac += " (spécialité %s)" % bacspecialite - if annee_bac: - dbac += " (année bac %s)" % annee_bac - if civilite: - dbac += " civilité: %s" % civilite - if statut: - dbac += " statut: %s" % statut - tab = GenTable( - titles=titles, - columns_ids=columns_ids, - rows=L, - html_col_width="4em", - html_sortable=True, - filename=scu.make_filename("cohorte " + sem["titreannee"]), - origin="Généré par %s le " % sco_version.SCONAME - + scu.timedate_human_repr() - + "", - caption="Suivi cohorte " + pp + sem["titreannee"] + dbac, - page_title="Suivi cohorte " + sem["titreannee"], - html_class="table_cohorte", - preferences=sco_preferences.SemPreferences(formsemestre_id), - ) - # Explication: liste des semestres associés à chaque date - if not P: - expl = [ - '

        (aucun étudiant trouvé dans un semestre ultérieur)

        ' - ] - else: - expl = ["

        Semestres associés à chaque date:

          "] - for p in P: - expl.append("
        • %s:" % p.datedebut.strftime("%d/%m/%y")) - ls = [] - for s in p.sems: - ls.append( - '%(titreannee)s' - % s - ) - expl.append(", ".join(ls) + "
        • ") - expl.append("
        ") - logt("Z: table_suivi_cohorte done") - return ( - tab, - "\n".join(expl), - bacs, - bacspecialites, - annee_bacs, - civilites, - statuts, - ) - - -def formsemestre_suivi_cohorte( - formsemestre_id, - format="html", - percent=1, - bac="", - bacspecialite="", - annee_bac="", - civilite=None, - statut="", - only_primo=False, -): - """Affiche suivi cohortes par numero de semestre""" - annee_bac = str(annee_bac) - percent = int(percent) - ( - tab, - expl, - bacs, - bacspecialites, - annee_bacs, - civilites, - statuts, - ) = table_suivi_cohorte( - formsemestre_id, - percent=percent, - bac=bac, - bacspecialite=bacspecialite, - annee_bac=annee_bac, - civilite=civilite, - statut=statut, - only_primo=only_primo, - ) - tab.base_url = ( - "%s?formsemestre_id=%s&percent=%s&bac=%s&bacspecialite=%s&civilite=%s" - % (request.base_url, formsemestre_id, percent, bac, bacspecialite, civilite) - ) - if only_primo: - tab.base_url += "&only_primo=on" - t = tab.make_page(format=format, with_html_headers=False) - if format != "html": - return t - - base_url = request.base_url - burl = "%s?formsemestre_id=%s&bac=%s&bacspecialite=%s&civilite=%s&statut=%s" % ( - base_url, - formsemestre_id, - bac, - bacspecialite, - civilite, - statut, - ) - if percent: - pplink = '

        Afficher les résultats bruts

        ' % burl - else: - pplink = ( - '

        Afficher les résultats en pourcentages

        ' - % burl - ) - help = ( - pplink - + """ -

        Nombre d'étudiants dans chaque semestre. Les dates indiquées sont les dates approximatives de début des semestres (les semestres commençant à des dates proches sont groupés). Le nombre de diplômés est celui à la fin du semestre correspondant. Lorsqu'il y a moins de %s étudiants dans une case, vous pouvez afficher leurs noms en passant le curseur sur le chiffre.

        -

        Les menus permettent de n'étudier que certaines catégories d'étudiants (titulaires d'un type de bac, garçons ou filles). La case "restreindre aux primo-entrants" permet de ne considérer que les étudiants qui n'ont jamais été inscrits dans ScoDoc avant le semestre considéré.

        - """ - % (MAX_ETUD_IN_DESCR,) - ) - - H = [ - html_sco_header.sco_header(page_title=tab.page_title), - """

        Suivi cohorte: devenir des étudiants de ce semestre

        """, - _gen_form_selectetuds( - formsemestre_id, - only_primo=only_primo, - bac=bac, - bacspecialite=bacspecialite, - annee_bac=annee_bac, - civilite=civilite, - statut=statut, - bacs=bacs, - bacspecialites=bacspecialites, - annee_bacs=annee_bacs, - civilites=civilites, - statuts=statuts, - percent=percent, - ), - t, - help, - expl, - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -def _gen_form_selectetuds( - formsemestre_id, - percent=None, - only_primo=None, - bac=None, - bacspecialite=None, - annee_bac=None, - civilite=None, - statut=None, - bacs=None, - bacspecialites=None, - annee_bacs=None, - civilites=None, - statuts=None, -): - """HTML form pour choix criteres selection etudiants""" - bacs = list(bacs) - bacs.sort(key=scu.heterogeneous_sorting_key) - bacspecialites = list(bacspecialites) - bacspecialites.sort(key=scu.heterogeneous_sorting_key) - # on peut avoir un mix de chaines vides et d'entiers: - annee_bacs = [int(x) if x else 0 for x in annee_bacs] - annee_bacs.sort() - civilites = list(civilites) - civilites.sort() - statuts = list(statuts) - statuts.sort() - # - if bac: - selected = "" - else: - selected = 'selected="selected"' - F = [ - """
        -

        Bac: ") - if bacspecialite: - selected = "" - else: - selected = 'selected="selected"' - F.append( - """  Bac/Specialité: ") - # - if annee_bac: - selected = "" - else: - selected = 'selected="selected"' - F.append( - """  Année bac: ") - # - F.append( - """  Genre: ") - - F.append( - """  Statut: ") - - if only_primo: - checked = 'checked="1"' - else: - checked = "" - F.append( - '
        Restreindre aux primo-entrants' - % checked - ) - F.append( - '' % formsemestre_id - ) - F.append('' % percent) - F.append("

        ") - return "\n".join(F) - - -def _descr_etud_set(etudids): - "textual html description of a set of etudids" - etuds = [] - for etudid in etudids: - etuds.append(sco_etud.get_etud_info(etudid=etudid, filled=True)[0]) - # sort by name - etuds.sort(key=itemgetter("nom")) - return ", ".join([e["nomprenom"] for e in etuds]) - - -def _count_dem_reo(formsemestre_id, etudids): - "count nb of demissions and reorientation in this etud set" - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - - dems = set() - reos = set() - for etudid in etudids: - if nt.get_etud_etat(etudid) == "D": - dems.add(etudid) - dec = nt.get_etud_decision_sem(etudid) - if dec and dec["code"] in sco_codes_parcours.CODES_SEM_REO: - reos.add(etudid) - return dems, reos - - -"""OLDGEA: -27s pour S1 F.I. classique Semestre 1 2006-2007 -B 2.3s -C 5.6s -D 5.9s -Z 27s => cache des semestres pour nt - -à chaud: 3s -B: etuds sets: 2.4s => lent: N x getEtudInfo (non caché) -""" - -EXP_LIC = re.compile(r"licence", re.I) -EXP_LPRO = re.compile(r"professionnelle", re.I) - - -def _codesem(sem, short=True, prefix=""): - "code semestre: S1 ou S1d" - idx = sem["semestre_id"] - # semestre décalé ? - # les semestres pairs normaux commencent entre janvier et mars - # les impairs normaux entre aout et decembre - d = "" - if idx and idx > 0 and sem["date_debut"]: - mois_debut = int(sem["date_debut"].split("/")[1]) - if (idx % 2 and mois_debut < 3) or (idx % 2 == 0 and mois_debut >= 8): - d = "d" - if idx == -1: - if short: - idx = "Autre " - else: - idx = sem["titre"] + " " - idx = EXP_LPRO.sub("pro.", idx) - idx = EXP_LIC.sub("Lic.", idx) - prefix = "" # indique titre au lieu de Sn - return "%s%s%s" % (prefix, idx, d) - - -def get_codeparcoursetud(etud, prefix="", separator=""): - """calcule un code de parcours pour un etudiant - exemples: - 1234A pour un etudiant ayant effectué S1, S2, S3, S4 puis diplome - 12D pour un étudiant en S1, S2 puis démission en S2 - 12R pour un etudiant en S1, S2 réorienté en fin de S2 - Construit aussi un dict: { semestre_id : decision_jury | None } - """ - p = [] - decisions_jury = {} - # élimine les semestres spéciaux sans parcours (LP...) - sems = [s for s in etud["sems"] if s["semestre_id"] >= 0] - i = len(sems) - 1 - while i >= 0: - s = sems[i] # 'sems' est a l'envers, du plus recent au plus ancien - s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) - - p.append(_codesem(s, prefix=prefix)) - # code decisions jury de chaque semestre: - if nt.get_etud_etat(etud["etudid"]) == "D": - decisions_jury[s["semestre_id"]] = "DEM" - else: - dec = nt.get_etud_decision_sem(etud["etudid"]) - if not dec: - decisions_jury[s["semestre_id"]] = "" - else: - decisions_jury[s["semestre_id"]] = dec["code"] - # code etat dans le codeparcours sur dernier semestre seulement - if i == 0: - # Démission - if nt.get_etud_etat(etud["etudid"]) == "D": - p.append(":D") - else: - dec = nt.get_etud_decision_sem(etud["etudid"]) - if dec and dec["code"] in sco_codes_parcours.CODES_SEM_REO: - p.append(":R") - if ( - dec - and s["semestre_id"] == nt.parcours.NB_SEM - and code_semestre_validant(dec["code"]) - ): - p.append(":A") - i -= 1 - return separator.join(p), decisions_jury - - -def tsp_etud_list( - formsemestre_id, - only_primo=False, - bac="", # selection sur type de bac - bacspecialite="", - annee_bac="", - civilite="", - statut="", -): - """Liste des etuds a considerer dans table suivi parcours - ramene aussi ensembles des bacs, genres, statuts de (tous) les etudiants - """ - # log('tsp_etud_list(%s, bac="%s")' % (formsemestre_id,bac)) - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - etudids = nt.get_etudids() - etuds = [] - bacs = set() - bacspecialites = set() - annee_bacs = set() - civilites = set() - statuts = set() - for etudid in etudids: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - bacspe = etud["bac"] + " / " + etud["specialite"] - # sélection sur bac, primo, ...: - if ( - (not bac or (bac == etud["bac"])) - and (not bacspecialite or (bacspecialite == bacspe)) - and (not annee_bac or (annee_bac == str(etud["annee_bac"]))) - and (not civilite or (civilite == etud["civilite"])) - and (not statut or (statut == etud["statut"])) - and (not only_primo or is_primo_etud(etud, sem)) - ): - etuds.append(etud) - - bacs.add(etud["bac"]) - bacspecialites.add(bacspe) - annee_bacs.add(etud["annee_bac"]) - civilites.add(etud["civilite"]) - if etud["statut"]: # ne montre pas les statuts non renseignés - statuts.add(etud["statut"]) - # log('tsp_etud_list: %s etuds' % len(etuds)) - return etuds, bacs, bacspecialites, annee_bacs, civilites, statuts - - -def tsp_grouped_list(codes_etuds): - """Liste pour table regroupant le nombre d'étudiants (+ bulle avec les noms) de chaque parcours""" - L = [] - parcours = list(codes_etuds.keys()) - parcours.sort() - for p in parcours: - nb = len(codes_etuds[p]) - l = {"parcours": p, "nb": nb} - if nb <= MAX_ETUD_IN_DESCR: - l["_nb_help"] = _descr_etud_set([e["etudid"] for e in codes_etuds[p]]) - L.append(l) - # tri par effectifs décroissants - L.sort(key=itemgetter("nb")) - return L - - -def table_suivi_parcours(formsemestre_id, only_primo=False, grouped_parcours=True): - """Tableau recapitulant tous les parcours""" - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - etuds, bacs, bacspecialites, annee_bacs, civilites, statuts = tsp_etud_list( - formsemestre_id, only_primo=only_primo - ) - codes_etuds = scu.DictDefault(defaultvalue=[]) - for etud in etuds: - etud["codeparcours"], etud["decisions_jury"] = get_codeparcoursetud(etud) - codes_etuds[etud["codeparcours"]].append(etud) - fiche_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] - ) - etud["_nom_target"] = fiche_url - etud["_prenom_target"] = fiche_url - etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) - - titles = { - "parcours": "Code parcours", - "nb": "Nombre d'étudiants", - "civilite": "", - "nom": "Nom", - "prenom": "Prénom", - "etudid": "etudid", - "codeparcours": "Code parcours", - "bac": "Bac", - "specialite": "Spe.", - } - - if grouped_parcours: - L = tsp_grouped_list(codes_etuds) - columns_ids = ("parcours", "nb") - else: - # Table avec le parcours de chaque étudiant: - L = etuds - columns_ids = ( - "etudid", - "civilite", - "nom", - "prenom", - "bac", - "specialite", - "codeparcours", - ) - # Calcule intitulés de colonnes - S = set() - sems_ids = list(S.union(*[list(e["decisions_jury"].keys()) for e in etuds])) - sems_ids.sort() - sem_tits = ["S%s" % s for s in sems_ids] - titles.update([(s, s) for s in sem_tits]) - columns_ids += tuple(sem_tits) - for etud in etuds: - for s in etud["decisions_jury"]: - etud["S%s" % s] = etud["decisions_jury"][s] - - if only_primo: - primostr = "primo-entrants du" - else: - primostr = "passés dans le" - tab = GenTable( - columns_ids=columns_ids, - rows=L, - titles=titles, - origin="Généré par %s le " % sco_version.SCONAME - + scu.timedate_human_repr() - + "", - caption="Parcours suivis, étudiants %s semestre " % primostr - + sem["titreannee"], - page_title="Parcours " + sem["titreannee"], - html_sortable=True, - html_class="table_leftalign table_listegroupe", - html_next_section=""" - - - - - -
        1, 2, ... numéros de semestres
        1d, 2d, ...semestres "décalés"
        :A étudiants diplômés
        :R étudiants réorientés
        :D étudiants démissionnaires
        """, - bottom_titles={ - "parcours": "Total", - "nb": len(etuds), - "codeparcours": len(etuds), - }, - preferences=sco_preferences.SemPreferences(formsemestre_id), - ) - return tab - - -def tsp_form_primo_group(only_primo, no_grouping, formsemestre_id, format): - """Element de formulaire pour choisir si restriction aux primos entrants et groupement par lycees""" - F = ["""
        """ % request.base_url] - if only_primo: - checked = 'checked="1"' - else: - checked = "" - F.append( - 'Restreindre aux primo-entrants' - % checked - ) - if no_grouping: - checked = 'checked="1"' - else: - checked = "" - F.append( - 'Lister chaque étudiant' - % checked - ) - F.append( - '' % formsemestre_id - ) - F.append('' % format) - F.append("""
        """) - return "\n".join(F) - - -def formsemestre_suivi_parcours( - formsemestre_id, - format="html", - only_primo=False, - no_grouping=False, -): - """Effectifs dans les differents parcours possibles.""" - tab = table_suivi_parcours( - formsemestre_id, - only_primo=only_primo, - grouped_parcours=not no_grouping, - ) - tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) - if only_primo: - tab.base_url += "&only_primo=1" - if no_grouping: - tab.base_url += "&no_grouping=1" - t = tab.make_page(format=format, with_html_headers=False) - if format != "html": - return t - F = [tsp_form_primo_group(only_primo, no_grouping, formsemestre_id, format)] - - H = [ - html_sco_header.sco_header( - page_title=tab.page_title, - init_qtip=True, - javascripts=["js/etud_info.js"], - ), - """

        Parcours suivis par les étudiants de ce semestre

        """, - "\n".join(F), - t, - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -# ------------- -def graph_parcours( - formsemestre_id, - format="svg", - only_primo=False, - bac="", # selection sur type de bac - bacspecialite="", - annee_bac="", - civilite="", - statut="", -): - """""" - etuds, bacs, bacspecialites, annee_bacs, civilites, statuts = tsp_etud_list( - formsemestre_id, - only_primo=only_primo, - bac=bac, - bacspecialite=bacspecialite, - annee_bac=annee_bac, - civilite=civilite, - statut=statut, - ) - # log('graph_parcours: %s etuds (only_primo=%s)' % (len(etuds), only_primo)) - if not etuds: - return "", etuds, bacs, bacspecialites, annee_bacs, civilites, statuts - edges = scu.DictDefault( - defaultvalue=set() - ) # {("SEM"formsemestre_id_origin, "SEM"formsemestre_id_dest) : etud_set} - - def sem_node_name(sem, prefix="SEM"): - "pydot node name for this integer id" - return prefix + str(sem["formsemestre_id"]) - - sems = {} - effectifs = scu.DictDefault(defaultvalue=set()) # formsemestre_id : etud_set - decisions = scu.DictDefault(defaultvalue={}) # formsemestre_id : { code : nb_etud } - isolated_nodes = [] # [ node_name_de_formsemestre_id, ... ] - connected_nodes = set() # { node_name_de_formsemestre_id } - diploma_nodes = [] - dem_nodes = {} # formsemestre_id : noeud (node name) pour demissionnaires - nar_nodes = {} # formsemestre_id : noeud pour NAR - for etud in etuds: - nxt = {} - etudid = etud["etudid"] - for s in etud["sems"]: # du plus recent au plus ancien - s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) - dec = nt.get_etud_decision_sem(etudid) - if nxt: - if ( - s["semestre_id"] == nt.parcours.NB_SEM - and dec - and code_semestre_validant(dec["code"]) - and nt.get_etud_etat(etudid) == "I" - ): - # cas particulier du diplome puis poursuite etude - edges[ - ( - sem_node_name(s, "_dipl_"), - sem_node_name(nxt), # = "SEM{formsemestre_id}" - ) - ].add(etudid) - else: - edges[(sem_node_name(s), sem_node_name(nxt))].add(etudid) - connected_nodes.add(sem_node_name(s)) - connected_nodes.add(sem_node_name(nxt)) - else: - isolated_nodes.append(sem_node_name(s)) - sems[s["formsemestre_id"]] = s - effectifs[s["formsemestre_id"]].add(etudid) - nxt = s - # Compte decisions jury de chaque semestres: - dc = decisions[s["formsemestre_id"]] - if dec: - if dec["code"] in dc: - dc[dec["code"]] += 1 - else: - dc[dec["code"]] = 1 - # ajout noeud pour demissionnaires - if nt.get_etud_etat(etudid) == "D": - nid = sem_node_name(s, "_dem_") - dem_nodes[s["formsemestre_id"]] = nid - edges[(sem_node_name(s), nid)].add(etudid) - # ajout noeud pour NAR (seulement pour noeud de depart) - if ( - s["formsemestre_id"] == formsemestre_id - and dec - and dec["code"] == sco_codes_parcours.NAR - ): - nid = sem_node_name(s, "_nar_") - nar_nodes[s["formsemestre_id"]] = nid - edges[(sem_node_name(s), nid)].add(etudid) - - # si "terminal", ajoute noeud pour diplomes - if s["semestre_id"] == nt.parcours.NB_SEM: - if ( - dec - and code_semestre_validant(dec["code"]) - and nt.get_etud_etat(etudid) == "I" - ): - nid = sem_node_name(s, "_dipl_") - edges[(sem_node_name(s), nid)].add(etudid) - diploma_nodes.append(nid) - # - g = scu.graph_from_edges(list(edges.keys())) - for fid in isolated_nodes: - if not fid in connected_nodes: - n = pydot.Node(name=fid) - g.add_node(n) - g.set("rankdir", "LR") # left to right - g.set_fontname("Helvetica") - if format == "svg": - g.set_bgcolor("#fffff0") # ou 'transparent' - # titres des semestres: - for s in sems.values(): - n = g.get_node(sem_node_name(s))[0] - log("s['formsemestre_id'] = %s" % s["formsemestre_id"]) - log("n=%s" % n) - log("get=%s" % g.get_node(sem_node_name(s))) - log("nodes names = %s" % [x.get_name() for x in g.get_node_list()]) - if s["modalite"] and s["modalite"] != FormationModalite.DEFAULT_MODALITE: - modalite = " " + s["modalite"] - else: - modalite = "" - label = "%s%s\\n%d/%s - %d/%s\\n%d" % ( - _codesem(s, short=False, prefix="S"), - modalite, - s["mois_debut_ord"], - s["annee_debut"][2:], - s["mois_fin_ord"], - s["annee_fin"][2:], - len(effectifs[s["formsemestre_id"]]), - ) - n.set("label", scu.suppress_accents(label)) - n.set_fontname("Helvetica") - n.set_fontsize(8.0) - n.set_width(1.2) - n.set_shape("box") - n.set_URL(f"formsemestre_status?formsemestre_id={s['formsemestre_id']}") - # semestre de depart en vert - n = g.get_node("SEM" + str(formsemestre_id))[0] - n.set_color("green") - # demissions en rouge, octagonal - for nid in dem_nodes.values(): - n = g.get_node(nid)[0] - n.set_color("red") - n.set_shape("octagon") - n.set("label", "Dem.") - - # NAR en rouge, Mcircle - for nid in nar_nodes.values(): - n = g.get_node(nid)[0] - n.set_color("red") - n.set_shape("Mcircle") - n.set("label", sco_codes_parcours.NAR) - # diplomes: - for nid in diploma_nodes: - n = g.get_node(nid)[0] - n.set_color("red") - n.set_shape("ellipse") - n.set("label", "Diplome") # bug si accent (pas compris pourquoi) - # Arètes: - bubbles = {} # substitue titres pour bulle aides: src_id:dst_id : etud_descr - for (src_id, dst_id) in edges.keys(): - e = g.get_edge(src_id, dst_id)[0] - e.set("arrowhead", "normal") - e.set("arrowsize", 1) - e.set_label(len(edges[(src_id, dst_id)])) - e.set_fontname("Helvetica") - e.set_fontsize(8.0) - # bulle avec liste etudiants - if len(edges[(src_id, dst_id)]) <= MAX_ETUD_IN_DESCR: - etud_descr = _descr_etud_set(edges[(src_id, dst_id)]) - bubbles[src_id + ":" + dst_id] = etud_descr - e.set_URL(f"__xxxetudlist__?{src_id}:{dst_id}") - # Genere graphe - _, path = tempfile.mkstemp(".gr") - g.write(path=path, format=format) - with open(path, "rb") as f: - data = f.read() - log("dot generated %d bytes in %s format" % (len(data), format)) - if not data: - log("graph.to_string=%s" % g.to_string()) - raise ValueError( - "Erreur lors de la génération du document au format %s" % format - ) - os.unlink(path) - if format == "svg": - # dot génère un document XML complet, il faut enlever l'en-tête - data_str = data.decode("utf-8") - data = "Parcours des étudiants de ce semestre""", - doc, - "

        %d étudiants sélectionnés

        " % len(etuds), - _gen_form_selectetuds( - formsemestre_id, - only_primo=only_primo, - bac=bac, - bacspecialite=bacspecialite, - annee_bac=annee_bac, - civilite=civilite, - statut=statut, - bacs=bacs, - bacspecialites=bacspecialites, - annee_bacs=annee_bacs, - civilites=civilites, - statuts=statuts, - percent=0, - ), - """

        Origine et devenir des étudiants inscrits dans %(titreannee)s""" - % sem, - """(version pdf""" - % url_for("notes.formsemestre_graph_parcours", format="pdf", **url_kw), - """, image PNG)""" - % url_for("notes.formsemestre_graph_parcours", format="png", **url_kw), - """

        """, - """

        Le graphe permet de suivre les étudiants inscrits dans le semestre - sélectionné (dessiné en vert). Chaque rectangle représente un semestre (cliquez dedans - pour afficher son tableau de bord). Les flèches indiquent le nombre d'étudiants passant - d'un semestre à l'autre (s'il y en a moins de %s, vous pouvez visualiser leurs noms en - passant la souris sur le chiffre). -

        """ - % MAX_ETUD_IN_DESCR, - html_sco_header.sco_footer(), - ] - return "\n".join(H) - else: - raise ValueError("invalid format: %s" % format) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Rapports suivi: + - statistiques decisions + - suivi cohortes +""" +import os +import tempfile +import re +import time +import datetime +from operator import itemgetter + +from flask import url_for, g, request +import pydot + +from app.but import jury_but +from app.comp import res_sem +from app.comp.res_compat import NotesTableCompat +from app.models import FormSemestre, ScolarAutorisationInscription +from app.models import FormationModalite +from app.models.etudiants import Identite + +import app.scodoc.sco_utils as scu +from app.scodoc import notesdb as ndb +from app.scodoc import html_sco_header +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_etud +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_preferences +from app.scodoc import sco_pvjury +import sco_version +from app.scodoc.gen_tables import GenTable +from app import log +from app.scodoc.sco_codes_parcours import code_semestre_validant + +MAX_ETUD_IN_DESCR = 20 + +LEGENDES_CODES_BUT = { + "Nb_rcue_valides": "nb RCUE validés", + "decision_annee": "code jury annuel BUT", +} + + +def formsemestre_etuds_stats(sem: dict, only_primo=False): + """Récupère liste d'etudiants avec etat et decision.""" + formsemestre: FormSemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + T = nt.get_table_moyennes_triees() + + # Décisions de jury BUT pour les semestres pairs seulement + jury_but_mode = ( + formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0 + ) + # Construit liste d'étudiants du semestre avec leur decision + etuds = [] + for t in T: + etudid = t[-1] + etudiant: Identite = Identite.query.get(etudid) + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + decision = nt.get_etud_decision_sem(etudid) + if decision: + etud["codedecision"] = decision["code"] + etud["etat"] = nt.get_etud_etat(etudid) + if etud["etat"] == "D": + etud["codedecision"] = "DEM" + if "codedecision" not in etud: + etud["codedecision"] = "(nd)" # pas de decision jury + # Ajout devenir (autorisations inscriptions), utile pour stats passage + aut_list = ScolarAutorisationInscription.query.filter_by( + etudid=etudid, origin_formsemestre_id=sem["formsemestre_id"] + ).all() + autorisations = [f"S{a.semestre_id}" for a in aut_list] + autorisations.sort() + autorisations_str = ", ".join(autorisations) + etud["devenir"] = autorisations_str + # Décisions de jury BUT (APC) + if jury_but_mode: + deca = jury_but.DecisionsProposeesAnnee(etudiant, formsemestre) + etud["nb_rcue_valides"] = deca.nb_rcue_valides + etud["decision_annee"] = deca.code_valide + # Ajout clé 'bac-specialite' + bs = [] + if etud["bac"]: + bs.append(etud["bac"]) + if etud["specialite"]: + bs.append(etud["specialite"]) + etud["bac-specialite"] = " ".join(bs) + # + if (not only_primo) or is_primo_etud(etud, sem): + etuds.append(etud) + return etuds + + +def is_primo_etud(etud: dict, sem: dict): + """Determine si un (filled) etud a été inscrit avant ce semestre. + Regarde la liste des semestres dans lesquels l'étudiant est inscrit. + Si semestre pair, considère comme primo-entrants ceux qui étaient + primo dans le précédent (S_{2n-1}). + """ + debut_cur = sem["date_debut_iso"] + # si semestre impair et sem. précédent contigu, recule date debut + if ( + (len(etud["sems"]) > 1) + and (sem["semestre_id"] % 2 == 0) + and (etud["sems"][1]["semestre_id"] == (sem["semestre_id"] - 1)) + ): + debut_cur = etud["sems"][1]["date_debut_iso"] + for s in etud["sems"]: # le + recent d'abord + if s["date_debut_iso"] < debut_cur: + return False + return True + + +def _categories_and_results(etuds, category, result): + categories = {} + results = {} + for etud in etuds: + categories[etud[category]] = True + results[etud[result]] = True + categories = list(categories.keys()) + categories.sort(key=scu.heterogeneous_sorting_key) + results = list(results.keys()) + results.sort(key=scu.heterogeneous_sorting_key) + return categories, results + + +def _results_by_category( + etuds, + category="", + result="", + category_name=None, + formsemestre_id=None, +): + """Construit table: categories (eg types de bacs) en ligne, décisions jury en colonnes + + etuds est une liste d'etuds (dicts). + category et result sont des clés de etud (category définie les lignes, result les colonnes). + + Retourne une table. + """ + if category_name is None: + category_name = category + # types de bacs differents: + categories, results = _categories_and_results(etuds, category, result) + # + Count = {} # { bac : { decision : nb_avec_ce_bac_et_ce_code } } + results = {} # { result_value : True } + for etud in etuds: + results[etud[result]] = True + if etud[category] in Count: + Count[etud[category]][etud[result]] += 1 + else: + Count[etud[category]] = scu.DictDefault(kv_dict={etud[result]: 1}) + # conversion en liste de dict + C = [Count[cat] for cat in categories] + # Totaux par lignes et colonnes + tot = 0 + for l in [Count[cat] for cat in categories]: + l["sum"] = sum(l.values()) + tot += l["sum"] + # pourcentages sur chaque total de ligne + for l in C: + l["sumpercent"] = "%2.1f%%" % ((100.0 * l["sum"]) / tot) + # + codes = list(results.keys()) + codes.sort(key=scu.heterogeneous_sorting_key) + + bottom_titles = [] + if C: # ligne du bas avec totaux: + bottom_titles = {} + for code in codes: + bottom_titles[code] = sum([l[code] for l in C]) + bottom_titles["sum"] = tot + bottom_titles["sumpercent"] = "100%" + bottom_titles["row_title"] = "Total" + + # ajout titre ligne: + for (cat, l) in zip(categories, C): + l["row_title"] = cat if cat is not None else "?" + + # + codes.append("sum") + codes.append("sumpercent") + + # on veut { ADM : ADM, ... } + titles = {x: x for x in codes} + # sauf pour + titles.update(LEGENDES_CODES_BUT) + titles["sum"] = "Total" + titles["sumpercent"] = "%" + titles["DEM"] = "Dém." # démissions + titles["row_title"] = titles.get(category_name, category_name) + return GenTable( + titles=titles, + columns_ids=codes, + rows=C, + bottom_titles=bottom_titles, + html_col_width="4em", + html_sortable=True, + preferences=sco_preferences.SemPreferences(formsemestre_id), + ) + + +# pages +def formsemestre_report( + formsemestre_id, + etuds, + category="bac", + result="codedecision", + category_name="", + result_name="", + title="Statistiques", + only_primo=None, +): + """ + Tableau sur résultats (result) par type de category bac + """ + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + if not category_name: + category_name = category + if not result_name: + result_name = result + if result_name == "codedecision": + result_name = "résultats" + # + tab = _results_by_category( + etuds, + category=category, + category_name=category_name, + result=result, + formsemestre_id=formsemestre_id, + ) + # + tab.filename = scu.make_filename("stats " + sem["titreannee"]) + + tab.origin = f"Généré par {sco_version.SCONAME} le {scu.timedate_human_repr()}" + tab.caption = ( + f"Répartition des résultats par {category_name}, semestre {sem['titreannee']}" + ) + tab.html_caption = f"Répartition des résultats par {category_name}." + tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) + if only_primo: + tab.base_url += "&only_primo=on" + return tab + + +# def formsemestre_report_bacs(formsemestre_id, format='html'): +# """ +# Tableau sur résultats par type de bac +# """ +# sem = sco_formsemestre.get_formsemestre( formsemestre_id) +# title = 'Statistiques bacs ' + sem['titreannee'] +# etuds = formsemestre_etuds_stats(sem) +# tab = formsemestre_report(formsemestre_id, etuds, +# category='bac', result='codedecision', +# category_name='Bac', +# title=title) +# return tab.make_page( +# title = """

        Résultats de %(titreannee)s

        """ % sem, +# format=format, page_title = title) + + +def formsemestre_report_counts( + formsemestre_id: int, + format="html", + category: str = "bac", + result: str = None, + allkeys: bool = False, + only_primo: bool = False, +): + """ + Tableau comptage avec choix des categories + category: attribut en lignes + result: attribut en colonnes + only_primo: restreint aux primo-entrants (= non redoublants) + allkeys: pour le menu du choix de l'attribut en colonnes: + si vrai, toutes les valeurs présentes dans les données + sinon liste prédéfinie (voir ci-dessous) + """ + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + # Décisions de jury BUT pour les semestres pairs seulement + jury_but_mode = ( + formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0 + ) + + if result is None: + result = "statut" if formsemestre.formation.is_apc() else "codedecision" + + category_name = category.capitalize() + title = "Comptages " + category_name + etuds = formsemestre_etuds_stats(sem, only_primo=only_primo) + tab = formsemestre_report( + formsemestre_id, + etuds, + category=category, + result=result, + category_name=category_name, + title=title, + only_primo=only_primo, + ) + if not etuds: + F = ["""

        Aucun étudiant

        """] + else: + if allkeys: + keys = list(etuds[0].keys()) + else: + # clés présentées à l'utilisateur: + keys = [ + "annee_bac", + "annee_naissance", + "bac", + "specialite", + "bac-specialite", + "codedecision", + "devenir", + "etat", + "civilite", + "qualite", + "villelycee", + "statut", + "type_admission", + "boursier_prec", + ] + if jury_but_mode: + keys += ["nb_rcue_valides", "decision_annee"] + keys.sort(key=scu.heterogeneous_sorting_key) + F = [ + """

        + Colonnes: ") + F.append(' Lignes: ") + if only_primo: + checked = 'checked="1"' + else: + checked = "" + F.append( + '
        Restreindre aux primo-entrants' + % checked + ) + F.append( + '' % formsemestre_id + ) + F.append("

        ") + + t = tab.make_page( + title="""

        Comptes croisés

        """, + format=format, + with_html_headers=False, + ) + if format != "html": + return t + H = [ + html_sco_header.sco_header(page_title=title), + t, + "\n".join(F), + """

        Le tableau affiche le nombre d'étudiants de ce semestre dans chacun + des cas choisis: à l'aide des deux menus, vous pouvez choisir les catégories utilisées + pour les lignes et les colonnes. Le codedecision est le code de la décision + du jury. +

        """, + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +# -------------------------------------------------------------------------- +def table_suivi_cohorte( + formsemestre_id, + percent=False, + bac="", # selection sur type de bac + bacspecialite="", + annee_bac="", + civilite=None, + statut="", + only_primo=False, +): + """ + Tableau indiquant le nombre d'etudiants de la cohorte dans chaque état: + Etat date_debut_Sn date1 date2 ... + S_n #inscrits en Sn + S_n+1 + ... + S_last + Diplome + Sorties + + Determination des dates: on regroupe les semestres commençant à des dates proches + + """ + sem = sco_formsemestre.get_formsemestre( + formsemestre_id + ) # sem est le semestre origine + t0 = time.time() + + def logt(op): + if 0: # debug, set to 0 in production + log("%s: %s" % (op, time.time() - t0)) + + logt("table_suivi_cohorte: start") + # 1-- Liste des semestres posterieurs dans lesquels ont été les etudiants de sem + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + etudids = nt.get_etudids() + + logt("A: orig etuds set") + S = {formsemestre_id: sem} # ensemble de formsemestre_id + orig_set = set() # ensemble d'etudid du semestre d'origine + bacs = set() + bacspecialites = set() + annee_bacs = set() + civilites = set() + statuts = set() + for etudid in etudids: + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + bacspe = etud["bac"] + " / " + etud["specialite"] + # sélection sur bac: + if ( + (not bac or (bac == etud["bac"])) + and (not bacspecialite or (bacspecialite == bacspe)) + and (not annee_bac or (annee_bac == str(etud["annee_bac"]))) + and (not civilite or (civilite == etud["civilite"])) + and (not statut or (statut == etud["statut"])) + and (not only_primo or is_primo_etud(etud, sem)) + ): + orig_set.add(etudid) + # semestres suivants: + for s in etud["sems"]: + if ndb.DateDMYtoISO(s["date_debut"]) > ndb.DateDMYtoISO( + sem["date_debut"] + ): + S[s["formsemestre_id"]] = s + bacs.add(etud["bac"]) + bacspecialites.add(bacspe) + annee_bacs.add(str(etud["annee_bac"])) + civilites.add(etud["civilite"]) + if etud["statut"]: # ne montre pas les statuts non renseignés + statuts.add(etud["statut"]) + sems = list(S.values()) + # tri les semestres par date de debut + for s in sems: + d, m, y = [int(x) for x in s["date_debut"].split("/")] + s["date_debut_dt"] = datetime.datetime(y, m, d) + sems.sort(key=itemgetter("date_debut_dt")) + + # 2-- Pour chaque semestre, trouve l'ensemble des etudiants venant de sem + logt("B: etuds sets") + sem["members"] = orig_set + for s in sems: + ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"formsemestre_id": s["formsemestre_id"]} + ) # sans dems + inset = set([i["etudid"] for i in ins]) + s["members"] = orig_set.intersection(inset) + nb_dipl = 0 # combien de diplomes dans ce semestre ? + if s["semestre_id"] == nt.parcours.NB_SEM: + s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) + for etudid in s["members"]: + dec = nt.get_etud_decision_sem(etudid) + if dec and code_semestre_validant(dec["code"]): + nb_dipl += 1 + s["nb_dipl"] = nb_dipl + + # 3-- Regroupe les semestres par date de debut + P = [] # liste de periodsem + + class periodsem(object): + pass + + # semestre de depart: + porigin = periodsem() + d, m, y = [int(x) for x in sem["date_debut"].split("/")] + porigin.datedebut = datetime.datetime(y, m, d) + porigin.sems = [sem] + + # + tolerance = datetime.timedelta(days=45) + for s in sems: + merged = False + for p in P: + if abs(s["date_debut_dt"] - p.datedebut) < tolerance: + p.sems.append(s) + merged = True + break + if not merged: + p = periodsem() + p.datedebut = s["date_debut_dt"] + p.sems = [s] + P.append(p) + + # 4-- regroupe par indice de semestre S_i + indices_sems = list({s["semestre_id"] for s in sems}) + indices_sems.sort() + for p in P: + p.nb_etuds = 0 # nombre total d'etudiants dans la periode + p.sems_by_id = scu.DictDefault(defaultvalue=[]) + for s in p.sems: + p.sems_by_id[s["semestre_id"]].append(s) + p.nb_etuds += len(s["members"]) + + # 5-- Contruit table + logt("C: build table") + nb_initial = len(sem["members"]) + + def fmtval(x): + if not x: + return "" # ne montre pas les 0 + if percent: + return "%2.1f%%" % (100.0 * x / nb_initial) + else: + return x + + L = [ + { + "row_title": "Origine: S%s" % sem["semestre_id"], + porigin.datedebut: nb_initial, + "_css_row_class": "sorttop", + } + ] + if nb_initial <= MAX_ETUD_IN_DESCR: + etud_descr = _descr_etud_set(sem["members"]) + L[0]["_%s_help" % porigin.datedebut] = etud_descr + for idx_sem in indices_sems: + if idx_sem >= 0: + d = {"row_title": "S%s" % idx_sem} + else: + d = {"row_title": "Autre semestre"} + + for p in P: + etuds_period = set() + for s in p.sems: + if s["semestre_id"] == idx_sem: + etuds_period = etuds_period.union(s["members"]) + nbetuds = len(etuds_period) + if nbetuds: + d[p.datedebut] = fmtval(nbetuds) + if nbetuds <= MAX_ETUD_IN_DESCR: # si peu d'etudiants, indique la liste + etud_descr = _descr_etud_set(etuds_period) + d["_%s_help" % p.datedebut] = etud_descr + L.append(d) + # Compte nb de démissions et de ré-orientation par période + logt("D: cout dems reos") + sem["dems"], sem["reos"] = _count_dem_reo(formsemestre_id, sem["members"]) + for p in P: + p.dems = set() + p.reos = set() + for s in p.sems: + d, r = _count_dem_reo(s["formsemestre_id"], s["members"]) + p.dems.update(d) + p.reos.update(r) + # Nombre total d'etudiants par periode + l = { + "row_title": "Inscrits", + "row_title_help": "Nombre d'étudiants inscrits", + "_table_part": "foot", + porigin.datedebut: fmtval(nb_initial), + } + for p in P: + l[p.datedebut] = fmtval(p.nb_etuds) + L.append(l) + # Nombre de démissions par période + l = { + "row_title": "Démissions", + "row_title_help": "Nombre de démissions pendant la période", + "_table_part": "foot", + porigin.datedebut: fmtval(len(sem["dems"])), + } + if len(sem["dems"]) <= MAX_ETUD_IN_DESCR: + etud_descr = _descr_etud_set(sem["dems"]) + l["_%s_help" % porigin.datedebut] = etud_descr + for p in P: + l[p.datedebut] = fmtval(len(p.dems)) + if len(p.dems) <= MAX_ETUD_IN_DESCR: + etud_descr = _descr_etud_set(p.dems) + l["_%s_help" % p.datedebut] = etud_descr + L.append(l) + # Nombre de réorientations par période + l = { + "row_title": "Echecs", + "row_title_help": "Ré-orientations (décisions NAR)", + "_table_part": "foot", + porigin.datedebut: fmtval(len(sem["reos"])), + } + if len(sem["reos"]) < 10: + etud_descr = _descr_etud_set(sem["reos"]) + l["_%s_help" % porigin.datedebut] = etud_descr + for p in P: + l[p.datedebut] = fmtval(len(p.reos)) + if len(p.reos) <= MAX_ETUD_IN_DESCR: + etud_descr = _descr_etud_set(p.reos) + l["_%s_help" % p.datedebut] = etud_descr + L.append(l) + # derniere ligne: nombre et pourcentage de diplomes + l = { + "row_title": "Diplômes", + "row_title_help": "Nombre de diplômés à la fin de la période", + "_table_part": "foot", + } + for p in P: + nb_dipl = 0 + for s in p.sems: + nb_dipl += s["nb_dipl"] + l[p.datedebut] = fmtval(nb_dipl) + L.append(l) + + columns_ids = [p.datedebut for p in P] + titles = dict([(p.datedebut, p.datedebut.strftime("%d/%m/%y")) for p in P]) + titles[porigin.datedebut] = porigin.datedebut.strftime("%d/%m/%y") + if percent: + pp = "(en % de la population initiale) " + titles["row_title"] = "%" + else: + pp = "" + titles["row_title"] = "" + if only_primo: + pp += "(restreint aux primo-entrants) " + if bac: + dbac = " (bacs %s)" % bac + else: + dbac = "" + if bacspecialite: + dbac += " (spécialité %s)" % bacspecialite + if annee_bac: + dbac += " (année bac %s)" % annee_bac + if civilite: + dbac += " civilité: %s" % civilite + if statut: + dbac += " statut: %s" % statut + tab = GenTable( + titles=titles, + columns_ids=columns_ids, + rows=L, + html_col_width="4em", + html_sortable=True, + filename=scu.make_filename("cohorte " + sem["titreannee"]), + origin="Généré par %s le " % sco_version.SCONAME + + scu.timedate_human_repr() + + "", + caption="Suivi cohorte " + pp + sem["titreannee"] + dbac, + page_title="Suivi cohorte " + sem["titreannee"], + html_class="table_cohorte", + preferences=sco_preferences.SemPreferences(formsemestre_id), + ) + # Explication: liste des semestres associés à chaque date + if not P: + expl = [ + '

        (aucun étudiant trouvé dans un semestre ultérieur)

        ' + ] + else: + expl = ["

        Semestres associés à chaque date:

          "] + for p in P: + expl.append("
        • %s:" % p.datedebut.strftime("%d/%m/%y")) + ls = [] + for s in p.sems: + ls.append( + '%(titreannee)s' + % s + ) + expl.append(", ".join(ls) + "
        • ") + expl.append("
        ") + logt("Z: table_suivi_cohorte done") + return ( + tab, + "\n".join(expl), + bacs, + bacspecialites, + annee_bacs, + civilites, + statuts, + ) + + +def formsemestre_suivi_cohorte( + formsemestre_id, + format="html", + percent=1, + bac="", + bacspecialite="", + annee_bac="", + civilite=None, + statut="", + only_primo=False, +): + """Affiche suivi cohortes par numero de semestre""" + annee_bac = str(annee_bac) + percent = int(percent) + ( + tab, + expl, + bacs, + bacspecialites, + annee_bacs, + civilites, + statuts, + ) = table_suivi_cohorte( + formsemestre_id, + percent=percent, + bac=bac, + bacspecialite=bacspecialite, + annee_bac=annee_bac, + civilite=civilite, + statut=statut, + only_primo=only_primo, + ) + tab.base_url = ( + "%s?formsemestre_id=%s&percent=%s&bac=%s&bacspecialite=%s&civilite=%s" + % (request.base_url, formsemestre_id, percent, bac, bacspecialite, civilite) + ) + if only_primo: + tab.base_url += "&only_primo=on" + t = tab.make_page(format=format, with_html_headers=False) + if format != "html": + return t + + base_url = request.base_url + burl = "%s?formsemestre_id=%s&bac=%s&bacspecialite=%s&civilite=%s&statut=%s" % ( + base_url, + formsemestre_id, + bac, + bacspecialite, + civilite, + statut, + ) + if percent: + pplink = '

        Afficher les résultats bruts

        ' % burl + else: + pplink = ( + '

        Afficher les résultats en pourcentages

        ' + % burl + ) + help = ( + pplink + + """ +

        Nombre d'étudiants dans chaque semestre. Les dates indiquées sont les dates approximatives de début des semestres (les semestres commençant à des dates proches sont groupés). Le nombre de diplômés est celui à la fin du semestre correspondant. Lorsqu'il y a moins de %s étudiants dans une case, vous pouvez afficher leurs noms en passant le curseur sur le chiffre.

        +

        Les menus permettent de n'étudier que certaines catégories d'étudiants (titulaires d'un type de bac, garçons ou filles). La case "restreindre aux primo-entrants" permet de ne considérer que les étudiants qui n'ont jamais été inscrits dans ScoDoc avant le semestre considéré.

        + """ + % (MAX_ETUD_IN_DESCR,) + ) + + H = [ + html_sco_header.sco_header(page_title=tab.page_title), + """

        Suivi cohorte: devenir des étudiants de ce semestre

        """, + _gen_form_selectetuds( + formsemestre_id, + only_primo=only_primo, + bac=bac, + bacspecialite=bacspecialite, + annee_bac=annee_bac, + civilite=civilite, + statut=statut, + bacs=bacs, + bacspecialites=bacspecialites, + annee_bacs=annee_bacs, + civilites=civilites, + statuts=statuts, + percent=percent, + ), + t, + help, + expl, + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +def _gen_form_selectetuds( + formsemestre_id, + percent=None, + only_primo=None, + bac=None, + bacspecialite=None, + annee_bac=None, + civilite=None, + statut=None, + bacs=None, + bacspecialites=None, + annee_bacs=None, + civilites=None, + statuts=None, +): + """HTML form pour choix criteres selection etudiants""" + bacs = list(bacs) + bacs.sort(key=scu.heterogeneous_sorting_key) + bacspecialites = list(bacspecialites) + bacspecialites.sort(key=scu.heterogeneous_sorting_key) + # on peut avoir un mix de chaines vides et d'entiers: + annee_bacs = [int(x) if x else 0 for x in annee_bacs] + annee_bacs.sort() + civilites = list(civilites) + civilites.sort() + statuts = list(statuts) + statuts.sort() + # + if bac: + selected = "" + else: + selected = 'selected="selected"' + F = [ + """
        +

        Bac: ") + if bacspecialite: + selected = "" + else: + selected = 'selected="selected"' + F.append( + """  Bac/Specialité: ") + # + if annee_bac: + selected = "" + else: + selected = 'selected="selected"' + F.append( + """  Année bac: ") + # + F.append( + """  Genre: ") + + F.append( + """  Statut: ") + + if only_primo: + checked = 'checked="1"' + else: + checked = "" + F.append( + '
        Restreindre aux primo-entrants' + % checked + ) + F.append( + '' % formsemestre_id + ) + F.append('' % percent) + F.append("

        ") + return "\n".join(F) + + +def _descr_etud_set(etudids): + "textual html description of a set of etudids" + etuds = [] + for etudid in etudids: + etuds.append(sco_etud.get_etud_info(etudid=etudid, filled=True)[0]) + # sort by name + etuds.sort(key=itemgetter("nom")) + return ", ".join([e["nomprenom"] for e in etuds]) + + +def _count_dem_reo(formsemestre_id, etudids): + "count nb of demissions and reorientation in this etud set" + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + dems = set() + reos = set() + for etudid in etudids: + if nt.get_etud_etat(etudid) == "D": + dems.add(etudid) + dec = nt.get_etud_decision_sem(etudid) + if dec and dec["code"] in sco_codes_parcours.CODES_SEM_REO: + reos.add(etudid) + return dems, reos + + +"""OLDGEA: +27s pour S1 F.I. classique Semestre 1 2006-2007 +B 2.3s +C 5.6s +D 5.9s +Z 27s => cache des semestres pour nt + +à chaud: 3s +B: etuds sets: 2.4s => lent: N x getEtudInfo (non caché) +""" + +EXP_LIC = re.compile(r"licence", re.I) +EXP_LPRO = re.compile(r"professionnelle", re.I) + + +def _codesem(sem, short=True, prefix=""): + "code semestre: S1 ou S1d" + idx = sem["semestre_id"] + # semestre décalé ? + # les semestres pairs normaux commencent entre janvier et mars + # les impairs normaux entre aout et decembre + d = "" + if idx and idx > 0 and sem["date_debut"]: + mois_debut = int(sem["date_debut"].split("/")[1]) + if (idx % 2 and mois_debut < 3) or (idx % 2 == 0 and mois_debut >= 8): + d = "d" + if idx == -1: + if short: + idx = "Autre " + else: + idx = sem["titre"] + " " + idx = EXP_LPRO.sub("pro.", idx) + idx = EXP_LIC.sub("Lic.", idx) + prefix = "" # indique titre au lieu de Sn + return "%s%s%s" % (prefix, idx, d) + + +def get_codeparcoursetud(etud, prefix="", separator=""): + """calcule un code de parcours pour un etudiant + exemples: + 1234A pour un etudiant ayant effectué S1, S2, S3, S4 puis diplome + 12D pour un étudiant en S1, S2 puis démission en S2 + 12R pour un etudiant en S1, S2 réorienté en fin de S2 + Construit aussi un dict: { semestre_id : decision_jury | None } + """ + p = [] + decisions_jury = {} + # élimine les semestres spéciaux sans parcours (LP...) + sems = [s for s in etud["sems"] if s["semestre_id"] >= 0] + i = len(sems) - 1 + while i >= 0: + s = sems[i] # 'sems' est a l'envers, du plus recent au plus ancien + s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) + + p.append(_codesem(s, prefix=prefix)) + # code decisions jury de chaque semestre: + if nt.get_etud_etat(etud["etudid"]) == "D": + decisions_jury[s["semestre_id"]] = "DEM" + else: + dec = nt.get_etud_decision_sem(etud["etudid"]) + if not dec: + decisions_jury[s["semestre_id"]] = "" + else: + decisions_jury[s["semestre_id"]] = dec["code"] + # code etat dans le codeparcours sur dernier semestre seulement + if i == 0: + # Démission + if nt.get_etud_etat(etud["etudid"]) == "D": + p.append(":D") + else: + dec = nt.get_etud_decision_sem(etud["etudid"]) + if dec and dec["code"] in sco_codes_parcours.CODES_SEM_REO: + p.append(":R") + if ( + dec + and s["semestre_id"] == nt.parcours.NB_SEM + and code_semestre_validant(dec["code"]) + ): + p.append(":A") + i -= 1 + return separator.join(p), decisions_jury + + +def tsp_etud_list( + formsemestre_id, + only_primo=False, + bac="", # selection sur type de bac + bacspecialite="", + annee_bac="", + civilite="", + statut="", +): + """Liste des etuds a considerer dans table suivi parcours + ramene aussi ensembles des bacs, genres, statuts de (tous) les etudiants + """ + # log('tsp_etud_list(%s, bac="%s")' % (formsemestre_id,bac)) + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + etudids = nt.get_etudids() + etuds = [] + bacs = set() + bacspecialites = set() + annee_bacs = set() + civilites = set() + statuts = set() + for etudid in etudids: + etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + bacspe = etud["bac"] + " / " + etud["specialite"] + # sélection sur bac, primo, ...: + if ( + (not bac or (bac == etud["bac"])) + and (not bacspecialite or (bacspecialite == bacspe)) + and (not annee_bac or (annee_bac == str(etud["annee_bac"]))) + and (not civilite or (civilite == etud["civilite"])) + and (not statut or (statut == etud["statut"])) + and (not only_primo or is_primo_etud(etud, sem)) + ): + etuds.append(etud) + + bacs.add(etud["bac"]) + bacspecialites.add(bacspe) + annee_bacs.add(etud["annee_bac"]) + civilites.add(etud["civilite"]) + if etud["statut"]: # ne montre pas les statuts non renseignés + statuts.add(etud["statut"]) + # log('tsp_etud_list: %s etuds' % len(etuds)) + return etuds, bacs, bacspecialites, annee_bacs, civilites, statuts + + +def tsp_grouped_list(codes_etuds): + """Liste pour table regroupant le nombre d'étudiants (+ bulle avec les noms) de chaque parcours""" + L = [] + parcours = list(codes_etuds.keys()) + parcours.sort() + for p in parcours: + nb = len(codes_etuds[p]) + l = {"parcours": p, "nb": nb} + if nb <= MAX_ETUD_IN_DESCR: + l["_nb_help"] = _descr_etud_set([e["etudid"] for e in codes_etuds[p]]) + L.append(l) + # tri par effectifs décroissants + L.sort(key=itemgetter("nb")) + return L + + +def table_suivi_parcours(formsemestre_id, only_primo=False, grouped_parcours=True): + """Tableau recapitulant tous les parcours""" + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + etuds, bacs, bacspecialites, annee_bacs, civilites, statuts = tsp_etud_list( + formsemestre_id, only_primo=only_primo + ) + codes_etuds = scu.DictDefault(defaultvalue=[]) + for etud in etuds: + etud["codeparcours"], etud["decisions_jury"] = get_codeparcoursetud(etud) + codes_etuds[etud["codeparcours"]].append(etud) + fiche_url = url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + ) + etud["_nom_target"] = fiche_url + etud["_prenom_target"] = fiche_url + etud["_nom_td_attrs"] = 'id="%s" class="etudinfo"' % (etud["etudid"]) + + titles = { + "parcours": "Code parcours", + "nb": "Nombre d'étudiants", + "civilite": "", + "nom": "Nom", + "prenom": "Prénom", + "etudid": "etudid", + "codeparcours": "Code parcours", + "bac": "Bac", + "specialite": "Spe.", + } + + if grouped_parcours: + L = tsp_grouped_list(codes_etuds) + columns_ids = ("parcours", "nb") + else: + # Table avec le parcours de chaque étudiant: + L = etuds + columns_ids = ( + "etudid", + "civilite", + "nom", + "prenom", + "bac", + "specialite", + "codeparcours", + ) + # Calcule intitulés de colonnes + S = set() + sems_ids = list(S.union(*[list(e["decisions_jury"].keys()) for e in etuds])) + sems_ids.sort() + sem_tits = ["S%s" % s for s in sems_ids] + titles.update([(s, s) for s in sem_tits]) + columns_ids += tuple(sem_tits) + for etud in etuds: + for s in etud["decisions_jury"]: + etud["S%s" % s] = etud["decisions_jury"][s] + + if only_primo: + primostr = "primo-entrants du" + else: + primostr = "passés dans le" + tab = GenTable( + columns_ids=columns_ids, + rows=L, + titles=titles, + origin="Généré par %s le " % sco_version.SCONAME + + scu.timedate_human_repr() + + "", + caption="Parcours suivis, étudiants %s semestre " % primostr + + sem["titreannee"], + page_title="Parcours " + sem["titreannee"], + html_sortable=True, + html_class="table_leftalign table_listegroupe", + html_next_section=""" + + + + + +
        1, 2, ... numéros de semestres
        1d, 2d, ...semestres "décalés"
        :A étudiants diplômés
        :R étudiants réorientés
        :D étudiants démissionnaires
        """, + bottom_titles={ + "parcours": "Total", + "nb": len(etuds), + "codeparcours": len(etuds), + }, + preferences=sco_preferences.SemPreferences(formsemestre_id), + ) + return tab + + +def tsp_form_primo_group(only_primo, no_grouping, formsemestre_id, format): + """Element de formulaire pour choisir si restriction aux primos entrants et groupement par lycees""" + F = ["""
        """ % request.base_url] + if only_primo: + checked = 'checked="1"' + else: + checked = "" + F.append( + 'Restreindre aux primo-entrants' + % checked + ) + if no_grouping: + checked = 'checked="1"' + else: + checked = "" + F.append( + 'Lister chaque étudiant' + % checked + ) + F.append( + '' % formsemestre_id + ) + F.append('' % format) + F.append("""
        """) + return "\n".join(F) + + +def formsemestre_suivi_parcours( + formsemestre_id, + format="html", + only_primo=False, + no_grouping=False, +): + """Effectifs dans les differents parcours possibles.""" + tab = table_suivi_parcours( + formsemestre_id, + only_primo=only_primo, + grouped_parcours=not no_grouping, + ) + tab.base_url = "%s?formsemestre_id=%s" % (request.base_url, formsemestre_id) + if only_primo: + tab.base_url += "&only_primo=1" + if no_grouping: + tab.base_url += "&no_grouping=1" + t = tab.make_page(format=format, with_html_headers=False) + if format != "html": + return t + F = [tsp_form_primo_group(only_primo, no_grouping, formsemestre_id, format)] + + H = [ + html_sco_header.sco_header( + page_title=tab.page_title, + init_qtip=True, + javascripts=["js/etud_info.js"], + ), + """

        Parcours suivis par les étudiants de ce semestre

        """, + "\n".join(F), + t, + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +# ------------- +def graph_parcours( + formsemestre_id, + format="svg", + only_primo=False, + bac="", # selection sur type de bac + bacspecialite="", + annee_bac="", + civilite="", + statut="", +): + """""" + etuds, bacs, bacspecialites, annee_bacs, civilites, statuts = tsp_etud_list( + formsemestre_id, + only_primo=only_primo, + bac=bac, + bacspecialite=bacspecialite, + annee_bac=annee_bac, + civilite=civilite, + statut=statut, + ) + # log('graph_parcours: %s etuds (only_primo=%s)' % (len(etuds), only_primo)) + if not etuds: + return "", etuds, bacs, bacspecialites, annee_bacs, civilites, statuts + edges = scu.DictDefault( + defaultvalue=set() + ) # {("SEM"formsemestre_id_origin, "SEM"formsemestre_id_dest) : etud_set} + + def sem_node_name(sem, prefix="SEM"): + "pydot node name for this integer id" + return prefix + str(sem["formsemestre_id"]) + + sems = {} + effectifs = scu.DictDefault(defaultvalue=set()) # formsemestre_id : etud_set + decisions = scu.DictDefault(defaultvalue={}) # formsemestre_id : { code : nb_etud } + isolated_nodes = [] # [ node_name_de_formsemestre_id, ... ] + connected_nodes = set() # { node_name_de_formsemestre_id } + diploma_nodes = [] + dem_nodes = {} # formsemestre_id : noeud (node name) pour demissionnaires + nar_nodes = {} # formsemestre_id : noeud pour NAR + for etud in etuds: + nxt = {} + etudid = etud["etudid"] + for s in etud["sems"]: # du plus recent au plus ancien + s_formsemestre = FormSemestre.query.get_or_404(s["formsemestre_id"]) + nt: NotesTableCompat = res_sem.load_formsemestre_results(s_formsemestre) + dec = nt.get_etud_decision_sem(etudid) + if nxt: + if ( + s["semestre_id"] == nt.parcours.NB_SEM + and dec + and code_semestre_validant(dec["code"]) + and nt.get_etud_etat(etudid) == scu.INSCRIT + ): + # cas particulier du diplome puis poursuite etude + edges[ + ( + sem_node_name(s, "_dipl_"), + sem_node_name(nxt), # = "SEM{formsemestre_id}" + ) + ].add(etudid) + else: + edges[(sem_node_name(s), sem_node_name(nxt))].add(etudid) + connected_nodes.add(sem_node_name(s)) + connected_nodes.add(sem_node_name(nxt)) + else: + isolated_nodes.append(sem_node_name(s)) + sems[s["formsemestre_id"]] = s + effectifs[s["formsemestre_id"]].add(etudid) + nxt = s + # Compte decisions jury de chaque semestres: + dc = decisions[s["formsemestre_id"]] + if dec: + if dec["code"] in dc: + dc[dec["code"]] += 1 + else: + dc[dec["code"]] = 1 + # ajout noeud pour demissionnaires + if nt.get_etud_etat(etudid) == "D": + nid = sem_node_name(s, "_dem_") + dem_nodes[s["formsemestre_id"]] = nid + edges[(sem_node_name(s), nid)].add(etudid) + # ajout noeud pour NAR (seulement pour noeud de depart) + if ( + s["formsemestre_id"] == formsemestre_id + and dec + and dec["code"] == sco_codes_parcours.NAR + ): + nid = sem_node_name(s, "_nar_") + nar_nodes[s["formsemestre_id"]] = nid + edges[(sem_node_name(s), nid)].add(etudid) + + # si "terminal", ajoute noeud pour diplomes + if s["semestre_id"] == nt.parcours.NB_SEM: + if ( + dec + and code_semestre_validant(dec["code"]) + and nt.get_etud_etat(etudid) == scu.INSCRIT + ): + nid = sem_node_name(s, "_dipl_") + edges[(sem_node_name(s), nid)].add(etudid) + diploma_nodes.append(nid) + # + g = scu.graph_from_edges(list(edges.keys())) + for fid in isolated_nodes: + if not fid in connected_nodes: + n = pydot.Node(name=fid) + g.add_node(n) + g.set("rankdir", "LR") # left to right + g.set_fontname("Helvetica") + if format == "svg": + g.set_bgcolor("#fffff0") # ou 'transparent' + # titres des semestres: + for s in sems.values(): + n = g.get_node(sem_node_name(s))[0] + log("s['formsemestre_id'] = %s" % s["formsemestre_id"]) + log("n=%s" % n) + log("get=%s" % g.get_node(sem_node_name(s))) + log("nodes names = %s" % [x.get_name() for x in g.get_node_list()]) + if s["modalite"] and s["modalite"] != FormationModalite.DEFAULT_MODALITE: + modalite = " " + s["modalite"] + else: + modalite = "" + label = "%s%s\\n%d/%s - %d/%s\\n%d" % ( + _codesem(s, short=False, prefix="S"), + modalite, + s["mois_debut_ord"], + s["annee_debut"][2:], + s["mois_fin_ord"], + s["annee_fin"][2:], + len(effectifs[s["formsemestre_id"]]), + ) + n.set("label", scu.suppress_accents(label)) + n.set_fontname("Helvetica") + n.set_fontsize(8.0) + n.set_width(1.2) + n.set_shape("box") + n.set_URL(f"formsemestre_status?formsemestre_id={s['formsemestre_id']}") + # semestre de depart en vert + n = g.get_node("SEM" + str(formsemestre_id))[0] + n.set_color("green") + # demissions en rouge, octagonal + for nid in dem_nodes.values(): + n = g.get_node(nid)[0] + n.set_color("red") + n.set_shape("octagon") + n.set("label", "Dem.") + + # NAR en rouge, Mcircle + for nid in nar_nodes.values(): + n = g.get_node(nid)[0] + n.set_color("red") + n.set_shape("Mcircle") + n.set("label", sco_codes_parcours.NAR) + # diplomes: + for nid in diploma_nodes: + n = g.get_node(nid)[0] + n.set_color("red") + n.set_shape("ellipse") + n.set("label", "Diplome") # bug si accent (pas compris pourquoi) + # Arètes: + bubbles = {} # substitue titres pour bulle aides: src_id:dst_id : etud_descr + for (src_id, dst_id) in edges.keys(): + e = g.get_edge(src_id, dst_id)[0] + e.set("arrowhead", "normal") + e.set("arrowsize", 1) + e.set_label(len(edges[(src_id, dst_id)])) + e.set_fontname("Helvetica") + e.set_fontsize(8.0) + # bulle avec liste etudiants + if len(edges[(src_id, dst_id)]) <= MAX_ETUD_IN_DESCR: + etud_descr = _descr_etud_set(edges[(src_id, dst_id)]) + bubbles[src_id + ":" + dst_id] = etud_descr + e.set_URL(f"__xxxetudlist__?{src_id}:{dst_id}") + # Genere graphe + _, path = tempfile.mkstemp(".gr") + g.write(path=path, format=format) + with open(path, "rb") as f: + data = f.read() + log("dot generated %d bytes in %s format" % (len(data), format)) + if not data: + log("graph.to_string=%s" % g.to_string()) + raise ValueError( + "Erreur lors de la génération du document au format %s" % format + ) + os.unlink(path) + if format == "svg": + # dot génère un document XML complet, il faut enlever l'en-tête + data_str = data.decode("utf-8") + data = "Parcours des étudiants de ce semestre""", + doc, + "

        %d étudiants sélectionnés

        " % len(etuds), + _gen_form_selectetuds( + formsemestre_id, + only_primo=only_primo, + bac=bac, + bacspecialite=bacspecialite, + annee_bac=annee_bac, + civilite=civilite, + statut=statut, + bacs=bacs, + bacspecialites=bacspecialites, + annee_bacs=annee_bacs, + civilites=civilites, + statuts=statuts, + percent=0, + ), + """

        Origine et devenir des étudiants inscrits dans %(titreannee)s""" + % sem, + """(version pdf""" + % url_for("notes.formsemestre_graph_parcours", format="pdf", **url_kw), + """, image PNG)""" + % url_for("notes.formsemestre_graph_parcours", format="png", **url_kw), + """

        """, + """

        Le graphe permet de suivre les étudiants inscrits dans le semestre + sélectionné (dessiné en vert). Chaque rectangle représente un semestre (cliquez dedans + pour afficher son tableau de bord). Les flèches indiquent le nombre d'étudiants passant + d'un semestre à l'autre (s'il y en a moins de %s, vous pouvez visualiser leurs noms en + passant la souris sur le chiffre). +

        """ + % MAX_ETUD_IN_DESCR, + html_sco_header.sco_footer(), + ] + return "\n".join(H) + else: + raise ValueError("invalid format: %s" % format) diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py index ef16a6d3..bfcd0f81 100644 --- a/app/scodoc/sco_synchro_etuds.py +++ b/app/scodoc/sco_synchro_etuds.py @@ -1,885 +1,885 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Synchronisation des listes d'étudiants avec liste portail (Apogée) -""" - -import time -from operator import itemgetter - -from flask import g, url_for -from flask_login import current_user - -from app import log -from app.models import ScolarNews - -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app.scodoc import html_sco_header -from app.scodoc import sco_cache -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_groups -from app.scodoc import sco_inscr_passage -from app.scodoc import sco_portal_apogee -from app.scodoc import sco_etud -from app.scodoc.sco_exceptions import ScoValueError -from app.scodoc.sco_permissions import Permission - -# Clés utilisées pour la synchro -EKEY_APO = "nip" -EKEY_SCO = "code_nip" -EKEY_NAME = "code NIP" - -# view: -def formsemestre_synchro_etuds( - formsemestre_id, - etuds=[], # liste des codes NIP des etudiants a inscrire (ou deja inscrits) - inscrits_without_key=[], # codes etudid des etudiants sans code NIP a laisser inscrits - anneeapogee=None, - submitted=False, - dialog_confirmed=False, - export_cat_xls=None, - read_only=False, # Affiche sans permettre modifications -): - """Synchronise les étudiants de ce semestre avec ceux d'Apogée. - On a plusieurs cas de figure: L'étudiant peut être - 1- présent dans Apogée et inscrit dans le semestre ScoDoc (etuds_ok) - 2- dans Apogée, dans ScoDoc, mais pas inscrit dans le semestre (etuds_noninscrits) - 3- dans Apogée et pas dans ScoDoc (a_importer) - 4- inscrit dans le semestre ScoDoc, mais pas trouvé dans Apogée (sur la base du code NIP) - - Que faire ? - Cas 1: rien à faire - Cas 2: inscrire dans le semestre - Cas 3: importer l'étudiant (le créer) - puis l'inscrire à ce semestre. - Cas 4: lister les etudiants absents d'Apogée (indiquer leur code NIP...) - - - présenter les différents cas - - l'utilisateur valide (cocher les étudiants à importer/inscrire) - - go - - etuds: apres sélection par l'utilisateur, la liste des étudiants selectionnés - que l'on va importer/inscrire - """ - log(f"formsemestre_synchro_etuds: formsemestre_id={formsemestre_id}") - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - sem["etape_apo_str"] = sco_formsemestre.formsemestre_etape_apo_str(sem) - # Write access ? - if not current_user.has_permission(Permission.ScoEtudInscrit): - read_only = True - if read_only: - submitted = False - dialog_confirmed = False - # -- check lock - if not sem["etat"]: - raise ScoValueError("opération impossible: semestre verrouille") - if not sem["etapes"]: - raise ScoValueError( - """opération impossible: ce semestre n'a pas de code étape - (voir "Modifier ce semestre") - """ - % sem - ) - header = html_sco_header.sco_header(page_title="Synchronisation étudiants") - footer = html_sco_header.sco_footer() - base_url = url_for( - "notes.formsemestre_synchro_etuds", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - anneeapogee=anneeapogee or None, # si None, le param n'est pas dans l'URL - ) - - if anneeapogee is None: # année d'inscription par défaut - anneeapogee = scu.annee_scolaire_debut( - sem["annee_debut"], sem["mois_debut_ord"] - ) - anneeapogee = str(anneeapogee) - - if isinstance(etuds, str): - etuds = etuds.split(",") # vient du form de confirmation - elif isinstance(etuds, int): - etuds = [etuds] - if isinstance(inscrits_without_key, int): - inscrits_without_key = [inscrits_without_key] - elif isinstance(inscrits_without_key, str): - inscrits_without_key = inscrits_without_key.split(",") - elif not isinstance(inscrits_without_key, list): - raise ValueError("invalid type for inscrits_without_key") - inscrits_without_key = [int(x) for x in inscrits_without_key if x] - ( - etuds_by_cat, - a_importer, - a_inscrire, - inscrits_set, - inscrits_without_key_all, - etudsapo_ident, - ) = list_synch(sem, anneeapogee=anneeapogee) - if export_cat_xls: - filename = export_cat_xls - xls = build_page( - sem, - etuds_by_cat, - anneeapogee, - export_cat_xls=export_cat_xls, - base_url=base_url, - read_only=read_only, - ) - return scu.send_file( - xls, - mime=scu.XLS_MIMETYPE, - filename=filename, - suffix=scu.XLSX_SUFFIX, - ) - - H = [header] - if not submitted: - H += build_page( - sem, - etuds_by_cat, - anneeapogee, - base_url=base_url, - read_only=read_only, - ) - else: - etuds_set = set(etuds) - a_importer = a_importer.intersection(etuds_set) - a_desinscrire = inscrits_set - etuds_set - log("inscrits_without_key_all=%s" % set(inscrits_without_key_all)) - log("inscrits_without_key=%s" % inscrits_without_key) - a_desinscrire_without_key = set(inscrits_without_key_all) - set( - inscrits_without_key - ) - log("a_desinscrire_without_key=%s" % a_desinscrire_without_key) - inscrits_ailleurs = set(sco_inscr_passage.list_inscrits_date(sem)) - a_inscrire = a_inscrire.intersection(etuds_set) - - if not dialog_confirmed: - # Confirmation - if a_importer: - H.append("

        Étudiants à importer et inscrire :

          ") - for key in a_importer: - nom = f"""{etudsapo_ident[key]['nom']} {etudsapo_ident[key].get("prenom", "")}""" - H.append(f"
        1. {nom}
        2. ") - H.append("
        ") - - if a_inscrire: - H.append("

        Étudiants à inscrire :

          ") - for key in a_inscrire: - nom = f"""{etudsapo_ident[key]['nom']} {etudsapo_ident[key].get("prenom", "")}""" - H.append(f"
        1. {nom}
        2. ") - H.append("
        ") - - a_inscrire_en_double = inscrits_ailleurs.intersection(a_inscrire) - if a_inscrire_en_double: - H.append("

        dont étudiants déjà inscrits:

          ") - for key in a_inscrire_en_double: - nom = f"""{etudsapo_ident[key]['nom']} {etudsapo_ident[key].get("prenom", "")}""" - H.append(f'
        1. {nom}
        2. ') - H.append("
        ") - - if a_desinscrire: - H.append("

        Étudiants à désinscrire :

          ") - for key in a_desinscrire: - etud = sco_etud.get_etud_info(filled=True, code_nip=key)[0] - H.append('
        1. %(nomprenom)s
        2. ' % etud) - H.append("
        ") - if a_desinscrire_without_key: - H.append("

        Étudiants à désinscrire (sans code):

          ") - for etudid in a_desinscrire_without_key: - etud = inscrits_without_key_all[etudid] - sco_etud.format_etud_ident(etud) - H.append('
        1. %(nomprenom)s
        2. ' % etud) - H.append("
        ") - - todo = ( - a_importer or a_inscrire or a_desinscrire or a_desinscrire_without_key - ) - if not todo: - H.append("""

        Il n'y a rien à modifier !

        """) - H.append( - scu.confirm_dialog( - dest_url="formsemestre_synchro_etuds", - add_headers=False, - cancel_url="formsemestre_synchro_etuds?formsemestre_id=" - + str(formsemestre_id), - OK="Effectuer l'opération" if todo else "OK", - parameters={ - "formsemestre_id": formsemestre_id, - "etuds": ",".join(etuds), - "inscrits_without_key": ",".join( - [str(x) for x in inscrits_without_key] - ), - "submitted": 1, - "anneeapogee": anneeapogee, - }, - ) - ) - else: - # OK, do it - - # Conversions des listes de codes NIP en listes de codes etudid - def nip2etudid(code_nip): - etud = sco_etud.get_etud_info(code_nip=code_nip)[0] - return etud["etudid"] - - etudids_a_inscrire = [nip2etudid(x) for x in a_inscrire] - etudids_a_desinscrire = [nip2etudid(x) for x in a_desinscrire] - etudids_a_desinscrire += a_desinscrire_without_key - # - with sco_cache.DeferredSemCacheManager(): - do_import_etuds_from_portal(sem, a_importer, etudsapo_ident) - sco_inscr_passage.do_inscrit(sem, etudids_a_inscrire) - sco_inscr_passage.do_desinscrit(sem, etudids_a_desinscrire) - - H.append( - """

        Opération effectuée

        -
          -
        • Continuer la synchronisation
        • """ - % formsemestre_id - ) - # - partitions = sco_groups.get_partitions_list( - formsemestre_id, with_default=False - ) - if partitions: # il y a au moins une vraie partition - H.append( - f"""
        • Répartir les groupes de {partitions[0]["partition_name"]}
        • - """ - ) - - H.append(footer) - return "\n".join(H) - - -def build_page( - sem, - etuds_by_cat, - anneeapogee, - export_cat_xls=None, - base_url="", - read_only=False, -): - if export_cat_xls: - return sco_inscr_passage.etuds_select_boxes( - etuds_by_cat, export_cat_xls=export_cat_xls, base_url=base_url - ) - year = time.localtime()[0] - if anneeapogee and abs(year - int(anneeapogee)) < 50: - years = list( - range(min(year - 1, int(anneeapogee) - 1), max(year, int(anneeapogee)) + 1) - ) - else: - years = list(range(year - 1, year + 1)) - anneeapogee = "" - options = [] - for y in years: - if str(y) == anneeapogee: - sel = "selected" - else: - sel = "" - options.append('' % (str(y), sel, str(y))) - if anneeapogee: - sel = "" - else: - sel = "selected" - options.append('' % sel) - # sem['etape_apo_str'] = sem['etape_apo'] or '-' - - H = [ - """

          Synchronisation des étudiants du semestre avec Apogée

          """, - """

          Actuellement %d inscrits dans ce semestre.

          """ - % ( - len(etuds_by_cat["etuds_ok"]["etuds"]) - + len(etuds_by_cat["etuds_nonapogee"]["etuds"]) - + len(etuds_by_cat["inscrits_without_key"]["etuds"]) - ), - """

          Code étape Apogée: %(etape_apo_str)s

          -
          - """ - % sem, - """ - Année Apogée: - """, - "" - if read_only - else """ - - -  aide - """ - % sem, # " - sco_inscr_passage.etuds_select_boxes( - etuds_by_cat, - sel_inscrits=False, - show_empty_boxes=True, - base_url=base_url, - read_only=read_only, - ), - "" - if read_only - else """

          """, - formsemestre_synchro_etuds_help(sem), - """

          """, - ] - return H - - -def list_synch(sem, anneeapogee=None): - """""" - inscrits = sco_inscr_passage.list_inscrits(sem["formsemestre_id"], with_dems=True) - # Tous les ensembles d'etudiants sont ici des ensembles de codes NIP (voir EKEY_SCO) - # (sauf inscrits_without_key) - inscrits_set = set() - inscrits_without_key = {} # etudid : etud sans code NIP - for e in inscrits.values(): - if not e[EKEY_SCO]: - inscrits_without_key[e["etudid"]] = e - e["inscrit"] = True # checkbox state - else: - inscrits_set.add(e[EKEY_SCO]) - # allinscrits_set = set() # tous les inscrits scodoc avec code_nip, y compris les demissionnaires - # for e in inscrits.values(): - # if e[EKEY_SCO]: - # allinscrits_set.add(e[EKEY_SCO]) - - datefinalisationinscription_by_NIP = {} # nip : datefinalisationinscription_str - - etapes = sem["etapes"] - etudsapo_set = set() - etudsapo_ident = {} - for etape in etapes: - if etape: - etudsapo = sco_portal_apogee.get_inscrits_etape( - etape, anneeapogee=anneeapogee - ) - etudsapo_set = etudsapo_set.union(set([x[EKEY_APO] for x in etudsapo])) - for e in etudsapo: - if e[EKEY_APO] not in etudsapo_ident: - etudsapo_ident[e[EKEY_APO]] = e - datefinalisationinscription_by_NIP[e[EKEY_APO]] = e[ - "datefinalisationinscription" - ] - - # categories: - etuds_ok = etudsapo_set.intersection(inscrits_set) - etuds_aposco, a_importer, key2etudid = list_all(etudsapo_set) - etuds_noninscrits = etuds_aposco - inscrits_set - etuds_nonapogee = inscrits_set - etudsapo_set - # Etudiants ayant payé (avec balise true) - # note: si le portail ne renseigne pas cette balise, suppose que paiement ok - etuds_payes = set( - [x[EKEY_APO] for x in etudsapo if x.get("paiementinscription", True)] - ) - # - cnx = ndb.GetDBConnexion() - # Tri listes - def set_to_sorted_list(etudset, etud_apo=False, is_inscrit=False): - def key2etud(key, etud_apo=False): - if not etud_apo: - etudid = key2etudid[key] - etuds = sco_etud.identite_list(cnx, {"etudid": etudid}) - if not etuds: # ? cela ne devrait pas arriver XXX - log(f"XXX key2etud etudid={{etudid}}, type {{type(etudid)}}") - etud = etuds[0] - etud["inscrit"] = is_inscrit # checkbox state - etud[ - "datefinalisationinscription" - ] = datefinalisationinscription_by_NIP.get(key, None) - if key in etudsapo_ident: - etud["etape"] = etudsapo_ident[key].get("etape", "") - else: - # etudiant Apogee - etud = etudsapo_ident[key] - - etud["etudid"] = "" - etud["civilite"] = etud.get( - "sexe", etud.get("gender", "") - ) # la cle 'sexe' est prioritaire sur 'gender' - etud["inscrit"] = is_inscrit # checkbox state - if key in etuds_payes: - etud["paiementinscription"] = True - else: - etud["paiementinscription"] = False - return etud - - etuds = [key2etud(x, etud_apo) for x in etudset] - etuds.sort(key=itemgetter("nom")) - return etuds - - # - boites = { - "etuds_a_importer": { - "etuds": set_to_sorted_list(a_importer, is_inscrit=True, etud_apo=True), - "infos": { - "id": "etuds_a_importer", - "title": "Étudiants dans Apogée à importer", - "help": """Ces étudiants sont inscrits dans cette étape Apogée mais ne sont pas connus par ScoDoc: - cocher les noms à importer et inscrire puis appuyer sur le bouton "Appliquer".""", - "title_target": "", - "with_checkbox": True, - "etud_key": EKEY_APO, # clé à stocker dans le formulaire html - "filename": "etuds_a_importer", - }, - "nomprenoms": etudsapo_ident, - }, - "etuds_noninscrits": { - "etuds": set_to_sorted_list(etuds_noninscrits, is_inscrit=True), - "infos": { - "id": "etuds_noninscrits", - "title": "Étudiants non inscrits dans ce semestre", - "help": """Ces étudiants sont déjà connus par ScoDoc, sont inscrits dans cette étape Apogée mais ne sont pas inscrits à ce semestre ScoDoc. Cochez les étudiants à inscrire.""", - "comment": """ dans ScoDoc et Apogée,
          mais pas inscrits - dans ce semestre""", - "title_target": "", - "with_checkbox": True, - "etud_key": EKEY_SCO, - "filename": "etuds_non_inscrits", - }, - }, - "etuds_nonapogee": { - "etuds": set_to_sorted_list(etuds_nonapogee, is_inscrit=True), - "infos": { - "id": "etuds_nonapogee", - "title": "Étudiants ScoDoc inconnus dans cette étape Apogée", - "help": """Ces étudiants sont inscrits dans ce semestre ScoDoc, ont un code NIP, mais ne sont pas inscrits dans cette étape Apogée. Soit ils sont en retard pour leur inscription, soit il s'agit d'une erreur: vérifiez avec le service Scolarité de votre établissement. Autre possibilité: votre code étape semestre (%s) est incorrect ou vous n'avez pas choisi la bonne année d'inscription.""" - % sem["etape_apo_str"], - "comment": " à vérifier avec la Scolarité", - "title_target": "", - "with_checkbox": True, - "etud_key": EKEY_SCO, - "filename": "etuds_non_apogee", - }, - }, - "inscrits_without_key": { - "etuds": list(inscrits_without_key.values()), - "infos": { - "id": "inscrits_without_key", - "title": "Étudiants ScoDoc sans clé Apogée (NIP)", - "help": """Ces étudiants sont inscrits dans ce semestre ScoDoc, mais n'ont pas de code NIP: on ne peut pas les mettre en correspondance avec Apogée. Utiliser le lien 'Changer les données identité' dans le menu 'Etudiant' sur leur fiche pour ajouter cette information.""", - "title_target": "", - "with_checkbox": True, - "checkbox_name": "inscrits_without_key", - "filename": "inscrits_without_key", - }, - }, - "etuds_ok": { - "etuds": set_to_sorted_list(etuds_ok, is_inscrit=True), - "infos": { - "id": "etuds_ok", - "title": "Étudiants dans Apogée et déjà inscrits", - "help": """Ces etudiants sont inscrits dans le semestre ScoDoc et sont présents dans Apogée: - tout est donc correct. Décocher les étudiants que vous souhaitez désinscrire.""", - "title_target": "", - "with_checkbox": True, - "etud_key": EKEY_SCO, - "filename": "etuds_inscrits_ok_apo", - }, - }, - } - return ( - boites, - a_importer, - etuds_noninscrits, - inscrits_set, - inscrits_without_key, - etudsapo_ident, - ) - - -def list_all(etudsapo_set): - """Cherche le sous-ensemble des etudiants Apogee de ce semestre - qui existent dans ScoDoc. - """ - # on charge TOUS les etudiants (au pire qq 100000 ?) - # si tres grosse base, il serait mieux de faire une requete - # d'interrogation par etudiant. - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - "SELECT " - + EKEY_SCO - + """, id AS etudid - FROM identite WHERE dept_id=%(dept_id)s - """, - {"dept_id": g.scodoc_dept_id}, - ) - key2etudid = dict([(x[0], x[1]) for x in cursor.fetchall()]) - all_set = set(key2etudid.keys()) - - # ne retient que ceux dans Apo - etuds_aposco = etudsapo_set.intersection( - all_set - ) # a la fois dans Apogee et dans ScoDoc - a_importer = etudsapo_set - all_set # dans Apogee, mais inconnus dans ScoDoc - return etuds_aposco, a_importer, key2etudid - - -def formsemestre_synchro_etuds_help(sem): - sem["default_group_id"] = sco_groups.get_default_group(sem["formsemestre_id"]) - return ( - """

          Explications

          -

          Cette page permet d'importer dans le semestre destination - %(titreannee)s - les étudiants inscrits dans l'étape Apogée correspondante (%(etape_apo_str)s) -

          -

          Au départ, tous les étudiants d'Apogée sont sélectionnés; vous pouvez - en déselectionner certains. Tous les étudiants cochés seront inscrits au semestre ScoDoc, - les autres seront si besoin désinscrits. Aucune modification n'est effectuée avant - d'appuyer sur le bouton "Appliquer les modifications".

          - -

          Autres fonctions utiles

          - -
          """ - % sem - ) - - -def gender2civilite(gender): - """Le portail code en 'M', 'F', et ScoDoc en 'M', 'F', 'X'""" - if gender == "M" or gender == "F" or gender == "X": - return gender - elif not gender: - return "X" - log('gender2civilite: invalid value "%s", defaulting to "X"' % gender) - return "X" # "X" en général n'est pas affiché, donc bon choix si invalide - - -def get_opt_str(etud, k): - v = etud.get(k, None) - if not v: - return v - return v.strip() - - -def get_annee_naissance(ddmmyyyyy: str) -> int: - """Extrait l'année de la date stockée en dd/mm/yyyy dans le XML portail""" - if not ddmmyyyyy: - return None - try: - return int(ddmmyyyyy.split("/")[2]) - except (ValueError, IndexError): - return None - - -def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident): - """Inscrit les etudiants Apogee dans ce semestre.""" - log("do_import_etuds_from_portal: a_importer=%s" % a_importer) - if not a_importer: - return - cnx = ndb.GetDBConnexion() - created_etudids = [] - - try: # --- begin DB transaction - for key in a_importer: - etud = etudsapo_ident[ - key - ] # on a ici toutes les infos renvoyées par le portail - - # Traduit les infos portail en infos pour ScoDoc: - address = etud.get("address", "").strip() - if address[-2:] == "\\n": # certains champs se terminent par \n - address = address[:-2] - - args = { - "code_nip": etud["nip"], - "nom": etud["nom"].strip(), - "prenom": etud["prenom"].strip(), - # Les champs suivants sont facultatifs (pas toujours renvoyés par le portail) - "code_ine": etud.get("ine", "").strip(), - "civilite": gender2civilite(etud["gender"].strip()), - "etape": etud.get("etape", None), - "email": etud.get("mail", "").strip(), - "emailperso": etud.get("mailperso", "").strip(), - "date_naissance": etud.get("naissance", "").strip(), - "lieu_naissance": etud.get("ville_naissance", "").strip(), - "dept_naissance": etud.get("code_dep_naissance", "").strip(), - "domicile": address, - "codepostaldomicile": etud.get("postalcode", "").strip(), - "villedomicile": etud.get("city", "").strip(), - "paysdomicile": etud.get("country", "").strip(), - "telephone": etud.get("phone", "").strip(), - "typeadresse": "domicile", - "boursier": etud.get("bourse", None), - "description": "infos portail", - } - - # Identite - args["etudid"] = sco_etud.identite_create(cnx, args) - created_etudids.append(args["etudid"]) - # Admissions - do_import_etud_admission(cnx, args["etudid"], etud) - - # Adresse - sco_etud.adresse_create(cnx, args) - - # Inscription au semestre - sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( - sem["formsemestre_id"], - args["etudid"], - etat="I", - etape=args["etape"], - method="synchro_apogee", - ) - except: - cnx.rollback() - log("do_import_etuds_from_portal: aborting transaction !") - # Nota: db transaction is sometimes partly commited... - # here we try to remove all created students - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - for etudid in created_etudids: - log(f"do_import_etuds_from_portal: deleting etudid={etudid}") - cursor.execute( - "delete from notes_moduleimpl_inscription where etudid=%(etudid)s", - {"etudid": etudid}, - ) - cursor.execute( - "delete from notes_formsemestre_inscription where etudid=%(etudid)s", - {"etudid": etudid}, - ) - cursor.execute( - "delete from scolar_events where etudid=%(etudid)s", {"etudid": etudid} - ) - cursor.execute( - "delete from adresse where etudid=%(etudid)s", {"etudid": etudid} - ) - cursor.execute( - "delete from admissions where etudid=%(etudid)s", {"etudid": etudid} - ) - cursor.execute( - "delete from group_membership where etudid=%(etudid)s", - {"etudid": etudid}, - ) - cursor.execute( - "delete from identite where id=%(etudid)s", {"etudid": etudid} - ) - cnx.commit() - log("do_import_etuds_from_portal: re-raising exception") - # > import: modif identite, adresses, inscriptions - sco_cache.invalidate_formsemestre() - raise - - ScolarNews.add( - typ=ScolarNews.NEWS_INSCR, - text=f"Import Apogée de {len(created_etudids)} étudiants en ", - obj=sem["formsemestre_id"], - max_frequency=10 * 60, # 10' - ) - - -def do_import_etud_admission( - cnx, etudid, etud, import_naissance=False, import_identite=False -): - """Importe les donnees admission pour cet etud. - etud est un dictionnaire traduit du XML portail - """ - annee_courante = time.localtime()[0] - serie_bac, spe_bac = get_bac(etud) - # Les champs n'ont pas les mêmes noms dans Apogee et dans ScoDoc: - args = { - "etudid": etudid, - "annee": get_opt_str(etud, "inscription") or annee_courante, - "bac": serie_bac, - "specialite": spe_bac, - "annee_bac": get_opt_str(etud, "anneebac"), - "codelycee": get_opt_str(etud, "lycee"), - "nomlycee": get_opt_str(etud, "nom_lycee"), - "villelycee": get_opt_str(etud, "ville_lycee"), - "codepostallycee": get_opt_str(etud, "codepostal_lycee"), - "boursier": get_opt_str(etud, "bourse"), - } - # log("do_import_etud_admission: etud=%s" % pprint.pformat(etud)) - adm_list = sco_etud.admission_list(cnx, args={"etudid": etudid}) - if not adm_list: - sco_etud.admission_create(cnx, args) # -> adm_id - else: - # existing data: merge - adm_info = adm_list[0] - if get_opt_str(etud, "inscription"): - adm_info["annee"] = args["annee"] - keys = list(args.keys()) - for k in keys: - if not args[k]: - del args[k] - adm_info.update(args) - sco_etud.admission_edit(cnx, adm_info) - # Traite cas particulier de la date de naissance pour anciens - # etudiants IUTV - if import_naissance and "naissance" in etud: - date_naissance = etud["naissance"].strip() - if date_naissance: - sco_etud.identite_edit_nocheck( - cnx, {"etudid": etudid, "date_naissance": date_naissance} - ) - # Reimport des identités - if import_identite: - args = {"etudid": etudid} - # Les champs n'ont pas les mêmes noms dans Apogee et dans ScoDoc: - fields_apo_sco = [ - ("naissance", "date_naissance"), - ("ville_naissance", "lieu_naissance"), - ("code_dep_naissance", "dept_naissance"), - ("nom", "nom"), - ("prenom", "prenom"), - ("ine", "code_ine"), - ("bourse", "boursier"), - ] - for apo_field, sco_field in fields_apo_sco: - x = etud.get(apo_field, "").strip() - if x: - args[sco_field] = x - # Champs spécifiques: - civilite = gender2civilite(etud["gender"].strip()) - if civilite: - args["civilite"] = civilite - - sco_etud.identite_edit_nocheck(cnx, args) - - -def get_bac(etud): - bac = get_opt_str(etud, "bac") - if not bac: - return None, None - serie_bac = bac.split("-")[0] - if len(serie_bac) < 8: - spe_bac = bac[len(serie_bac) + 1 :] - else: - serie_bac = bac - spe_bac = None - return serie_bac, spe_bac - - -def update_etape_formsemestre_inscription(ins, etud): - """Met à jour l'étape de l'inscription. - - Args: - ins (dict): formsemestre_inscription - etud (dict): etudiant portail Apo - """ - if etud["etape"] != ins["etape"]: - ins["etape"] = etud["etape"] - sco_formsemestre_inscriptions.do_formsemestre_inscription_edit(args=ins) - - -def formsemestre_import_etud_admission( - formsemestre_id, import_identite=True, import_email=False -): - """Tente d'importer les données admission depuis le portail - pour tous les étudiants du semestre. - Si import_identite==True, recopie l'identité (nom/prenom/sexe/date_naissance) - de chaque étudiant depuis le portail. - N'affecte pas les etudiants inconnus sur le portail. - """ - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - {"formsemestre_id": formsemestre_id} - ) - log( - "formsemestre_import_etud_admission: %s (%d etuds)" - % (formsemestre_id, len(ins)) - ) - no_nip = [] # liste d'etudids sans code NIP - unknowns = [] # etudiants avec NIP mais inconnus du portail - changed_mails = [] # modification d'adresse mails - cnx = ndb.GetDBConnexion() - - # Essaie de recuperer les etudiants des étapes, car - # la requete get_inscrits_etape est en général beaucoup plus - # rapide que les requetes individuelles get_etud_apogee - anneeapogee = str( - scu.annee_scolaire_debut(sem["annee_debut"], sem["mois_debut_ord"]) - ) - apo_etuds = {} # nip : etud apo - for etape in sem["etapes"]: - etudsapo = sco_portal_apogee.get_inscrits_etape(etape, anneeapogee=anneeapogee) - apo_etuds.update({e["nip"]: e for e in etudsapo}) - - for i in ins: - etudid = i["etudid"] - info = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - code_nip = info["code_nip"] - if not code_nip: - no_nip.append(etudid) - else: - etud = apo_etuds.get(code_nip) - if not etud: - # pas vu dans les etudiants de l'étape, tente en individuel - etud = sco_portal_apogee.get_etud_apogee(code_nip) - if etud: - update_etape_formsemestre_inscription(i, etud) - do_import_etud_admission( - cnx, - etudid, - etud, - import_naissance=True, - import_identite=import_identite, - ) - apo_emailperso = etud.get("mailperso", "") - if info["emailperso"] and not apo_emailperso: - apo_emailperso = info["emailperso"] - if import_email: - if not "mail" in etud: - raise ScoValueError( - "la réponse portail n'a pas le champs requis 'mail'" - ) - if ( - info["email"] != etud["mail"] - or info["emailperso"] != apo_emailperso - ): - sco_etud.adresse_edit( - cnx, - args={ - "etudid": etudid, - "adresse_id": info["adresse_id"], - "email": etud["mail"], - "emailperso": apo_emailperso, - }, - ) - # notifie seulement les changements d'adresse mail institutionnelle - if info["email"] != etud["mail"]: - changed_mails.append((info, etud["mail"])) - else: - unknowns.append(code_nip) - sco_cache.invalidate_formsemestre(formsemestre_id=sem["formsemestre_id"]) - return no_nip, unknowns, changed_mails +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Synchronisation des listes d'étudiants avec liste portail (Apogée) +""" + +import time +from operator import itemgetter + +from flask import g, url_for +from flask_login import current_user + +from app import log +from app.models import ScolarNews + +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app.scodoc import html_sco_header +from app.scodoc import sco_cache +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_groups +from app.scodoc import sco_inscr_passage +from app.scodoc import sco_portal_apogee +from app.scodoc import sco_etud +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc.sco_permissions import Permission + +# Clés utilisées pour la synchro +EKEY_APO = "nip" +EKEY_SCO = "code_nip" +EKEY_NAME = "code NIP" + +# view: +def formsemestre_synchro_etuds( + formsemestre_id, + etuds=[], # liste des codes NIP des etudiants a inscrire (ou deja inscrits) + inscrits_without_key=[], # codes etudid des etudiants sans code NIP a laisser inscrits + anneeapogee=None, + submitted=False, + dialog_confirmed=False, + export_cat_xls=None, + read_only=False, # Affiche sans permettre modifications +): + """Synchronise les étudiants de ce semestre avec ceux d'Apogée. + On a plusieurs cas de figure: L'étudiant peut être + 1- présent dans Apogée et inscrit dans le semestre ScoDoc (etuds_ok) + 2- dans Apogée, dans ScoDoc, mais pas inscrit dans le semestre (etuds_noninscrits) + 3- dans Apogée et pas dans ScoDoc (a_importer) + 4- inscrit dans le semestre ScoDoc, mais pas trouvé dans Apogée (sur la base du code NIP) + + Que faire ? + Cas 1: rien à faire + Cas 2: inscrire dans le semestre + Cas 3: importer l'étudiant (le créer) + puis l'inscrire à ce semestre. + Cas 4: lister les etudiants absents d'Apogée (indiquer leur code NIP...) + + - présenter les différents cas + - l'utilisateur valide (cocher les étudiants à importer/inscrire) + - go + + etuds: apres sélection par l'utilisateur, la liste des étudiants selectionnés + que l'on va importer/inscrire + """ + log(f"formsemestre_synchro_etuds: formsemestre_id={formsemestre_id}") + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + sem["etape_apo_str"] = sco_formsemestre.formsemestre_etape_apo_str(sem) + # Write access ? + if not current_user.has_permission(Permission.ScoEtudInscrit): + read_only = True + if read_only: + submitted = False + dialog_confirmed = False + # -- check lock + if not sem["etat"]: + raise ScoValueError("opération impossible: semestre verrouille") + if not sem["etapes"]: + raise ScoValueError( + """opération impossible: ce semestre n'a pas de code étape + (voir "Modifier ce semestre") + """ + % sem + ) + header = html_sco_header.sco_header(page_title="Synchronisation étudiants") + footer = html_sco_header.sco_footer() + base_url = url_for( + "notes.formsemestre_synchro_etuds", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + anneeapogee=anneeapogee or None, # si None, le param n'est pas dans l'URL + ) + + if anneeapogee is None: # année d'inscription par défaut + anneeapogee = scu.annee_scolaire_debut( + sem["annee_debut"], sem["mois_debut_ord"] + ) + anneeapogee = str(anneeapogee) + + if isinstance(etuds, str): + etuds = etuds.split(",") # vient du form de confirmation + elif isinstance(etuds, int): + etuds = [etuds] + if isinstance(inscrits_without_key, int): + inscrits_without_key = [inscrits_without_key] + elif isinstance(inscrits_without_key, str): + inscrits_without_key = inscrits_without_key.split(",") + elif not isinstance(inscrits_without_key, list): + raise ValueError("invalid type for inscrits_without_key") + inscrits_without_key = [int(x) for x in inscrits_without_key if x] + ( + etuds_by_cat, + a_importer, + a_inscrire, + inscrits_set, + inscrits_without_key_all, + etudsapo_ident, + ) = list_synch(sem, anneeapogee=anneeapogee) + if export_cat_xls: + filename = export_cat_xls + xls = build_page( + sem, + etuds_by_cat, + anneeapogee, + export_cat_xls=export_cat_xls, + base_url=base_url, + read_only=read_only, + ) + return scu.send_file( + xls, + mime=scu.XLS_MIMETYPE, + filename=filename, + suffix=scu.XLSX_SUFFIX, + ) + + H = [header] + if not submitted: + H += build_page( + sem, + etuds_by_cat, + anneeapogee, + base_url=base_url, + read_only=read_only, + ) + else: + etuds_set = set(etuds) + a_importer = a_importer.intersection(etuds_set) + a_desinscrire = inscrits_set - etuds_set + log("inscrits_without_key_all=%s" % set(inscrits_without_key_all)) + log("inscrits_without_key=%s" % inscrits_without_key) + a_desinscrire_without_key = set(inscrits_without_key_all) - set( + inscrits_without_key + ) + log("a_desinscrire_without_key=%s" % a_desinscrire_without_key) + inscrits_ailleurs = set(sco_inscr_passage.list_inscrits_date(sem)) + a_inscrire = a_inscrire.intersection(etuds_set) + + if not dialog_confirmed: + # Confirmation + if a_importer: + H.append("

          Étudiants à importer et inscrire :

            ") + for key in a_importer: + nom = f"""{etudsapo_ident[key]['nom']} {etudsapo_ident[key].get("prenom", "")}""" + H.append(f"
          1. {nom}
          2. ") + H.append("
          ") + + if a_inscrire: + H.append("

          Étudiants à inscrire :

            ") + for key in a_inscrire: + nom = f"""{etudsapo_ident[key]['nom']} {etudsapo_ident[key].get("prenom", "")}""" + H.append(f"
          1. {nom}
          2. ") + H.append("
          ") + + a_inscrire_en_double = inscrits_ailleurs.intersection(a_inscrire) + if a_inscrire_en_double: + H.append("

          dont étudiants déjà inscrits:

            ") + for key in a_inscrire_en_double: + nom = f"""{etudsapo_ident[key]['nom']} {etudsapo_ident[key].get("prenom", "")}""" + H.append(f'
          1. {nom}
          2. ') + H.append("
          ") + + if a_desinscrire: + H.append("

          Étudiants à désinscrire :

            ") + for key in a_desinscrire: + etud = sco_etud.get_etud_info(filled=True, code_nip=key)[0] + H.append('
          1. %(nomprenom)s
          2. ' % etud) + H.append("
          ") + if a_desinscrire_without_key: + H.append("

          Étudiants à désinscrire (sans code):

            ") + for etudid in a_desinscrire_without_key: + etud = inscrits_without_key_all[etudid] + sco_etud.format_etud_ident(etud) + H.append('
          1. %(nomprenom)s
          2. ' % etud) + H.append("
          ") + + todo = ( + a_importer or a_inscrire or a_desinscrire or a_desinscrire_without_key + ) + if not todo: + H.append("""

          Il n'y a rien à modifier !

          """) + H.append( + scu.confirm_dialog( + dest_url="formsemestre_synchro_etuds", + add_headers=False, + cancel_url="formsemestre_synchro_etuds?formsemestre_id=" + + str(formsemestre_id), + OK="Effectuer l'opération" if todo else "OK", + parameters={ + "formsemestre_id": formsemestre_id, + "etuds": ",".join(etuds), + "inscrits_without_key": ",".join( + [str(x) for x in inscrits_without_key] + ), + "submitted": 1, + "anneeapogee": anneeapogee, + }, + ) + ) + else: + # OK, do it + + # Conversions des listes de codes NIP en listes de codes etudid + def nip2etudid(code_nip): + etud = sco_etud.get_etud_info(code_nip=code_nip)[0] + return etud["etudid"] + + etudids_a_inscrire = [nip2etudid(x) for x in a_inscrire] + etudids_a_desinscrire = [nip2etudid(x) for x in a_desinscrire] + etudids_a_desinscrire += a_desinscrire_without_key + # + with sco_cache.DeferredSemCacheManager(): + do_import_etuds_from_portal(sem, a_importer, etudsapo_ident) + sco_inscr_passage.do_inscrit(sem, etudids_a_inscrire) + sco_inscr_passage.do_desinscrit(sem, etudids_a_desinscrire) + + H.append( + """

          Opération effectuée

          +
            +
          • Continuer la synchronisation
          • """ + % formsemestre_id + ) + # + partitions = sco_groups.get_partitions_list( + formsemestre_id, with_default=False + ) + if partitions: # il y a au moins une vraie partition + H.append( + f"""
          • Répartir les groupes de {partitions[0]["partition_name"]}
          • + """ + ) + + H.append(footer) + return "\n".join(H) + + +def build_page( + sem, + etuds_by_cat, + anneeapogee, + export_cat_xls=None, + base_url="", + read_only=False, +): + if export_cat_xls: + return sco_inscr_passage.etuds_select_boxes( + etuds_by_cat, export_cat_xls=export_cat_xls, base_url=base_url + ) + year = time.localtime()[0] + if anneeapogee and abs(year - int(anneeapogee)) < 50: + years = list( + range(min(year - 1, int(anneeapogee) - 1), max(year, int(anneeapogee)) + 1) + ) + else: + years = list(range(year - 1, year + 1)) + anneeapogee = "" + options = [] + for y in years: + if str(y) == anneeapogee: + sel = "selected" + else: + sel = "" + options.append('' % (str(y), sel, str(y))) + if anneeapogee: + sel = "" + else: + sel = "selected" + options.append('' % sel) + # sem['etape_apo_str'] = sem['etape_apo'] or '-' + + H = [ + """

            Synchronisation des étudiants du semestre avec Apogée

            """, + """

            Actuellement %d inscrits dans ce semestre.

            """ + % ( + len(etuds_by_cat["etuds_ok"]["etuds"]) + + len(etuds_by_cat["etuds_nonapogee"]["etuds"]) + + len(etuds_by_cat["inscrits_without_key"]["etuds"]) + ), + """

            Code étape Apogée: %(etape_apo_str)s

            +
            + """ + % sem, + """ + Année Apogée: + """, + "" + if read_only + else """ + + +  aide + """ + % sem, # " + sco_inscr_passage.etuds_select_boxes( + etuds_by_cat, + sel_inscrits=False, + show_empty_boxes=True, + base_url=base_url, + read_only=read_only, + ), + "" + if read_only + else """

            """, + formsemestre_synchro_etuds_help(sem), + """

            """, + ] + return H + + +def list_synch(sem, anneeapogee=None): + """""" + inscrits = sco_inscr_passage.list_inscrits(sem["formsemestre_id"], with_dems=True) + # Tous les ensembles d'etudiants sont ici des ensembles de codes NIP (voir EKEY_SCO) + # (sauf inscrits_without_key) + inscrits_set = set() + inscrits_without_key = {} # etudid : etud sans code NIP + for e in inscrits.values(): + if not e[EKEY_SCO]: + inscrits_without_key[e["etudid"]] = e + e["inscrit"] = True # checkbox state + else: + inscrits_set.add(e[EKEY_SCO]) + # allinscrits_set = set() # tous les inscrits scodoc avec code_nip, y compris les demissionnaires + # for e in inscrits.values(): + # if e[EKEY_SCO]: + # allinscrits_set.add(e[EKEY_SCO]) + + datefinalisationinscription_by_NIP = {} # nip : datefinalisationinscription_str + + etapes = sem["etapes"] + etudsapo_set = set() + etudsapo_ident = {} + for etape in etapes: + if etape: + etudsapo = sco_portal_apogee.get_inscrits_etape( + etape, anneeapogee=anneeapogee + ) + etudsapo_set = etudsapo_set.union(set([x[EKEY_APO] for x in etudsapo])) + for e in etudsapo: + if e[EKEY_APO] not in etudsapo_ident: + etudsapo_ident[e[EKEY_APO]] = e + datefinalisationinscription_by_NIP[e[EKEY_APO]] = e[ + "datefinalisationinscription" + ] + + # categories: + etuds_ok = etudsapo_set.intersection(inscrits_set) + etuds_aposco, a_importer, key2etudid = list_all(etudsapo_set) + etuds_noninscrits = etuds_aposco - inscrits_set + etuds_nonapogee = inscrits_set - etudsapo_set + # Etudiants ayant payé (avec balise true) + # note: si le portail ne renseigne pas cette balise, suppose que paiement ok + etuds_payes = set( + [x[EKEY_APO] for x in etudsapo if x.get("paiementinscription", True)] + ) + # + cnx = ndb.GetDBConnexion() + # Tri listes + def set_to_sorted_list(etudset, etud_apo=False, is_inscrit=False): + def key2etud(key, etud_apo=False): + if not etud_apo: + etudid = key2etudid[key] + etuds = sco_etud.identite_list(cnx, {"etudid": etudid}) + if not etuds: # ? cela ne devrait pas arriver XXX + log(f"XXX key2etud etudid={{etudid}}, type {{type(etudid)}}") + etud = etuds[0] + etud["inscrit"] = is_inscrit # checkbox state + etud[ + "datefinalisationinscription" + ] = datefinalisationinscription_by_NIP.get(key, None) + if key in etudsapo_ident: + etud["etape"] = etudsapo_ident[key].get("etape", "") + else: + # etudiant Apogee + etud = etudsapo_ident[key] + + etud["etudid"] = "" + etud["civilite"] = etud.get( + "sexe", etud.get("gender", "") + ) # la cle 'sexe' est prioritaire sur 'gender' + etud["inscrit"] = is_inscrit # checkbox state + if key in etuds_payes: + etud["paiementinscription"] = True + else: + etud["paiementinscription"] = False + return etud + + etuds = [key2etud(x, etud_apo) for x in etudset] + etuds.sort(key=itemgetter("nom")) + return etuds + + # + boites = { + "etuds_a_importer": { + "etuds": set_to_sorted_list(a_importer, is_inscrit=True, etud_apo=True), + "infos": { + "id": "etuds_a_importer", + "title": "Étudiants dans Apogée à importer", + "help": """Ces étudiants sont inscrits dans cette étape Apogée mais ne sont pas connus par ScoDoc: + cocher les noms à importer et inscrire puis appuyer sur le bouton "Appliquer".""", + "title_target": "", + "with_checkbox": True, + "etud_key": EKEY_APO, # clé à stocker dans le formulaire html + "filename": "etuds_a_importer", + }, + "nomprenoms": etudsapo_ident, + }, + "etuds_noninscrits": { + "etuds": set_to_sorted_list(etuds_noninscrits, is_inscrit=True), + "infos": { + "id": "etuds_noninscrits", + "title": "Étudiants non inscrits dans ce semestre", + "help": """Ces étudiants sont déjà connus par ScoDoc, sont inscrits dans cette étape Apogée mais ne sont pas inscrits à ce semestre ScoDoc. Cochez les étudiants à inscrire.""", + "comment": """ dans ScoDoc et Apogée,
            mais pas inscrits + dans ce semestre""", + "title_target": "", + "with_checkbox": True, + "etud_key": EKEY_SCO, + "filename": "etuds_non_inscrits", + }, + }, + "etuds_nonapogee": { + "etuds": set_to_sorted_list(etuds_nonapogee, is_inscrit=True), + "infos": { + "id": "etuds_nonapogee", + "title": "Étudiants ScoDoc inconnus dans cette étape Apogée", + "help": """Ces étudiants sont inscrits dans ce semestre ScoDoc, ont un code NIP, mais ne sont pas inscrits dans cette étape Apogée. Soit ils sont en retard pour leur inscription, soit il s'agit d'une erreur: vérifiez avec le service Scolarité de votre établissement. Autre possibilité: votre code étape semestre (%s) est incorrect ou vous n'avez pas choisi la bonne année d'inscription.""" + % sem["etape_apo_str"], + "comment": " à vérifier avec la Scolarité", + "title_target": "", + "with_checkbox": True, + "etud_key": EKEY_SCO, + "filename": "etuds_non_apogee", + }, + }, + "inscrits_without_key": { + "etuds": list(inscrits_without_key.values()), + "infos": { + "id": "inscrits_without_key", + "title": "Étudiants ScoDoc sans clé Apogée (NIP)", + "help": """Ces étudiants sont inscrits dans ce semestre ScoDoc, mais n'ont pas de code NIP: on ne peut pas les mettre en correspondance avec Apogée. Utiliser le lien 'Changer les données identité' dans le menu 'Etudiant' sur leur fiche pour ajouter cette information.""", + "title_target": "", + "with_checkbox": True, + "checkbox_name": "inscrits_without_key", + "filename": "inscrits_without_key", + }, + }, + "etuds_ok": { + "etuds": set_to_sorted_list(etuds_ok, is_inscrit=True), + "infos": { + "id": "etuds_ok", + "title": "Étudiants dans Apogée et déjà inscrits", + "help": """Ces etudiants sont inscrits dans le semestre ScoDoc et sont présents dans Apogée: + tout est donc correct. Décocher les étudiants que vous souhaitez désinscrire.""", + "title_target": "", + "with_checkbox": True, + "etud_key": EKEY_SCO, + "filename": "etuds_inscrits_ok_apo", + }, + }, + } + return ( + boites, + a_importer, + etuds_noninscrits, + inscrits_set, + inscrits_without_key, + etudsapo_ident, + ) + + +def list_all(etudsapo_set): + """Cherche le sous-ensemble des etudiants Apogee de ce semestre + qui existent dans ScoDoc. + """ + # on charge TOUS les etudiants (au pire qq 100000 ?) + # si tres grosse base, il serait mieux de faire une requete + # d'interrogation par etudiant. + cnx = ndb.GetDBConnexion() + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + cursor.execute( + "SELECT " + + EKEY_SCO + + """, id AS etudid + FROM identite WHERE dept_id=%(dept_id)s + """, + {"dept_id": g.scodoc_dept_id}, + ) + key2etudid = dict([(x[0], x[1]) for x in cursor.fetchall()]) + all_set = set(key2etudid.keys()) + + # ne retient que ceux dans Apo + etuds_aposco = etudsapo_set.intersection( + all_set + ) # a la fois dans Apogee et dans ScoDoc + a_importer = etudsapo_set - all_set # dans Apogee, mais inconnus dans ScoDoc + return etuds_aposco, a_importer, key2etudid + + +def formsemestre_synchro_etuds_help(sem): + sem["default_group_id"] = sco_groups.get_default_group(sem["formsemestre_id"]) + return ( + """

            Explications

            +

            Cette page permet d'importer dans le semestre destination + %(titreannee)s + les étudiants inscrits dans l'étape Apogée correspondante (%(etape_apo_str)s) +

            +

            Au départ, tous les étudiants d'Apogée sont sélectionnés; vous pouvez + en déselectionner certains. Tous les étudiants cochés seront inscrits au semestre ScoDoc, + les autres seront si besoin désinscrits. Aucune modification n'est effectuée avant + d'appuyer sur le bouton "Appliquer les modifications".

            + +

            Autres fonctions utiles

            + +
            """ + % sem + ) + + +def gender2civilite(gender): + """Le portail code en 'M', 'F', et ScoDoc en 'M', 'F', 'X'""" + if gender == "M" or gender == "F" or gender == "X": + return gender + elif not gender: + return "X" + log('gender2civilite: invalid value "%s", defaulting to "X"' % gender) + return "X" # "X" en général n'est pas affiché, donc bon choix si invalide + + +def get_opt_str(etud, k): + v = etud.get(k, None) + if not v: + return v + return v.strip() + + +def get_annee_naissance(ddmmyyyyy: str) -> int: + """Extrait l'année de la date stockée en dd/mm/yyyy dans le XML portail""" + if not ddmmyyyyy: + return None + try: + return int(ddmmyyyyy.split("/")[2]) + except (ValueError, IndexError): + return None + + +def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident): + """Inscrit les etudiants Apogee dans ce semestre.""" + log("do_import_etuds_from_portal: a_importer=%s" % a_importer) + if not a_importer: + return + cnx = ndb.GetDBConnexion() + created_etudids = [] + + try: # --- begin DB transaction + for key in a_importer: + etud = etudsapo_ident[ + key + ] # on a ici toutes les infos renvoyées par le portail + + # Traduit les infos portail en infos pour ScoDoc: + address = etud.get("address", "").strip() + if address[-2:] == "\\n": # certains champs se terminent par \n + address = address[:-2] + + args = { + "code_nip": etud["nip"], + "nom": etud["nom"].strip(), + "prenom": etud["prenom"].strip(), + # Les champs suivants sont facultatifs (pas toujours renvoyés par le portail) + "code_ine": etud.get("ine", "").strip(), + "civilite": gender2civilite(etud["gender"].strip()), + "etape": etud.get("etape", None), + "email": etud.get("mail", "").strip(), + "emailperso": etud.get("mailperso", "").strip(), + "date_naissance": etud.get("naissance", "").strip(), + "lieu_naissance": etud.get("ville_naissance", "").strip(), + "dept_naissance": etud.get("code_dep_naissance", "").strip(), + "domicile": address, + "codepostaldomicile": etud.get("postalcode", "").strip(), + "villedomicile": etud.get("city", "").strip(), + "paysdomicile": etud.get("country", "").strip(), + "telephone": etud.get("phone", "").strip(), + "typeadresse": "domicile", + "boursier": etud.get("bourse", None), + "description": "infos portail", + } + + # Identite + args["etudid"] = sco_etud.identite_create(cnx, args) + created_etudids.append(args["etudid"]) + # Admissions + do_import_etud_admission(cnx, args["etudid"], etud) + + # Adresse + sco_etud.adresse_create(cnx, args) + + # Inscription au semestre + sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules( + sem["formsemestre_id"], + args["etudid"], + etat=scu.INSCRIT, + etape=args["etape"], + method="synchro_apogee", + ) + except: + cnx.rollback() + log("do_import_etuds_from_portal: aborting transaction !") + # Nota: db transaction is sometimes partly commited... + # here we try to remove all created students + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + for etudid in created_etudids: + log(f"do_import_etuds_from_portal: deleting etudid={etudid}") + cursor.execute( + "delete from notes_moduleimpl_inscription where etudid=%(etudid)s", + {"etudid": etudid}, + ) + cursor.execute( + "delete from notes_formsemestre_inscription where etudid=%(etudid)s", + {"etudid": etudid}, + ) + cursor.execute( + "delete from scolar_events where etudid=%(etudid)s", {"etudid": etudid} + ) + cursor.execute( + "delete from adresse where etudid=%(etudid)s", {"etudid": etudid} + ) + cursor.execute( + "delete from admissions where etudid=%(etudid)s", {"etudid": etudid} + ) + cursor.execute( + "delete from group_membership where etudid=%(etudid)s", + {"etudid": etudid}, + ) + cursor.execute( + "delete from identite where id=%(etudid)s", {"etudid": etudid} + ) + cnx.commit() + log("do_import_etuds_from_portal: re-raising exception") + # > import: modif identite, adresses, inscriptions + sco_cache.invalidate_formsemestre() + raise + + ScolarNews.add( + typ=ScolarNews.NEWS_INSCR, + text=f"Import Apogée de {len(created_etudids)} étudiants en ", + obj=sem["formsemestre_id"], + max_frequency=10 * 60, # 10' + ) + + +def do_import_etud_admission( + cnx, etudid, etud, import_naissance=False, import_identite=False +): + """Importe les donnees admission pour cet etud. + etud est un dictionnaire traduit du XML portail + """ + annee_courante = time.localtime()[0] + serie_bac, spe_bac = get_bac(etud) + # Les champs n'ont pas les mêmes noms dans Apogee et dans ScoDoc: + args = { + "etudid": etudid, + "annee": get_opt_str(etud, "inscription") or annee_courante, + "bac": serie_bac, + "specialite": spe_bac, + "annee_bac": get_opt_str(etud, "anneebac"), + "codelycee": get_opt_str(etud, "lycee"), + "nomlycee": get_opt_str(etud, "nom_lycee"), + "villelycee": get_opt_str(etud, "ville_lycee"), + "codepostallycee": get_opt_str(etud, "codepostal_lycee"), + "boursier": get_opt_str(etud, "bourse"), + } + # log("do_import_etud_admission: etud=%s" % pprint.pformat(etud)) + adm_list = sco_etud.admission_list(cnx, args={"etudid": etudid}) + if not adm_list: + sco_etud.admission_create(cnx, args) # -> adm_id + else: + # existing data: merge + adm_info = adm_list[0] + if get_opt_str(etud, "inscription"): + adm_info["annee"] = args["annee"] + keys = list(args.keys()) + for k in keys: + if not args[k]: + del args[k] + adm_info.update(args) + sco_etud.admission_edit(cnx, adm_info) + # Traite cas particulier de la date de naissance pour anciens + # etudiants IUTV + if import_naissance and "naissance" in etud: + date_naissance = etud["naissance"].strip() + if date_naissance: + sco_etud.identite_edit_nocheck( + cnx, {"etudid": etudid, "date_naissance": date_naissance} + ) + # Reimport des identités + if import_identite: + args = {"etudid": etudid} + # Les champs n'ont pas les mêmes noms dans Apogee et dans ScoDoc: + fields_apo_sco = [ + ("naissance", "date_naissance"), + ("ville_naissance", "lieu_naissance"), + ("code_dep_naissance", "dept_naissance"), + ("nom", "nom"), + ("prenom", "prenom"), + ("ine", "code_ine"), + ("bourse", "boursier"), + ] + for apo_field, sco_field in fields_apo_sco: + x = etud.get(apo_field, "").strip() + if x: + args[sco_field] = x + # Champs spécifiques: + civilite = gender2civilite(etud["gender"].strip()) + if civilite: + args["civilite"] = civilite + + sco_etud.identite_edit_nocheck(cnx, args) + + +def get_bac(etud): + bac = get_opt_str(etud, "bac") + if not bac: + return None, None + serie_bac = bac.split("-")[0] + if len(serie_bac) < 8: + spe_bac = bac[len(serie_bac) + 1 :] + else: + serie_bac = bac + spe_bac = None + return serie_bac, spe_bac + + +def update_etape_formsemestre_inscription(ins, etud): + """Met à jour l'étape de l'inscription. + + Args: + ins (dict): formsemestre_inscription + etud (dict): etudiant portail Apo + """ + if etud["etape"] != ins["etape"]: + ins["etape"] = etud["etape"] + sco_formsemestre_inscriptions.do_formsemestre_inscription_edit(args=ins) + + +def formsemestre_import_etud_admission( + formsemestre_id, import_identite=True, import_email=False +): + """Tente d'importer les données admission depuis le portail + pour tous les étudiants du semestre. + Si import_identite==True, recopie l'identité (nom/prenom/sexe/date_naissance) + de chaque étudiant depuis le portail. + N'affecte pas les etudiants inconnus sur le portail. + """ + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + {"formsemestre_id": formsemestre_id} + ) + log( + "formsemestre_import_etud_admission: %s (%d etuds)" + % (formsemestre_id, len(ins)) + ) + no_nip = [] # liste d'etudids sans code NIP + unknowns = [] # etudiants avec NIP mais inconnus du portail + changed_mails = [] # modification d'adresse mails + cnx = ndb.GetDBConnexion() + + # Essaie de recuperer les etudiants des étapes, car + # la requete get_inscrits_etape est en général beaucoup plus + # rapide que les requetes individuelles get_etud_apogee + anneeapogee = str( + scu.annee_scolaire_debut(sem["annee_debut"], sem["mois_debut_ord"]) + ) + apo_etuds = {} # nip : etud apo + for etape in sem["etapes"]: + etudsapo = sco_portal_apogee.get_inscrits_etape(etape, anneeapogee=anneeapogee) + apo_etuds.update({e["nip"]: e for e in etudsapo}) + + for i in ins: + etudid = i["etudid"] + info = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] + code_nip = info["code_nip"] + if not code_nip: + no_nip.append(etudid) + else: + etud = apo_etuds.get(code_nip) + if not etud: + # pas vu dans les etudiants de l'étape, tente en individuel + etud = sco_portal_apogee.get_etud_apogee(code_nip) + if etud: + update_etape_formsemestre_inscription(i, etud) + do_import_etud_admission( + cnx, + etudid, + etud, + import_naissance=True, + import_identite=import_identite, + ) + apo_emailperso = etud.get("mailperso", "") + if info["emailperso"] and not apo_emailperso: + apo_emailperso = info["emailperso"] + if import_email: + if not "mail" in etud: + raise ScoValueError( + "la réponse portail n'a pas le champs requis 'mail'" + ) + if ( + info["email"] != etud["mail"] + or info["emailperso"] != apo_emailperso + ): + sco_etud.adresse_edit( + cnx, + args={ + "etudid": etudid, + "adresse_id": info["adresse_id"], + "email": etud["mail"], + "emailperso": apo_emailperso, + }, + ) + # notifie seulement les changements d'adresse mail institutionnelle + if info["email"] != etud["mail"]: + changed_mails.append((info, etud["mail"])) + else: + unknowns.append(code_nip) + sco_cache.invalidate_formsemestre(formsemestre_id=sem["formsemestre_id"]) + return no_nip, unknowns, changed_mails diff --git a/app/scodoc/sco_trombino_tours.py b/app/scodoc/sco_trombino_tours.py index 67c083f8..85867b1e 100644 --- a/app/scodoc/sco_trombino_tours.py +++ b/app/scodoc/sco_trombino_tours.py @@ -1,478 +1,480 @@ -# -*- mode: python -*- -# -*- coding: utf-8 -*- - -############################################################################## -# -# Gestion scolarite IUT -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -"""Photos: trombinoscopes - Version IUT Tours - Code contribué par Jérôme Billoue, IUT de Tours, 2014 - Modification Jérome Billoue,Vincent Grimaud, IUT de Tours, 2017 -""" - -import io - -from reportlab.lib import colors -from reportlab.lib.colors import black -from reportlab.lib.pagesizes import A4, A3 -from reportlab.lib import styles -from reportlab.lib.pagesizes import landscape -from reportlab.lib.units import cm -from reportlab.platypus import KeepInFrame, Paragraph, Table, TableStyle -from reportlab.platypus.doctemplate import BaseDocTemplate - -from app.scodoc import sco_abs -from app.scodoc import sco_etud -from app.scodoc.sco_exceptions import ScoPDFFormatError -from app.scodoc import sco_groups -from app.scodoc import sco_groups_view -from app.scodoc import sco_preferences -from app.scodoc import sco_trombino -import app.scodoc.sco_utils as scu -from app.scodoc.sco_pdf import SU, ScoDocPageTemplate - -# Paramétrage de l'aspect graphique: -PHOTOWIDTH = 2.8 * cm -COLWIDTH = 3.4 * cm -N_PER_ROW = 5 - - -def pdf_trombino_tours( - group_ids=(), # liste des groupes à afficher - formsemestre_id=None, # utilisé si pas de groupes selectionné -): - """Generation du trombinoscope en fichier PDF""" - # Informations sur les groupes à afficher: - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids, formsemestre_id=formsemestre_id - ) - - DeptName = sco_preferences.get_preference("DeptName") - DeptFullName = sco_preferences.get_preference("DeptFullName") - InstituteName = sco_preferences.get_preference("InstituteName") - # Generate PDF page - StyleSheet = styles.getSampleStyleSheet() - objects = [] - T = Table( - [ - [Paragraph(SU(InstituteName), StyleSheet["Heading3"])], - [ - Paragraph( - SU("Département " + DeptFullName or "(?)"), StyleSheet["Heading3"] - ) - ], - [ - Paragraph( - SU("Date ............ / ............ / ......................"), - StyleSheet["Normal"], - ), - Paragraph( - SU( - "Module ......................................................." - ), - StyleSheet["Normal"], - ), - ], - [ - Paragraph( - SU("de ............h............ à ............h............ "), - StyleSheet["Normal"], - ), - Paragraph( - SU("Enseignant ................................................."), - StyleSheet["Normal"], - ), - ], - [ - Table( - [ - [ - "Séance notée :", - " ", - "DS ", - " ", - "TP Contrôle ", - " ", - "Autre cas (TD ou TP noté, QCM, etc...)", - ] - ], - style=TableStyle( - [ - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("BOX", (1, 0), (1, 0), 0.75, black), - ("BOX", (3, 0), (3, 0), 0.75, black), - ("BOX", (5, 0), (5, 0), 0.75, black), - ] - ), - ) - ], - ], - colWidths=(COLWIDTH * N_PER_ROW) / 2, - style=TableStyle( - [ - ("ALIGN", (0, 0), (-1, -1), "LEFT"), - ("SPAN", (0, 1), (1, 1)), - ("SPAN", (0, 4), (1, 4)), - ("BOTTOMPADDING", (0, -1), (-1, -1), 10), - ("BOX", (0, 0), (-1, -1), 0.75, black), - ] - ), - ) - - objects.append(T) - - groups = "" - - for group_id in groups_infos.group_ids: - if group_id != "None": - members, _, group_tit, sem, _ = sco_groups.get_group_infos(group_id, "I") - groups += " %s" % group_tit - L = [] - currow = [] - - if sem["semestre_id"] != -1: - currow = [ - Paragraph( - SU( - "Semestre %s" % sem["semestre_id"] - ), - StyleSheet["Normal"], - ) - ] - currow += [" "] * (N_PER_ROW - len(currow) - 1) - currow += [ - Paragraph( - SU("%s" % sem["anneescolaire"]), - StyleSheet["Normal"], - ) - ] - L.append(currow) - currow = [" "] * N_PER_ROW - L.append(currow) - - currow = [] - currow.append( - Paragraph( - SU("" + group_tit + ""), - StyleSheet["Heading3"], - ) - ) - n = 1 - for m in members: - img = sco_trombino._get_etud_platypus_image(m, image_width=PHOTOWIDTH) - etud_main_group = sco_groups.get_etud_main_group( - m["etudid"], sem["formsemestre_id"] - ) - if group_id != etud_main_group["group_id"]: - text_group = " (" + etud_main_group["group_name"] + ")" - else: - text_group = "" - elem = Table( - [ - [img], - [ - Paragraph( - SU( - "" - + sco_etud.format_prenom(m["prenom"]) - + " " - + sco_etud.format_nom(m["nom"]) - + text_group - + "" - ), - StyleSheet["Normal"], - ) - ], - ], - colWidths=[COLWIDTH], - style=TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")]), - ) - currow.append(elem) - if n == (N_PER_ROW - 1): - L.append(currow) - currow = [] - n = (n + 1) % N_PER_ROW - if currow: - currow += [" "] * (N_PER_ROW - len(currow)) - L.append(currow) - if not L: - T = Paragraph(SU("Aucune photo à exporter !"), StyleSheet["Normal"]) - else: - T = Table( - L, - colWidths=[COLWIDTH] * N_PER_ROW, - style=TableStyle( - [ - ("LEFTPADDING", (0, 0), (-1, -1), 0), - ("RIGHTPADDING", (0, 0), (-1, -1), 0), - ("BOTTOMPADDING", (0, 0), (-1, -1), 0), - ("TOPPADDING", (0, 1), (-1, -1), 0), - ("TOPPADDING", (0, 0), (-1, 0), 10), - ("LINEBELOW", (1, 0), (-2, 0), 0.75, black), - ("VALIGN", (0, 0), (-1, 1), "MIDDLE"), - ("VALIGN", (0, 2), (-1, -1), "TOP"), - ("VALIGN", (0, 2), (0, 2), "MIDDLE"), - ("SPAN", (0, 0), (0, 1)), - ("SPAN", (-1, 0), (-1, 1)), - ] - ), - ) - - objects.append(T) - - T = Table( - [ - [ - Paragraph( - SU( - "Nombre d'absents : ................. (Merci d'entourer les absents SVP)" - ), - StyleSheet["Normal"], - ) - ] - ], - colWidths=(COLWIDTH * N_PER_ROW), - style=TableStyle( - [ - ("ALIGN", (0, 0), (-1, -1), "CENTER"), - ("BOTTOMPADDING", (0, -1), (-1, -1), 10), - ("BOX", (0, 0), (-1, -1), 0.75, black), - ] - ), - ) - - objects.append(T) - - # Réduit sur une page - objects = [KeepInFrame(0, 0, objects, mode="shrink")] - # Build document - report = io.BytesIO() # in-memory document, no disk file - filename = "trombino-%s-%s.pdf" % (DeptName, groups_infos.groups_filename) - document = BaseDocTemplate(report) - document.addPageTemplates( - ScoDocPageTemplate( - document, - preferences=sco_preferences.SemPreferences(), - ) - ) - try: - document.build(objects) - except (ValueError, KeyError) as exc: - raise ScoPDFFormatError(str(exc)) from exc - data = report.getvalue() - - return scu.sendPDFFile(data, filename) - - -# Feuille d'absences en pdf avec photos: - - -def pdf_feuille_releve_absences( - group_ids=(), # liste des groupes à afficher - formsemestre_id=None, # utilisé si pas de groupes selectionné -): - """Generation de la feuille d'absence en fichier PDF, avec photos""" - - NB_CELL_AM = sco_preferences.get_preference("feuille_releve_abs_AM") - NB_CELL_PM = sco_preferences.get_preference("feuille_releve_abs_PM") - col_width = 0.85 * cm - if sco_preferences.get_preference("feuille_releve_abs_samedi"): - days = sco_abs.DAYNAMES[:6] # Lundi, ..., Samedi - else: - days = sco_abs.DAYNAMES[:5] # Lundi, ..., Vendredi - nb_days = len(days) - - # Informations sur les groupes à afficher: - groups_infos = sco_groups_view.DisplayedGroupsInfos( - group_ids, formsemestre_id=formsemestre_id - ) - - DeptName = sco_preferences.get_preference("DeptName") - DeptFullName = sco_preferences.get_preference("DeptFullName") - InstituteName = sco_preferences.get_preference("InstituteName") - # Generate PDF page - StyleSheet = styles.getSampleStyleSheet() - objects = [ - Table( - [ - [ - Paragraph(SU(InstituteName), StyleSheet["Heading3"]), - Paragraph( - SU( - "Semaine .................................................................." - ), - StyleSheet["Normal"], - ), - ], - [ - Paragraph( - SU("Département " + (DeptFullName or "(?)")), - StyleSheet["Heading3"], - ), - "", - ], - ], - style=TableStyle( - [("SPAN", (0, 1), (1, 1)), ("BOTTOMPADDING", (0, -1), (-1, -1), 10)] - ), - ) - ] - - currow = [""] * (NB_CELL_AM + 1 + NB_CELL_PM + 1) - elem_day = Table( - [currow], - colWidths=([col_width] * (NB_CELL_AM + 1 + NB_CELL_PM + 1)), - style=TableStyle( - [ - ("GRID", (0, 0), (NB_CELL_AM - 1, 0), 0.25, black), - ( - "GRID", - (NB_CELL_AM + 1, 0), - (NB_CELL_AM + NB_CELL_PM, 0), - 0.25, - black, - ), - ] - ), - ) - W = [] - currow = [] - for n in range(nb_days): - currow.append(elem_day) - W.append(currow) - - elem_week = Table( - W, - colWidths=([col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), - style=TableStyle( - [ - ("LEFTPADDING", (0, 0), (-1, -1), 0), - ("RIGHTPADDING", (0, 0), (-1, -1), 0), - ("BOTTOMPADDING", (0, 0), (-1, -1), 0), - ("TOPPADDING", (0, 0), (-1, -1), 0), - ] - ), - ) - currow = [] - for n in range(nb_days): - currow += [Paragraph(SU("" + days[n] + ""), StyleSheet["Normal"])] - - elem_day_name = Table( - [currow], - colWidths=([col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), - style=TableStyle( - [ - ("LEFTPADDING", (0, 0), (-1, -1), 0), - ("RIGHTPADDING", (0, 0), (-1, -1), 0), - ("BOTTOMPADDING", (0, 0), (-1, -1), 0), - ("TOPPADDING", (0, 0), (-1, -1), 0), - ] - ), - ) - - for group_id in groups_infos.group_ids: - members, _, group_tit, _, _ = sco_groups.get_group_infos(group_id, "I") - L = [] - - currow = [ - Paragraph(SU("Groupe " + group_tit + ""), StyleSheet["Normal"]) - ] - currow.append(elem_day_name) - L.append(currow) - - currow = [Paragraph(SU("Initiales enseignant :"), StyleSheet["Normal"])] - currow.append(elem_week) - L.append(currow) - - currow = [Paragraph(SU("Initiales module :"), StyleSheet["Normal"])] - currow.append(elem_week) - L.append(currow) - - for m in members: - currow = [ - Paragraph( - SU( - sco_etud.format_nom(m["nom"]) - + " " - + sco_etud.format_prenom(m["prenom"]) - ), - StyleSheet["Normal"], - ) - ] - currow.append(elem_week) - L.append(currow) - - if not L: - T = Paragraph(SU("Aucun étudiant !"), StyleSheet["Normal"]) - else: - T = Table( - L, - colWidths=( - [ - 5.0 * cm, - (col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1) * nb_days), - ] - ), - style=TableStyle( - [ - ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), - ("LEFTPADDING", (0, 0), (-1, -1), 0), - ("RIGHTPADDING", (0, 0), (-1, -1), 0), - ("BOTTOMPADDING", (0, 0), (-1, -1), 3), - ("TOPPADDING", (0, 0), (-1, -1), 3), - ("BOTTOMPADDING", (0, -1), (-1, -1), 10), - ( - "ROWBACKGROUNDS", - (0, 2), - (-1, -1), - (colors.white, colors.lightgrey), - ), - ] - ), - ) - - objects.append(T) - - # Réduit sur une page - objects = [KeepInFrame(0, 0, objects, mode="shrink")] - # Build document - report = io.BytesIO() # in-memory document, no disk file - filename = "absences-%s-%s.pdf" % (DeptName, groups_infos.groups_filename) - if sco_preferences.get_preference("feuille_releve_abs_taille") == "A3": - taille = A3 - elif sco_preferences.get_preference("feuille_releve_abs_taille") == "A4": - taille = A4 - if sco_preferences.get_preference("feuille_releve_abs_format") == "Paysage": - document = BaseDocTemplate(report, pagesize=landscape(taille)) - else: - document = BaseDocTemplate(report, pagesize=taille) - document.addPageTemplates( - ScoDocPageTemplate( - document, - preferences=sco_preferences.SemPreferences(), - ) - ) - document.build(objects) - data = report.getvalue() - - return scu.sendPDFFile(data, filename) +# -*- mode: python -*- +# -*- coding: utf-8 -*- + +############################################################################## +# +# Gestion scolarite IUT +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +"""Photos: trombinoscopes - Version IUT Tours + Code contribué par Jérôme Billoue, IUT de Tours, 2014 + Modification Jérome Billoue,Vincent Grimaud, IUT de Tours, 2017 +""" + +import io + +from reportlab.lib import colors +from reportlab.lib.colors import black +from reportlab.lib.pagesizes import A4, A3 +from reportlab.lib import styles +from reportlab.lib.pagesizes import landscape +from reportlab.lib.units import cm +from reportlab.platypus import KeepInFrame, Paragraph, Table, TableStyle +from reportlab.platypus.doctemplate import BaseDocTemplate + +from app.scodoc import sco_abs +from app.scodoc import sco_etud +from app.scodoc.sco_exceptions import ScoPDFFormatError +from app.scodoc import sco_groups +from app.scodoc import sco_groups_view +from app.scodoc import sco_preferences +from app.scodoc import sco_trombino +import app.scodoc.sco_utils as scu +from app.scodoc.sco_pdf import SU, ScoDocPageTemplate + +# Paramétrage de l'aspect graphique: +PHOTOWIDTH = 2.8 * cm +COLWIDTH = 3.4 * cm +N_PER_ROW = 5 + + +def pdf_trombino_tours( + group_ids=(), # liste des groupes à afficher + formsemestre_id=None, # utilisé si pas de groupes selectionné +): + """Generation du trombinoscope en fichier PDF""" + # Informations sur les groupes à afficher: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, formsemestre_id=formsemestre_id + ) + + DeptName = sco_preferences.get_preference("DeptName") + DeptFullName = sco_preferences.get_preference("DeptFullName") + InstituteName = sco_preferences.get_preference("InstituteName") + # Generate PDF page + StyleSheet = styles.getSampleStyleSheet() + objects = [] + T = Table( + [ + [Paragraph(SU(InstituteName), StyleSheet["Heading3"])], + [ + Paragraph( + SU("Département " + DeptFullName or "(?)"), StyleSheet["Heading3"] + ) + ], + [ + Paragraph( + SU("Date ............ / ............ / ......................"), + StyleSheet["Normal"], + ), + Paragraph( + SU( + "Module ......................................................." + ), + StyleSheet["Normal"], + ), + ], + [ + Paragraph( + SU("de ............h............ à ............h............ "), + StyleSheet["Normal"], + ), + Paragraph( + SU("Enseignant ................................................."), + StyleSheet["Normal"], + ), + ], + [ + Table( + [ + [ + "Séance notée :", + " ", + "DS ", + " ", + "TP Contrôle ", + " ", + "Autre cas (TD ou TP noté, QCM, etc...)", + ] + ], + style=TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("BOX", (1, 0), (1, 0), 0.75, black), + ("BOX", (3, 0), (3, 0), 0.75, black), + ("BOX", (5, 0), (5, 0), 0.75, black), + ] + ), + ) + ], + ], + colWidths=(COLWIDTH * N_PER_ROW) / 2, + style=TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("SPAN", (0, 1), (1, 1)), + ("SPAN", (0, 4), (1, 4)), + ("BOTTOMPADDING", (0, -1), (-1, -1), 10), + ("BOX", (0, 0), (-1, -1), 0.75, black), + ] + ), + ) + + objects.append(T) + + groups = "" + + for group_id in groups_infos.group_ids: + if group_id != "None": + members, _, group_tit, sem, _ = sco_groups.get_group_infos( + group_id, scu.INSCRIT + ) + groups += " %s" % group_tit + L = [] + currow = [] + + if sem["semestre_id"] != -1: + currow = [ + Paragraph( + SU( + "Semestre %s" % sem["semestre_id"] + ), + StyleSheet["Normal"], + ) + ] + currow += [" "] * (N_PER_ROW - len(currow) - 1) + currow += [ + Paragraph( + SU("%s" % sem["anneescolaire"]), + StyleSheet["Normal"], + ) + ] + L.append(currow) + currow = [" "] * N_PER_ROW + L.append(currow) + + currow = [] + currow.append( + Paragraph( + SU("" + group_tit + ""), + StyleSheet["Heading3"], + ) + ) + n = 1 + for m in members: + img = sco_trombino._get_etud_platypus_image(m, image_width=PHOTOWIDTH) + etud_main_group = sco_groups.get_etud_main_group( + m["etudid"], sem["formsemestre_id"] + ) + if group_id != etud_main_group["group_id"]: + text_group = " (" + etud_main_group["group_name"] + ")" + else: + text_group = "" + elem = Table( + [ + [img], + [ + Paragraph( + SU( + "" + + sco_etud.format_prenom(m["prenom"]) + + " " + + sco_etud.format_nom(m["nom"]) + + text_group + + "" + ), + StyleSheet["Normal"], + ) + ], + ], + colWidths=[COLWIDTH], + style=TableStyle([("ALIGN", (0, 0), (-1, -1), "CENTER")]), + ) + currow.append(elem) + if n == (N_PER_ROW - 1): + L.append(currow) + currow = [] + n = (n + 1) % N_PER_ROW + if currow: + currow += [" "] * (N_PER_ROW - len(currow)) + L.append(currow) + if not L: + T = Paragraph(SU("Aucune photo à exporter !"), StyleSheet["Normal"]) + else: + T = Table( + L, + colWidths=[COLWIDTH] * N_PER_ROW, + style=TableStyle( + [ + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 1), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, 0), 10), + ("LINEBELOW", (1, 0), (-2, 0), 0.75, black), + ("VALIGN", (0, 0), (-1, 1), "MIDDLE"), + ("VALIGN", (0, 2), (-1, -1), "TOP"), + ("VALIGN", (0, 2), (0, 2), "MIDDLE"), + ("SPAN", (0, 0), (0, 1)), + ("SPAN", (-1, 0), (-1, 1)), + ] + ), + ) + + objects.append(T) + + T = Table( + [ + [ + Paragraph( + SU( + "Nombre d'absents : ................. (Merci d'entourer les absents SVP)" + ), + StyleSheet["Normal"], + ) + ] + ], + colWidths=(COLWIDTH * N_PER_ROW), + style=TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("BOTTOMPADDING", (0, -1), (-1, -1), 10), + ("BOX", (0, 0), (-1, -1), 0.75, black), + ] + ), + ) + + objects.append(T) + + # Réduit sur une page + objects = [KeepInFrame(0, 0, objects, mode="shrink")] + # Build document + report = io.BytesIO() # in-memory document, no disk file + filename = "trombino-%s-%s.pdf" % (DeptName, groups_infos.groups_filename) + document = BaseDocTemplate(report) + document.addPageTemplates( + ScoDocPageTemplate( + document, + preferences=sco_preferences.SemPreferences(), + ) + ) + try: + document.build(objects) + except (ValueError, KeyError) as exc: + raise ScoPDFFormatError(str(exc)) from exc + data = report.getvalue() + + return scu.sendPDFFile(data, filename) + + +# Feuille d'absences en pdf avec photos: + + +def pdf_feuille_releve_absences( + group_ids=(), # liste des groupes à afficher + formsemestre_id=None, # utilisé si pas de groupes selectionné +): + """Generation de la feuille d'absence en fichier PDF, avec photos""" + + NB_CELL_AM = sco_preferences.get_preference("feuille_releve_abs_AM") + NB_CELL_PM = sco_preferences.get_preference("feuille_releve_abs_PM") + col_width = 0.85 * cm + if sco_preferences.get_preference("feuille_releve_abs_samedi"): + days = sco_abs.DAYNAMES[:6] # Lundi, ..., Samedi + else: + days = sco_abs.DAYNAMES[:5] # Lundi, ..., Vendredi + nb_days = len(days) + + # Informations sur les groupes à afficher: + groups_infos = sco_groups_view.DisplayedGroupsInfos( + group_ids, formsemestre_id=formsemestre_id + ) + + DeptName = sco_preferences.get_preference("DeptName") + DeptFullName = sco_preferences.get_preference("DeptFullName") + InstituteName = sco_preferences.get_preference("InstituteName") + # Generate PDF page + StyleSheet = styles.getSampleStyleSheet() + objects = [ + Table( + [ + [ + Paragraph(SU(InstituteName), StyleSheet["Heading3"]), + Paragraph( + SU( + "Semaine .................................................................." + ), + StyleSheet["Normal"], + ), + ], + [ + Paragraph( + SU("Département " + (DeptFullName or "(?)")), + StyleSheet["Heading3"], + ), + "", + ], + ], + style=TableStyle( + [("SPAN", (0, 1), (1, 1)), ("BOTTOMPADDING", (0, -1), (-1, -1), 10)] + ), + ) + ] + + currow = [""] * (NB_CELL_AM + 1 + NB_CELL_PM + 1) + elem_day = Table( + [currow], + colWidths=([col_width] * (NB_CELL_AM + 1 + NB_CELL_PM + 1)), + style=TableStyle( + [ + ("GRID", (0, 0), (NB_CELL_AM - 1, 0), 0.25, black), + ( + "GRID", + (NB_CELL_AM + 1, 0), + (NB_CELL_AM + NB_CELL_PM, 0), + 0.25, + black, + ), + ] + ), + ) + W = [] + currow = [] + for n in range(nb_days): + currow.append(elem_day) + W.append(currow) + + elem_week = Table( + W, + colWidths=([col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), + style=TableStyle( + [ + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ] + ), + ) + currow = [] + for n in range(nb_days): + currow += [Paragraph(SU("" + days[n] + ""), StyleSheet["Normal"])] + + elem_day_name = Table( + [currow], + colWidths=([col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1)] * nb_days), + style=TableStyle( + [ + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 0), + ("TOPPADDING", (0, 0), (-1, -1), 0), + ] + ), + ) + + for group_id in groups_infos.group_ids: + members, _, group_tit, _, _ = sco_groups.get_group_infos(group_id, scu.INSCRIT) + L = [] + + currow = [ + Paragraph(SU("Groupe " + group_tit + ""), StyleSheet["Normal"]) + ] + currow.append(elem_day_name) + L.append(currow) + + currow = [Paragraph(SU("Initiales enseignant :"), StyleSheet["Normal"])] + currow.append(elem_week) + L.append(currow) + + currow = [Paragraph(SU("Initiales module :"), StyleSheet["Normal"])] + currow.append(elem_week) + L.append(currow) + + for m in members: + currow = [ + Paragraph( + SU( + sco_etud.format_nom(m["nom"]) + + " " + + sco_etud.format_prenom(m["prenom"]) + ), + StyleSheet["Normal"], + ) + ] + currow.append(elem_week) + L.append(currow) + + if not L: + T = Paragraph(SU("Aucun étudiant !"), StyleSheet["Normal"]) + else: + T = Table( + L, + colWidths=( + [ + 5.0 * cm, + (col_width * (NB_CELL_AM + 1 + NB_CELL_PM + 1) * nb_days), + ] + ), + style=TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ("BOTTOMPADDING", (0, 0), (-1, -1), 3), + ("TOPPADDING", (0, 0), (-1, -1), 3), + ("BOTTOMPADDING", (0, -1), (-1, -1), 10), + ( + "ROWBACKGROUNDS", + (0, 2), + (-1, -1), + (colors.white, colors.lightgrey), + ), + ] + ), + ) + + objects.append(T) + + # Réduit sur une page + objects = [KeepInFrame(0, 0, objects, mode="shrink")] + # Build document + report = io.BytesIO() # in-memory document, no disk file + filename = "absences-%s-%s.pdf" % (DeptName, groups_infos.groups_filename) + if sco_preferences.get_preference("feuille_releve_abs_taille") == "A3": + taille = A3 + elif sco_preferences.get_preference("feuille_releve_abs_taille") == "A4": + taille = A4 + if sco_preferences.get_preference("feuille_releve_abs_format") == "Paysage": + document = BaseDocTemplate(report, pagesize=landscape(taille)) + else: + document = BaseDocTemplate(report, pagesize=taille) + document.addPageTemplates( + ScoDocPageTemplate( + document, + preferences=sco_preferences.SemPreferences(), + ) + ) + document.build(objects) + data = report.getvalue() + + return scu.sendPDFFile(data, filename) diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index 8a216093..ce001b54 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -1,4092 +1,4096 @@ -/* # -*- mode: css -*- - ScoDoc, (c) Emmanuel Viennet 1998 - 2021 - */ - -html, -body { - margin: 0; - padding: 0; - width: 100%; - background-color: rgb(242, 242, 238); - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 12pt; -} - - -@media print { - .noprint { - display: none; - } -} - -h1, -h2, -h3 { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; -} - -h3 { - font-size: 14pt; - font-weight: bold; -} - -div#gtrcontent { - margin-bottom: 4ex; -} - -.gtrcontent { - margin-left: 140px; - height: 100%; - margin-bottom: 10px; -} - -.gtrcontent a, -.gtrcontent a:visited { - color: rgb(4, 16, 159); - text-decoration: none; -} - -.gtrcontent a:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - -.scotext { - font-family: TimesNewRoman, "Times New Roman", Times, Baskerville, Georgia, serif; -} - -.sco-hidden { - display: None; -} - -div.tab-content { - margin-top: 10px; - margin-left: 15px; -} - -div.tab-content ul { - padding-bottom: 3px; -} - -div.tab-content h3 { - font-size: 1.17em; -} - -form#group_selector { - display: inline; -} - -#group_selector button { - padding-top: 3px; - padding-bottom: 3px; - margin-bottom: 3px; -} - -/* ----- bandeau haut ------ */ -span.bandeaugtr { - width: 100%; - margin: 0; - border-width: 0; - padding-left: 160px; - /* background-color: rgb(17,51,85); */ -} - -@media print { - span.bandeaugtr { - display: none; - } -} - -tr.bandeaugtr { - /* background-color: rgb(17,51,85); */ - color: rgb(255, 215, 0); - /* font-style: italic; */ - font-weight: bold; - border-width: 0; - margin: 0; -} - -#authuser { - margin-top: 16px; -} - -#authuserlink { - color: rgb(255, 0, 0); - text-decoration: none; -} - -#authuserlink:hover { - text-decoration: underline; -} - -#deconnectlink { - font-size: 75%; - font-style: normal; - color: rgb(255, 0, 0); - text-decoration: underline; -} - -.navbar-default .navbar-nav>li.logout a { - color: rgb(255, 0, 0); -} - -/* ----- page content ------ */ - -div.about-logo { - text-align: center; - padding-top: 10px; -} - - -div.head_message { - margin-top: 2px; - margin-bottom: 8px; - padding: 5px; - margin-left: auto; - margin-right: auto; - background-color: rgba(255, 255, 115, 0.9); - -moz-border-radius: 8px; - -khtml-border-radius: 8px; - border-radius: 8px; - font-family: arial, verdana, sans-serif; - font-weight: bold; - width: 70%; - text-align: center; -} - -#sco_msg { - padding: 0px; - position: fixed; - top: 0px; - right: 0px; - color: green; -} - - -div.passwd_warn { - font-weight: bold; - font-size: 200%; - background-color: #feb199; - width: 80%; - height: 200px; - text-align: center; - padding: 20px; - margin-left: auto; - margin-right: auto; - margin-top: 10px; -} - -div.scovalueerror { - padding-left: 20px; - padding-bottom: 100px; -} - -p.footer { - font-size: 80%; - color: rgb(60, 60, 60); - margin-top: 15px; - border-top: 1px solid rgb(60, 60, 60); -} - -div.part2 { - margin-top: 3ex; -} - -/* ---- (left) SIDEBAR ----- */ - -div.sidebar { - position: absolute; - top: 5px; - left: 5px; - width: 130px; - border: black 1px; - /* debug background-color: rgb(245,245,245); */ - border-right: 1px solid rgb(210, 210, 210); -} - -@media print { - div.sidebar { - display: none; - } -} - -a.sidebar:link, -.sidebar a:link { - color: rgb(4, 16, 159); - text-decoration: none; -} - -a.sidebar:visited, -.sidebar a:visited { - color: rgb(4, 16, 159); - text-decoration: none; -} - -a.sidebar:hover, -.sidebar a:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - -a.scodoc_title { - color: rgb(102, 102, 102); - font-family: arial, verdana, sans-serif; - font-size: large; - font-weight: bold; - text-transform: uppercase; - text-decoration: none; -} - -h2.insidebar { - color: rgb(102, 102, 102); - font-weight: bold; - font-size: large; - margin-bottom: 0; -} - -h3.insidebar { - color: rgb(102, 102, 102); - font-weight: bold; - font-size: medium; - margin-bottom: 0; - margin-top: 0; -} - -ul.insidebar { - padding-left: 1em; - list-style: circle; -} - -div.box-chercheetud { - margin-top: 12px; -} - -/* Page accueil général */ -span.dept_full_name { - font-style: italic; -} - -span.dept_visible { - color: rgb(6, 158, 6); -} - -span.dept_cache { - color: rgb(194, 5, 5); -} - -div.table_etud_in_accessible_depts { - margin-left: 3em; - margin-bottom: 2em; -} - -div.table_etud_in_dept { - margin-bottom: 2em; -} - -div.table_etud_in_dept table.gt_table { - width: 600px; -} - -.etud-insidebar { - font-size: small; - background-color: rgb(220, 220, 220); - width: 100%; - -moz-border-radius: 6px; - -khtml-border-radius: 6px; - border-radius: 6px; -} - -.etud-insidebar h2 { - color: rgb(153, 51, 51); - font-size: medium; -} - - -.etud-insidebar ul { - padding-left: 1.5em; - margin-left: 0; -} - -div.logo-insidebar { - margin-left: 0px; - width: 75px; - /* la marge fait 130px */ -} - -div.logo-logo { - margin-left: -5px; - text-align: center; -} - -div.logo-logo img { - box-sizing: content-box; - margin-top: 10px; - /* -10px */ - width: 80px; - /* adapter suivant image */ - padding-right: 5px; -} - -div.sidebar-bottom { - margin-top: 10px; -} - -div.etud_info_div { - border: 2px solid gray; - height: 94px; - background-color: #f7f7ff; -} - -div.eid_left { - display: inline-block; - - padding: 2px; - border: 0px; - vertical-align: top; - margin-right: 100px; -} - -span.eid_right { - padding: 0px; - border: 0px; - position: absolute; - right: 2px; - top: 2px; -} - -div.eid_nom { - display: inline; - color: navy; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 120%; -} - -div.eid_nom div { - margin-top: 4px; -} - -div.eid_info { - margin-left: 2px; - margin-top: 3px; -} - -div.eid_bac { - margin-top: 5px; -} - -div.eid_bac span.eid_bac { - font-weight: bold; -} - -div.eid_parcours { - margin-top: 3px; -} - -.qtip-etud { - border-width: 0px; - margin: 0px; - padding: 0px; -} - -.qtip-etud .qtip-content { - padding: 0px 0px; -} - -table.listesems th { - text-align: left; - padding-top: 0.5em; - padding-left: 0.5em; -} - -table.listesems td { - vertical-align: center; -} - -table.listesems td.semicon { - padding-left: 1.5em; -} - -table.listesems tr.firstsem td { - padding-top: 0.8em; -} - -td.datesem { - font-size: 80%; - white-space: nowrap; -} - -h2.listesems { - padding-top: 10px; - padding-bottom: 0px; - margin-bottom: 0px; -} - -/* table.semlist tr.gt_firstrow th {} */ - -table.semlist tr td { - border: none; -} - -table.semlist tbody tr a.stdlink, -table.semlist tbody tr a.stdlink:visited { - color: navy; - text-decoration: none; -} - -table.semlist tr a.stdlink:hover { - color: red; - text-decoration: underline; -} - -table.semlist tr td.semestre_id { - text-align: right; -} - -table.semlist tbody tr td.modalite { - text-align: left; - padding-right: 1em; -} - -/***************************/ -/* Statut des cellules */ -/***************************/ -.sco_selected { - outline: 1px solid #c09; -} - -.sco_modifying { - outline: 2px dashed #c09; - background-color: white !important; -} - -.sco_wait { - outline: 2px solid #c90; -} - -.sco_good { - outline: 2px solid #9c0; -} - -.sco_modified { - font-weight: bold; - color: indigo -} - -/***************************/ -/* Message */ -/***************************/ -.message { - position: fixed; - bottom: 100%; - left: 50%; - z-index: 10; - padding: 20px; - border-radius: 0 0 10px 10px; - background: #ec7068; - background: #90c; - color: #FFF; - font-size: 24px; - animation: message 3s; - transform: translate(-50%, 0); -} - -@keyframes message { - 20% { - transform: translate(-50%, 100%) - } - - 80% { - transform: translate(-50%, 100%) - } -} - - -div#gtrcontent table.semlist tbody tr.css_S-1 td { - background-color: rgb(251, 250, 216); -} - -div#gtrcontent table.semlist tbody tr.css_S1 td { - background-color: rgb(92%, 95%, 94%); -} - -div#gtrcontent table.semlist tbody tr.css_S2 td { - background-color: rgb(214, 223, 236); -} - -div#gtrcontent table.semlist tbody tr.css_S3 td { - background-color: rgb(167, 216, 201); -} - -div#gtrcontent table.semlist tbody tr.css_S4 td { - background-color: rgb(131, 225, 140); -} - -div#gtrcontent table.semlist tbody tr.css_MEXT td { - color: #0b6e08; -} - -/* ----- Liste des news ----- */ - -div.news { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 10pt; - margin-top: 1em; - margin-bottom: 0px; - margin-right: 16px; - margin-left: 16px; - padding: 0.5em; - background-color: rgb(255, 235, 170); - -moz-border-radius: 8px; - -khtml-border-radius: 8px; - border-radius: 8px; -} - -div.news a { - color: black; - text-decoration: none; -} - -div.news a:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - -span.newstitle { - font-weight: bold; -} - -ul.newslist { - padding-left: 1em; - padding-bottom: 0em; - list-style: circle; -} - -span.newsdate { - padding-right: 2em; - font-family: monospace; -} - -span.newstext { - font-style: normal; -} - - -span.gt_export_icons { - margin-left: 1.5em; -} - -/* --- infos sur premiere page Sco --- */ -div.scoinfos { - margin-top: 0.5em; - margin-bottom: 0px; - padding: 2px; - padding-bottom: 0px; - background-color: #F4F4B2; -} - -/* ----- fiches etudiants ------ */ - -div.ficheEtud { - background-color: #f5edc8; - /* rgb(255,240,128); */ - border: 1px solid gray; - width: 910px; - padding: 10px; - margin-top: 10px; -} - -div.menus_etud { - position: absolute; - margin-left: 1px; - margin-top: 1px; -} - -div.ficheEtud h2 { - padding-top: 10px; -} - -div.code_nip { - padding-top: 10px; - font-family: "Andale Mono", "Courier"; -} - -div.fichesituation { - background-color: rgb(231, 234, 218); - /* E7EADA */ - margin: 0.5em 0 0.5em 0; - padding: 0.5em; - -moz-border-radius: 8px; - -khtml-border-radius: 8px; - border-radius: 8px; -} - -div.ficheadmission { - background-color: rgb(231, 234, 218); - /* E7EADA */ - - margin: 0.5em 0 0.5em 0; - padding: 0.5em; - -moz-border-radius: 8px; - -khtml-border-radius: 8px; - border-radius: 8px; -} - -div#adm_table_description_format table.gt_table td { - font-size: 80%; -} - -div.ficheadmission div.note_rapporteur { - font-size: 80%; - font-style: italic; -} - -div.etudarchive ul { - padding: 0; - margin: 0; - margin-left: 1em; - list-style-type: none; -} - -div.etudarchive ul li { - background-image: url(/ScoDoc/static/icons/bullet_arrow.png); - background-repeat: no-repeat; - background-position: 0 .4em; - padding-left: .6em; -} - -div.etudarchive ul li.addetudarchive { - background-image: url(/ScoDoc/static/icons/bullet_plus.png); - padding-left: 1.2em -} - -span.etudarchive_descr { - margin-right: .4em; -} - -span.deletudarchive { - margin-left: 0.5em; -} - -div#fichedebouche { - background-color: rgb(183, 227, 254); - /* bleu clair */ - color: navy; - width: 910px; - margin: 0.5em 0 0.5em 0; - padding: 0.5em; - -moz-border-radius: 8px; - -khtml-border-radius: 8px; - border-radius: 8px; -} - -div#fichedebouche .ui-accordion-content { - background-color: rgb(183, 227, 254); - /* bleu clair */ - padding: 0px 10px 0px 0px; -} - -span.debouche_tit { - font-weight: bold; - padding-right: 1em; -} - -/* li.itemsuivi {} */ - -span.itemsuivi_tag_edit { - border: 2px; -} - -.listdebouches .itemsuivi_tag_edit .tag-editor { - background-color: rgb(183, 227, 254); - border: 0px; -} - -.itemsuivi_tag_edit ul.tag-editor { - display: inline-block; - width: 100%; -} - -/* .itemsuivi_tag_edit ul.tag-editor li {} */ - -.itemsuivi_tag_edit .tag-editor-delete { - height: 20px; -} - -.itemsuivi_suppress { - float: right; - padding-top: 9px; - padding-right: 5px; -} - -div.itemsituation { - background-color: rgb(224, 234, 241); - /* height: 2em;*/ - border: 1px solid rgb(204, 204, 204); - -moz-border-radius: 4px; - -khtml-border-radius: 4px; - border-radius: 4px; - padding-top: 1px; - padding-bottom: 1px; - padding-left: 10px; - padding-right: 10px; -} - -div.itemsituation em { - color: #bbb; -} - -/* tags readonly */ -span.ro_tag { - display: inline-block; - background-color: rgb(224, 234, 241); - color: #46799b; - margin-top: 3px; - margin-left: 5px; - margin-right: 3px; - padding-left: 3px; - padding-right: 3px; - border: 1px solid rgb(204, 204, 204); -} - -div.ficheinscriptions { - background-color: #eae3e2; - /* was EADDDA */ - margin: 0.5em 0 0.5em 0; - padding: 0.5em; - -moz-border-radius: 8px; - -khtml-border-radius: 8px; - border-radius: 8px; - overflow-x: scroll; -} - -.ficheinscriptions a.sem { - text-decoration: none; - font-weight: bold; - color: blue; -} - -.ficheinscriptions a.sem:hover { - color: red; -} - - -td.photocell { - padding-left: 32px; -} - -div.fichetitre { - font-weight: bold; -} - -span.etud_type_admission { - color: rgb(0, 0, 128); - font-style: normal; -} - -td.fichetitre2 { - font-weight: bold; - vertical-align: top; -} - -td.fichetitre2 .formula { - font-weight: normal; - color: rgb(0, 64, 0); - border: 1px solid red; - padding-left: 1em; - padding-right: 1em; - padding-top: 3px; - padding-bottom: 3px; - margin-right: 1em; -} - -span.formula { - font-size: 80%; - font-family: Courier, monospace; - font-weight: normal; -} - -td.fichetitre2 .fl { - font-weight: normal; -} - -.ficheannotations { - background-color: #f7d892; - width: 910px; - - margin: 0.5em 0 0.5em 0; - padding: 0.5em; - -moz-border-radius: 8px; - -khtml-border-radius: 8px; - border-radius: 8px; -} - -.ficheannotations table#etudannotations { - width: 100%; - border-collapse: collapse; -} - -.ficheannotations table#etudannotations tr:nth-child(odd) { - background: rgb(240, 240, 240); -} - -.ficheannotations table#etudannotations tr:nth-child(even) { - background: rgb(230, 230, 230); -} - -.ficheannotations span.annodate { - color: rgb(200, 50, 50); - font-size: 80%; -} - -.ficheannotations span.annoc { - color: navy; -} - -.ficheannotations td.annodel { - text-align: right; -} - -span.link_bul_pdf { - font-size: 80%; - padding-right: 2em; -} - -/* Page accueil Sco */ -span.infostitresem { - font-weight: normal; -} - -span.linktitresem { - font-weight: normal; -} - -span.linktitresem a:link { - color: red; -} - -span.linktitresem a:visited { - color: red; -} - -.listegroupelink a:link { - color: blue; -} - -.listegroupelink a:visited { - color: blue; -} - -.listegroupelink a:hover { - color: red; -} - -a.stdlink, -a.stdlink:visited { - color: blue; - text-decoration: underline; -} - -a.stdlink:hover { - color: red; - text-decoration: underline; -} - -/* a.link_accessible {} */ -a.link_unauthorized, -a.link_unauthorized:visited { - color: rgb(75, 75, 75); -} - -span.spanlink { - color: rgb(0, 0, 255); - text-decoration: underline; -} - -span.spanlink:hover { - color: red; -} - -/* Trombinoscope */ - -.trombi_legend { - font-size: 80%; - margin-bottom: 3px; - -ms-word-break: break-all; - word-break: break-all; - /* non std for webkit: */ - word-break: break-word; - -webkit-hyphens: auto; - -moz-hyphens: auto; - hyphens: auto; -} - -.trombi_box { - display: inline-block; - width: 110px; - vertical-align: top; - margin-left: 5px; - margin-top: 5px; -} - -span.trombi_legend { - display: inline-block; -} - -span.trombi-photo { - display: inline-block; -} - -span.trombi_box a { - display: inline-block; -} - -span.trombi_box a img { - display: inline-block; -} - -.trombi_nom { - display: block; - padding-top: 0px; - padding-bottom: 0px; - margin-top: -5px; - margin-bottom: 0px; -} - -.trombi_prenom { - display: inline-block; - padding-top: 0px; - padding-bottom: 0px; - margin-top: -2px; - margin-bottom: 0px; -} - - -/* markup non semantique pour les cas simples */ - -.fontred { - color: red; -} - -.fontorange { - color: rgb(215, 90, 0); -} - -.fontitalic { - font-style: italic; -} - -.redboldtext { - font-weight: bold; - color: red; -} - -.greenboldtext { - font-weight: bold; - color: green; -} - -a.redlink { - color: red; -} - -a.redlink:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - -a.discretelink { - color: black; - text-decoration: none; -} - -a.discretelink:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - -.rightcell { - text-align: right; -} - -.rightjust { - padding-left: 2em; -} - -.centercell { - text-align: center; -} - -.help { - font-style: italic; -} - -.help_important { - font-style: italic; - color: red; -} - -div.sco_help { - margin-top: 12px; - margin-bottom: 4px; - padding: 8px; - border-radius: 4px; - font-style: italic; - background-color: rgb(200, 200, 220); -} - -span.wtf-field ul.errors li { - color: red; -} - -#bonus_description { - color: rgb(6, 73, 6); - padding: 5px; - margin-top: 5px; - border: 2px solid blue; - border-radius: 5px; - background-color: cornsilk; -} - -#bonus_description div.bonus_description_head { - font-weight: bold; -} - -.configuration_logo summary { - display: list-item !important; -} - -.configuration_logo entete_dept { - display: inline-block; -} - -.configuration_logo .effectifs { - float: right; -} - -.configuration_logo h1 { - display: inline-block; -} - -.configuration_logo h2 { - display: inline-block; -} - -.configuration_logo h3 { - display: inline-block; -} - -.configuration_logo details>*:not(summary) { - margin-left: 32px; -} - -.configuration_logo .content { - display: grid; - grid-template-columns: auto auto 1fr; -} - -.configuration_logo .image_logo { - vertical-align: top; - grid-column: 1/2; - width: 256px; -} - -.configuration_logo div.img-container img { - max-width: 100%; -} - -.configuration_logo .infos_logo { - grid-column: 2/3; -} - -.configuration_logo .actions_logo { - grid-column: 3/5; - display: grid; - grid-template-columns: auto auto; - grid-column-gap: 10px; - align-self: start; - grid-row-gap: 10px; -} - -.configuration_logo .actions_logo .action_label { - grid-column: 1/2; - grid-template-columns: auto auto; -} - -.configuration_logo .actions_logo .action_button { - grid-column: 2/3; - align-self: start; -} - -.configuration_logo logo-edit titre { - background-color: lightblue; -} - -.configuration_logo logo-edit nom { - float: left; - vertical-align: baseline; -} - -.configuration_logo logo-edit description { - float: right; - vertical-align: baseline; -} - -p.indent { - padding-left: 2em; -} - -.blacktt { - font-family: Courier, monospace; - font-weight: normal; - color: black; -} - -p.msg { - color: red; - font-weight: bold; - border: 1px solid blue; - background-color: rgb(140, 230, 250); - padding: 10px; -} - -table.tablegrid { - border-color: black; - border-width: 0 0 1px 1px; - border-style: solid; - border-collapse: collapse; -} - -table.tablegrid td, -table.tablegrid th { - border-color: black; - border-width: 1px 1px 0 0; - border-style: solid; - margin: 0; - padding-left: 4px; - padding-right: 4px; -} - -/* ----- Notes ------ */ -a.smallbutton { - border-width: 0; - margin: 0; - margin-left: 2px; - text-decoration: none; -} - -span.evallink { - font-size: 80%; - font-weight: normal; -} - -.boldredmsg { - color: red; - font-weight: bold; -} - -tr.etuddem td { - color: rgb(100, 100, 100); - font-style: italic; -} - -td.etudabs, -td.etudabs a.discretelink, -tr.etudabs td.moyenne a.discretelink { - color: rgb(195, 0, 0); -} - -tr.moyenne td { - font-weight: bold; -} - -table.notes_evaluation th.eval_complete { - color: rgb(6, 90, 6); -} - -table.notes_evaluation th.eval_incomplete { - color: red; - width: 80px; - font-size: 80%; -} - -table.notes_evaluation td.eval_incomplete>a { - font-size: 80%; - color: rgb(166, 50, 159); -} - -table.notes_evaluation th.eval_attente { - color: rgb(215, 90, 0); - width: 80px; -} - -table.notes_evaluation td.att a { - color: rgb(255, 0, 217); - font-weight: bold; -} - -table.notes_evaluation td.exc a { - font-style: italic; - color: rgb(0, 131, 0); -} - - -table.notes_evaluation tr td a.discretelink:hover { - text-decoration: none; -} - -table.notes_evaluation tr td.tdlink a.discretelink:hover { - color: red; - text-decoration: underline; -} - -table.notes_evaluation tr td.tdlink a.discretelink, -table.notes_evaluation tr td.tdlink a.discretelink:visited { - color: blue; - text-decoration: underline; -} - -table.notes_evaluation tr td { - padding-left: 0.5em; - padding-right: 0.5em; -} - -div.notes_evaluation_stats { - margin-top: -15px; -} - -span.eval_title { - font-weight: bold; - font-size: 14pt; -} - -/* #saisie_notes span.eval_title { - border-bottom: 1px solid rgb(100,100,100); -} -*/ - -span.jurylink { - margin-left: 1.5em; -} - -span.jurylink a { - color: red; - text-decoration: underline; -} - -div.jury_footer { - display: flex; - justify-content: space-evenly; -} - -div.jury_footer>span { - border: 2px solid rgb(90, 90, 90); - border-radius: 4px; - padding: 4px; - background-color: rgb(230, 242, 230); -} - -.eval_description p { - margin-left: 15px; - margin-bottom: 2px; - margin-top: 0px; -} - -.eval_description span.resp { - font-weight: normal; -} - -.eval_description span.resp a { - font-weight: normal; -} - -.eval_description span.eval_malus { - font-weight: bold; - color: red; -} - - -span.eval_info { - font-style: italic; -} - -span.eval_complete { - color: green; -} - -span.eval_incomplete { - color: red; -} - -span.eval_attente { - color: rgb(215, 90, 0); -} - -table.tablenote { - border-collapse: collapse; - border: 2px solid blue; - /* width: 100%;*/ - margin-bottom: 20px; - margin-right: 20px; -} - -table.tablenote th { - padding-left: 1em; -} - -.tablenote a { - text-decoration: none; - color: black; -} - -.tablenote a:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - -table.tablenote_anonyme { - border-collapse: collapse; - border: 2px solid blue; -} - -tr.tablenote { - border: solid blue 1px; -} - -td.colnote { - text-align: right; - padding-right: 0.5em; - border: solid blue 1px; -} - -td.colnotemoy { - text-align: right; - padding-right: 0.5em; - font-weight: bold; -} - -td.colcomment, -span.colcomment { - text-align: left; - padding-left: 2em; - font-style: italic; - color: rgb(80, 100, 80); -} - -table.notes_evaluation table.eval_poids { - font-size: 50%; -} - -table.notes_evaluation td.moy_ue { - font-weight: bold; - color: rgb(1, 116, 96); -} - -td.coef_mod_ue { - font-style: normal; - font-weight: bold; - color: rgb(1, 116, 96); -} - -td.coef_mod_ue_non_conforme { - font-style: normal; - font-weight: bold; - color: red; - background-color: yellow; -} - -h2.formsemestre, -#gtrcontent h2 { - margin-top: 2px; - font-size: 130%; -} - -.formsemestre_page_title table.semtitle, -.formsemestre_page_title table.semtitle td { - padding: 0px; - margin-top: 0px; - margin-bottom: 0px; - border-width: 0; - border-collapse: collapse; -} - -.formsemestre_page_title { - width: 100%; - padding-top: 5px; - padding-bottom: 10px; -} - -.formsemestre_page_title table.semtitle td.infos table { - padding-top: 10px; -} - -.formsemestre_page_title a { - color: black; -} - -.formsemestre_page_title .eye, -formsemestre_page_title .eye img { - display: inline-block; - vertical-align: middle; - margin-bottom: 2px; -} - -.formsemestre_page_title .infos span.lock, -formsemestre_page_title .lock img { - display: inline-block; - vertical-align: middle; - margin-bottom: 5px; - padding-right: 5px; -} - -#formnotes .tf-explanation { - font-size: 80%; -} - -#formnotes .tf-explanation .sn_abs { - color: red; -} - -#formnotes .tf-ro-fieldlabel.formnote_bareme { - text-align: right; - font-weight: bold; -} - -#formnotes td.tf-ro-fieldlabel:after { - content: ''; -} - -#formnotes .tf-ro-field.formnote_bareme { - font-weight: bold; -} - -#formnotes td.tf-fieldlabel { - border-bottom: 1px dotted #fdcaca; -} - -.wtf-field li { - display: inline; -} - -.wtf-field ul { - padding-left: 0; -} - -.wtf-field .errors { - color: red; - font-weight: bold; -} - -/* -.formsemestre_menubar { - border-top: 3px solid #67A7E3; - background-color: #D6E9F8; - margin-top: 8px; -} - -.formsemestre_menubar .barrenav ul li a.menu { - font-size: 12px; -} -*/ -/* Barre menu semestre */ -#sco_menu { - overflow: hidden; - background: rgb(214, 233, 248); - border-top-color: rgb(103, 167, 227); - border-top-style: solid; - border-top-width: 3px; - margin-top: 5px; - margin-right: 3px; - margin-left: -1px; -} - -#sco_menu>li { - float: left; - width: auto; - /* 120px !important; */ - font-size: 12px; - font-family: Arial, Helvetica, sans-serif; - text-transform: uppercase; -} - -#sco_menu>li li { - text-transform: none; - font-size: 14px; - font-family: Arial, Helvetica, sans-serif; -} - -#sco_menu>li>a { - font-weight: bold !important; - padding-left: 15px; - padding-right: 15px; -} - -#sco_menu>li>a.ui-menu-item, -#sco_menu>li>a.ui-menu-item:visited { - text-decoration: none; -} - -#sco_menu ul .ui-menu { - width: 200px; -} - -.sco_dropdown_menu>li { - width: auto; - /* 120px !important; */ - font-size: 12px; - font-family: Arial, Helvetica, sans-serif; -} - -span.inscr_addremove_menu { - width: 150px; -} - -.formsemestre_page_title .infos span { - padding-right: 25px; -} - -.formsemestre_page_title span.semtitle { - font-size: 12pt; -} - -.formsemestre_page_title span.resp, -span.resp a { - color: red; - font-weight: bold; -} - -.formsemestre_page_title span.nbinscrits { - text-align: right; - font-weight: bold; - padding-right: 1px; -} - -div.formsemestre_status { - -moz-border-radius: 8px; - -khtml-border-radius: 8px; - border-radius: 8px; - padding: 2px 6px 2px 16px; - margin-right: 10px; -} - -table.formsemestre_status { - border-collapse: collapse; -} - -tr.formsemestre_status { - background-color: rgb(90%, 90%, 90%); -} - -tr.formsemestre_status_green { - background-color: #EFF7F2; -} - -tr.formsemestre_status_ue { - background-color: rgb(90%, 90%, 90%); -} - -tr.formsemestre_status_cat td { - padding-top: 2ex; -} - -table.formsemestre_status td { - border-top: 1px solid rgb(80%, 80%, 80%); - border-bottom: 1px solid rgb(80%, 80%, 80%); - border-left: 0px; -} - -table.formsemestre_status td.evals, -table.formsemestre_status th.evals, -table.formsemestre_status td.resp, -table.formsemestre_status th.resp, -table.formsemestre_status td.malus { - padding-left: 1em; -} - -table.formsemestre_status th { - font-weight: bold; - text-align: left; -} - -th.formsemestre_status_inscrits { - font-weight: bold; - text-align: center; -} - -td.formsemestre_status_code { - /* width: 2em; */ - padding-right: 1em; -} - -table.formsemestre_status td.malus a { - color: red; -} - -a.formsemestre_status_link { - text-decoration: none; - color: black; -} - -a.formsemestre_status_link:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - -td.formsemestre_status_inscrits { - text-align: center; -} - -td.formsemestre_status_cell { - white-space: nowrap; -} - -span.mod_coef_indicator, -span.ue_color_indicator { - display: inline-block; - box-sizing: border-box; - width: 10px; - height: 10px; -} - -span.mod_coef_indicator_zero { - display: inline-block; - box-sizing: border-box; - width: 10px; - height: 10px; - border: 1px solid rgb(156, 156, 156); -} - - -span.status_ue_acro { - font-weight: bold; -} - -span.status_ue_title { - font-style: italic; - padding-left: 1cm; -} - -span.status_module_cat { - font-weight: bold; -} - -table.formsemestre_inscr td { - padding-right: 1.25em; -} - -ul.ue_inscr_list li span.tit { - font-weight: bold; -} - -ul.ue_inscr_list li.tit { - padding-top: 1ex; -} - -ul.ue_inscr_list li.etud { - padding-top: 0.7ex; -} - -/* Liste des groupes sur tableau bord semestre */ -.formsemestre_status h3 { - border: 0px solid black; - margin-bottom: 5px; -} - -#grouplists h4 { - font-style: italic; - margin-bottom: 0px; - margin-top: 5px; -} - -#grouplists table { - /*border: 1px solid black;*/ - border-spacing: 1px; -} - -/* Tableau de bord module */ -div.moduleimpl_tableaubord { - padding: 7px; - border: 2px solid gray; -} - -div.moduleimpl_type_sae { - background-color: #cfeccf; -} - -div.moduleimpl_type_ressource { - background-color: #f5e9d2; -} - -div#modimpl_coefs { - position: absolute; - border: 1px solid; - padding-top: 3px; - padding-left: 3px; - padding-right: 5px; - background-color: #d3d3d378; -} - -.coefs_histo { - height: 32px; - display: flex; - gap: 4px; - color: rgb(0, 0, 0); - text-align: center; - align-items: flex-end; - font-weight: normal; - font-size: 60%; -} - -.coefs_histo>div { - --height: calc(32px * var(--coef) / var(--max)); - height: var(--height); - padding: var(--height) 4px 0 4px; - background: #09c; - box-sizing: border-box; -} - -.coefs_histo>div:nth-child(odd) { - background-color: #9c0; -} - -span.moduleimpl_abs_link { - padding-right: 2em; -} - -.moduleimpl_evaluations_top_links { - font-size: 80%; - margin-bottom: 3px; -} - -table.moduleimpl_evaluations { - width: 100%; - border-spacing: 0px; -} - -th.moduleimpl_evaluations { - font-weight: normal; - text-align: left; - color: rgb(0, 0, 128); -} - -th.moduleimpl_evaluations a, -th.moduleimpl_evaluations a:visited { - font-weight: normal; - color: red; - text-decoration: none; -} - -th.moduleimpl_evaluations a:hover { - text-decoration: underline; -} - -tr.mievr { - background-color: #eeeeee; -} - -tr.mievr_rattr { - background-color: #dddddd; -} - -span.mievr_rattr { - display: inline-block; - font-weight: bold; - font-size: 80%; - color: white; - background-color: orangered; - margin-left: 2em; - margin-top: 1px; - margin-bottom: 2px; - ; - border: 1px solid red; - padding: 1px 3px 1px 3px; -} - -tr.mievr td.mievr_tit { - font-weight: bold; - background-color: #cccccc; -} - -tr.mievr td { - text-align: left; - background-color: white; -} - -tr.mievr th { - background-color: white; -} - -tr.mievr td.mievr { - width: 90px; -} - -tr.mievr td.mievr_menu { - width: 110px; -} - -tr.mievr td.mievr_dur { - width: 60px; -} - -tr.mievr td.mievr_coef { - width: 60px; -} - -tr.mievr td.mievr_nbnotes { - width: 90px; -} - -tr td.mievr_grtit { - vertical-align: top; - text-align: right; - font-weight: bold; -} - -span.mievr_lastmodif { - padding-left: 2em; - font-weight: normal; - font-style: italic; -} - -a.mievr_evalnodate { - color: rgb(215, 90, 0); - font-style: italic; - text-decoration: none; -} - -a.mievr_evalnodate:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - -span.evalindex_cont { - float: right; -} - -span.evalindex { - font-weight: normal; - font-size: 80%; - margin-right: 5px; -} - -.eval_arrows_chld { - margin-right: 3px; - margin-top: 3px; -} - -.eval_arrows_chld a { - margin-left: 3px; -} - -table.moduleimpl_evaluations td.eval_poids { - color: rgb(0, 0, 255); -} - -span.eval_coef_ue { - color: rgb(6, 73, 6); - font-style: normal; - font-size: 80%; - margin-right: 2em; -} - -span.eval_coef_ue_titre {} - -/* Formulaire edition des partitions */ -form#editpart table { - border: 1px solid gray; - border-collapse: collapse; -} - -form#editpart tr.eptit th { - font-size: 110%; - border-bottom: 1px solid gray; -} - -form#editpart td { - border-bottom: 1px dashed gray; -} - -form#editpart table td { - padding-left: 1em; -} - -form#editpart table td.epnav { - padding-left: 0; -} - -/* Liste des formations */ -ul.notes_formation_list { - list-style-type: none; - font-size: 110%; -} - -li.notes_formation_list { - padding-top: 10px; -} - -table.formation_list_table { - width: 100%; - border-collapse: collapse; - background-color: rgb(0%, 90%, 90%); -} - -table#formation_list_table tr.gt_hl { - background-color: rgb(96%, 96%, 96%); -} - -.formation_list_table img.delete_small_img { - width: 16px; - height: 16px; -} - -.formation_list_table td.acronyme { - width: 10%; - font-weight: bold; -} - -.formation_list_table td.formation_code { - font-family: Courier, monospace; - font-weight: normal; - color: black; - font-size: 100%; -} - -.formation_list_table td.version { - text-align: center; -} - -.formation_list_table td.titre { - width: 50%; -} - -.formation_list_table td.sems_list_txt { - font-size: 90%; -} - -/* Presentation formation (ue_list) */ -div.formation_descr { - background-color: rgb(250, 250, 240); - border: 1px solid rgb(128, 128, 128); - padding-left: 5px; - padding-bottom: 5px; - margin-right: 12px; -} - -div.formation_descr span.fd_t { - font-weight: bold; - margin-right: 5px; -} - -div.formation_descr span.fd_n { - font-weight: bold; - font-style: italic; - color: green; - margin-left: 6em; -} - -div.formation_ue_list { - border: 1px solid black; - margin-top: 5px; - margin-right: 12px; - padding-left: 5px; -} - -div.formation_list_ues_titre { - padding-left: 24px; - padding-right: 24px; - font-size: 120%; - font-weight: bold; -} - -div.formation_list_modules, -div.formation_list_ues { - border-radius: 18px; - margin-left: 10px; - margin-right: 10px; - margin-bottom: 10px; - padding-bottom: 1px; -} - -div.formation_list_ues { - background-color: #b7d2fa; - margin-top: 20px -} - -div.formation_list_modules { - margin-top: 20px; -} - -div.formation_list_modules_RESSOURCE { - background-color: #f8c844; -} - -div.formation_list_modules_SAE { - background-color: #c6ffab; -} - -div.formation_list_modules_STANDARD { - background-color: #afafc2; -} - -div.formation_list_modules_titre { - padding-left: 24px; - padding-right: 24px; - font-weight: bold; - font-size: 120%; -} - -div.formation_list_ues ul.notes_module_list { - margin-top: 0px; - margin-bottom: -1px; - padding-top: 5px; - padding-bottom: 5px; -} - -div.formation_list_modules ul.notes_module_list { - margin-top: 0px; - margin-bottom: -1px; - padding-top: 5px; - padding-bottom: 5px; -} - -span.missing_ue_ects { - color: red; - font-weight: bold; -} - -li.module_malus span.formation_module_tit { - color: red; - font-weight: bold; - text-decoration: underline; -} - -span.formation_module_ue { - background-color: #b7d2fa; -} - -span.notes_module_list_buts { - margin-right: 5px; -} - -.formation_apc_infos ul li:not(:last-child) { - margin-bottom: 6px; -} - -div.ue_list_tit { - font-weight: bold; - margin-top: 5px; -} - -ul.apc_ue_list { - background-color: rgba(180, 189, 191, 0.14); - margin-left: 8px; - margin-right: 8px; -} - -ul.notes_ue_list { - margin-top: 4px; - margin-right: 1em; - margin-left: 1em; - /* padding-top: 1em; */ - padding-bottom: 1em; - font-weight: bold; -} - -.formation_classic_infos ul.notes_ue_list { - padding-top: 0px; -} - -.formation_classic_infos li.notes_ue_list { - margin-top: 9px; - list-style-type: none; - border: 1px solid maroon; - border-radius: 10px; - padding-bottom: 5px; -} - -span.ue_type_1 { - color: green; - font-weight: bold; -} - -span.ue_code { - font-family: Courier, monospace; - font-weight: normal; - color: black; - font-size: 80%; -} - -span.ue_type { - color: green; - margin-left: 1.5em; - margin-right: 1.5em; -} - -ul.notes_module_list span.ue_coefs_list { - color: blue; - font-size: 70%; -} - -div.formation_ue_list_externes { - background-color: #98cc98; -} - -div.formation_ue_list_externes ul.notes_ue_list, -div.formation_ue_list_externes li.notes_ue_list { - background-color: #98cc98; -} - -span.ue_is_external span { - color: orange; -} - -span.ue_is_external a { - font-weight: normal; -} - -li.notes_matiere_list { - margin-top: 2px; -} - -ul.notes_matiere_list { - background-color: rgb(220, 220, 220); - font-weight: normal; - font-style: italic; - border-top: 1px solid maroon; -} - -ul.notes_module_list { - background-color: rgb(210, 210, 210); - font-weight: normal; - font-style: normal; -} - -div.ue_list_div { - border: 3px solid rgb(35, 0, 160); - padding-left: 5px; - padding-top: 5px; - margin-bottom: 5px; - margin-right: 5px; -} - -div.ue_list_tit_sem { - font-size: 120%; - font-weight: bold; - color: orangered; - display: list-item; - /* This has to be "list-item" */ - list-style-type: disc; - /* See https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type */ - list-style-position: inside; -} - -input.sco_tag_checkbox { - margin-bottom: 10px; -} - -.notes_ue_list a.stdlink { - color: #001084; - text-decoration: underline; -} - -.notes_ue_list span.locked { - font-weight: normal; -} - -.notes_ue_list a.smallbutton img { - position: relative; - top: 2px; -} - -div#ue_list_code { - background-color: rgb(155, 218, 155); - padding: 10px; - border: 1px solid blue; - border-radius: 10px; - padding: 10px; - margin-top: 10px; - margin-right: 15px; -} - -ul.notes_module_list { - list-style-type: none; -} - -/*Choix niveau dans form edit UE */ -div.ue_choix_niveau { - background-color: rgb(191, 242, 255); - border: 1px solid blue; - border-radius: 10px; - padding: 10px; - margin-top: 10px; - margin-right: 15px; -} - -/* Choix niveau dans edition programme (ue_table) */ -div.formation_list_ues div.ue_choix_niveau { - margin-left: 64px; - margin-right: 64px; - margin-top: 2px; - padding: 4px; - font-size: 14px; -} - -div.formation_list_ues div.ue_choix_niveau b { - font-weight: normal; -} - -div#ue_list_modules { - background-color: rgb(251, 225, 165); - border: 1px solid blue; - border-radius: 10px; - padding: 10px; - margin-top: 10px; - margin-right: 15px; -} - -div#ue_list_etud_validations { - background-color: rgb(220, 250, 220); - padding-left: 4px; - padding-bottom: 1px; - margin: 3ex; -} - -div#ue_list_etud_validations span { - font-weight: bold; -} - -span.ue_share { - font-weight: bold; -} - -div.ue_warning { - border: 1px solid red; - border-radius: 10px; - background-color: rgb(250, 220, 220); - margin-top: 10px; - margin-right: 15px; - margin-bottom: 10px; - padding: 10px; -} - -div.ue_warning:first-child { - font-weight: bold; -} - -div.ue_warning span:before { - content: url(/ScoDoc/static/icons/warning_img.png); - vertical-align: -80%; -} - -div.ue_warning span { - font-weight: bold; -} - -span.missing_value { - font-weight: bold; - color: red; -} - -span.code_parcours { - color: white; - background-color: rgb(254, 95, 246); - padding-left: 4px; - padding-right: 4px; - border-radius: 2px; -} - -tr#tf_module_parcours>td { - background-color: rgb(229, 229, 229); -} - -tr#tf_module_app_critiques>td { - background-color: rgb(194, 209, 228); -} - -/* tableau recap notes */ -table.notes_recapcomplet { - border: 2px solid blue; - border-spacing: 0px 0px; - border-collapse: collapse; - white-space: nowrap; -} - -tr.recap_row_even { - background-color: rgb(210, 210, 210); -} - -@media print { - tr.recap_row_even { - /* bordures noires pour impression */ - border-top: 1px solid black; - border-bottom: 1px solid black; - } -} - -tr.recap_row_min { - border-top: 1px solid blue; -} - -tr.recap_row_min, -tr.recap_row_max { - font-weight: normal; - font-style: italic; -} - -tr.recap_row_moy { - font-weight: bold; -} - -tr.recap_row_nbeval { - color: green; -} - -tr.recap_row_ects { - color: rgb(160, 86, 3); - border-bottom: 1px solid blue; -} - -td.recap_tit { - font-weight: bold; - text-align: left; - padding-right: 1.2em; -} - -td.recap_tit_ue { - font-weight: bold; - text-align: left; - padding-right: 1.2em; - padding-left: 2px; - border-left: 1px solid blue; -} - -td.recap_col { - padding-right: 1.2em; - text-align: left; -} - -td.recap_col_moy { - padding-right: 1.5em; - text-align: left; - font-weight: bold; - color: rgb(80, 0, 0); -} - -td.recap_col_moy_inf { - padding-right: 1.5em; - text-align: left; - font-weight: bold; - color: rgb(255, 0, 0); -} - -td.recap_col_ue { - padding-right: 1.2em; - padding-left: 4px; - text-align: left; - font-weight: bold; - border-left: 1px solid blue; -} - -td.recap_col_ue_inf { - padding-right: 1.2em; - padding-left: 4px; - text-align: left; - color: rgb(255, 0, 0); - border-left: 1px solid blue; -} - -td.recap_col_ue_val { - padding-right: 1.2em; - padding-left: 4px; - text-align: left; - color: rgb(0, 140, 0); - border-left: 1px solid blue; -} - -/* noms des etudiants sur recap complet */ -table.notes_recapcomplet a:link, -table.notes_recapcomplet a:visited { - text-decoration: none; - color: black; -} - -table.notes_recapcomplet a:hover { - color: red; - text-decoration: underline; -} - -/* bulletin */ -div.notes_bulletin { - margin-right: 5px; -} - -div.bull_head { - display: grid; - justify-content: space-between; - grid-template-columns: auto auto; -} - -div.bull_photo { - display: inline-block; - margin-right: 10px; -} - -span.bulletin_menubar_but { - display: inline-block; - margin-left: 2em; - margin-right: 2em; -} - -table.notes_bulletin { - border-collapse: collapse; - border: 2px solid rgb(100, 100, 240); - width: 100%; - margin-right: 100px; - background-color: rgb(240, 250, 255); - font-family: arial, verdana, sans-serif; - font-size: 13px; -} - -tr.notes_bulletin_row_gen { - border-top: 1px solid black; - font-weight: bold; -} - -tr.notes_bulletin_row_rang { - font-weight: bold; -} - -tr.notes_bulletin_row_ue { - /* background-color: rgb(170,187,204); voir sco_utils.UE_COLORS */ - font-weight: bold; - border-top: 1px solid black; -} - -tr.bul_row_ue_cur { - background-color: rgb(180, 180, 180); -} - -tr.bul_row_ue_cap { - background-color: rgb(150, 170, 200); - color: rgb(50, 50, 50); -} - -tr.notes_bulletin_row_mat { - border-top: 2px solid rgb(140, 140, 140); - color: blue; -} - -tr.notes_bulletin_row_mod { - border-top: 1px solid rgb(140, 140, 140); -} - -tr.notes_bulletin_row_sum_ects { - border-top: 1px solid black; - font-weight: bold; - background-color: rgb(170, 190, 200); -} - -tr.notes_bulletin_row_mod td.titre, -tr.notes_bulletin_row_mat td.titre { - padding-left: 1em; -} - -tr.notes_bulletin_row_eval { - font-style: italic; - color: rgb(60, 60, 80); -} - -tr.notes_bulletin_row_eval_incomplete .discretelink { - color: rgb(200, 0, 0); -} - -tr.b_eval_first td { - border-top: 1px dashed rgb(170, 170, 170); -} - -tr.b_eval_first td.titre { - border-top: 0px; -} - -tr.notes_bulletin_row_eval td.module { - padding-left: 5px; - border-left: 1px dashed rgb(170, 170, 170); -} - -span.bul_ue_descr { - font-weight: normal; - font-style: italic; -} - -table.notes_bulletin td.note { - padding-left: 1em; -} - -table.notes_bulletin td.min, -table.notes_bulletin td.max, -table.notes_bulletin td.moy { - font-size: 80%; -} - -table.notes_bulletin tr.notes_bulletin_row_ue_cur td.note, -table.notes_bulletin tr.notes_bulletin_row_ue_cur td.min, -table.notes_bulletin tr.notes_bulletin_row_ue_cur td.max { - font-style: italic; -} - -table.notes_bulletin tr.bul_row_ue_cur td, -table.notes_bulletin tr.bul_row_ue_cur td a { - color: rgb(114, 89, 89); -} - -.note_bold { - font-weight: bold; -} - -td.bull_coef_eval, -td.bull_nom_eval { - font-style: italic; - color: rgb(60, 60, 80); -} - -tr.notes_bulletin_row_eval td.note { - font-style: italic; - color: rgb(40, 40, 40); - font-size: 90%; -} - -tr.notes_bulletin_row_eval td.note .note_nd { - font-weight: bold; - color: red; -} - -/* --- Bulletins UCAC */ -tr.bul_ucac_row_tit, -tr.bul_ucac_row_ue, -tr.bul_ucac_row_total, -tr.bul_ucac_row_decision, -tr.bul_ucac_row_mention { - font-weight: bold; - border: 1px solid black; -} - -tr.bul_ucac_row_tit { - background-color: rgb(170, 187, 204); -} - -tr.bul_ucac_row_total, -tr.bul_ucac_row_decision, -tr.bul_ucac_row_mention { - background-color: rgb(220, 220, 220); -} - -/* ---- /ucac */ - -span.bul_minmax { - font-weight: normal; - font-size: 66%; -} - -span.bul_minmax:before { - content: " "; -} - -a.invisible_link, -a.invisible_link:hover { - text-decoration: none; - color: rgb(20, 30, 30); -} - -a.bull_link { - text-decoration: none; - color: rgb(20, 30, 30); -} - -a.bull_link:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - - -div.bulletin_menubar { - padding-left: 25px; -} - -.bull_liensemestre { - font-weight: bold; -} - -.bull_liensemestre a { - color: rgb(255, 0, 0); - text-decoration: none; -} - -.bull_liensemestre a:hover { - color: rgb(153, 51, 51); - text-decoration: underline; -} - -.bull_appreciations p { - margin: 0; - font-style: italic; -} - -.bull_appreciations_link { - margin-left: 1em; -} - -span.bull_appreciations_date { - margin-right: 1em; - font-style: normal; - font-size: 75%; -} - -div.eval_description { - color: rgb(20, 20, 20); - /* border: 1px solid rgb(30,100,0); */ - padding: 3px; -} - -div.bul_foot { - max-width: 1000px; - background: #FFE7D5; - border-radius: 16px; - border: 1px solid #AAA; - padding: 16px 32px; - margin: auto; -} - -div.bull_appreciations { - border-left: 1px solid black; - padding-left: 5px; -} - -/* Saisie des notes */ -div.saisienote_etape1 { - border: 2px solid blue; - padding: 5px; - background-color: rgb(231, 234, 218); - /* E7EADA */ -} - -div.saisienote_etape2 { - border: 2px solid green; - margin-top: 1em; - padding: 5px; - background-color: rgb(234, 221, 218); - /* EADDDA */ -} - -span.titredivsaisienote { - font-weight: bold; - font-size: 115%; -} - - -.etud_dem { - color: rgb(130, 130, 130); -} - -input.note_invalid { - color: red; - background-color: yellow; -} - -input.note_valid_new { - color: blue; -} - -input.note_saved { - color: green; -} - -span.history { - font-style: italic; -} - -span.histcomment { - font-style: italic; -} - -/* ----- Absences ------ */ -td.matin { - background-color: rgb(203, 242, 255); -} - -td.absent { - background-color: rgb(255, 156, 156); -} - -td.present { - background-color: rgb(230, 230, 230); -} - -span.capstr { - color: red; -} - -b.etuddem { - font-weight: normal; - font-style: italic; -} - -tr.row_1 { - background-color: white; -} - -tr.row_2 { - background-color: white; -} - -tr.row_3 { - background-color: #dfdfdf; -} - -td.matin_1 { - background-color: #e1f7ff; -} - -td.matin_2 { - background-color: #e1f7ff; -} - -td.matin_3 { - background-color: #c1efff; -} - -table.abs_form_table tr:hover td { - border: 1px solid red; -} - - -/* ----- Formulator ------- */ -ul.tf-msg { - color: rgb(6, 80, 18); - border: 1px solid red; -} - -li.tf-msg { - list-style-image: url(/ScoDoc/static/icons/warning16_img.png); - padding-top: 5px; - padding-bottom: 5px; -} - -.warning { - font-weight: bold; - color: red; -} - -.warning::before { - content: url(/ScoDoc/static/icons/warning_img.png); - vertical-align: -80%; -} - -.infop { - font-weight: normal; - color: rgb(26, 150, 26); - font-style: italic; -} - -.infop::before { - content: url(/ScoDoc/static/icons/info-16_img.png); - vertical-align: -15%; - padding-right: 5px; -} - - -form.sco_pref table.tf { - border-spacing: 5px 15px; -} - -td.tf-ro-fieldlabel { - /* font-weight: bold; */ - vertical-align: top; - margin-top: 20px; -} - -td.tf-ro-fieldlabel:after { - content: ' :'; -} - -td.tf-ro-field { - vertical-align: top; -} - -span.tf-ro-value { - background-color: white; - color: grey; - margin-right: 2em; -} - -div.tf-ro-textarea { - border: 1px solid grey; - padding-left: 8px; -} - -select.tf-selglobal { - margin-left: 10px; -} - -td.tf-fieldlabel { - /* font-weight: bold; */ - vertical-align: top; -} - -.tf-comment { - font-size: 80%; - font-style: italic; -} - -.tf-explanation { - font-style: italic; -} - -.radio_green { - background-color: green; -} - -.radio_red { - background-color: red; -} - -td.fvs_val { - border-left: 1px solid rgb(80, 80, 80); - text-align: center; - padding-left: 1em; - padding-right: 1em; -} - -td.fvs_val_inf { - border-left: 1px solid rgb(80, 80, 80); - text-align: center; - padding-left: 1em; - padding-right: 1em; - color: red; -} - -td.fvs_tit { - font-weight: bold; - text-align: left; - border-left: 1px solid rgb(80, 80, 80); - text-align: center; - padding-left: 1em; - padding-right: 1em; -} - -td.fvs_tit_chk { - font-weight: bold; -} - -span.table_nav_mid { - flex-grow: 1; - /* Set the middle element to grow and stretch */ -} - -span.table_nav_prev, -span.table_nav_next { - width: 11em; - /* A fixed width as the default */ -} - -div.table_nav { - width: 100%; - display: flex; - justify-content: space-between; -} - -P.gtr_interdit:before { - content: url(/ScoDoc/static/icons/interdit_img.png); - vertical-align: -80%; -} - -P.gtr_devel:before { - content: url(/ScoDoc/static/icons/devel_img.png); - vertical-align: -80%; -} - -/* ---- Sortable tables --- */ -/* Sortable tables */ -table.sortable a.sortheader { - background-color: #E6E6E6; - color: black; - font-weight: bold; - text-decoration: none; - display: block; -} - -table.sortable span.sortarrow { - color: black; - text-decoration: none; -} - -/* Horizontal bar graph */ -.graph { - width: 100px; - height: 12px; - /* background-color: rgb(200, 200, 250); */ - padding-bottom: 0px; - margin-bottom: 0; - margin-right: 0px; - margin-top: 3px; - margin-left: 10px; - border: 1px solid black; - position: absolute; -} - -.bar { - background-color: rgb(100, 150, 255); - margin: 0; - padding: 0; - position: absolute; - left: 0; - top: 2px; - height: 8px; - z-index: 2; -} - -.mark { - background-color: rgb(0, 150, 0); - margin: 0; - padding: 0; - position: absolute; - top: 0; - width: 2px; - height: 100%; - z-index: 2; -} - -td.cell_graph { - width: 170px; -} - -/* ------------------ Formulaire validation semestre ---------- */ -table.recap_parcours { - color: black; - border-collapse: collapse; -} - -table.recap_parcours td { - padding-left: 8px; - padding-right: 8px; -} - -.recap_parcours tr.sem_courant { - background-color: rgb(255, 241, 118); -} - -.recap_parcours tr.sem_precedent { - background-color: rgb(90%, 95%, 90%); -} - -.recap_parcours tr.sem_autre { - background-color: rgb(90%, 90%, 90%); -} - -.rcp_l1 td { - padding-top: 5px; - border-top: 3px solid rgb(50%, 50%, 50%); - border-right: 0px; - border-left: 0px; - color: blue; - vertical-align: top; -} - -td.rcp_dec { - color: rgb(0%, 0%, 50%); - ; -} - -td.rcp_nonass, -td.rcp_but { - color: red; -} - -.recap_hide_details tr.rcp_l2 { - display: none; -} - -table.recap_hide_details td.ue_acro span { - display: none; -} - -.sco_hide { - display: none; -} - -table.recap_hide_details tr.sem_courant, -table.recap_hide_details tr.sem_precedent { - display: table-row; -} - -table.recap_hide_details tr.sem_courant td.ue_acro span, -table.recap_hide_details tr.sem_precedent td.ue_acro span { - display: inline; -} - -.recap_parcours tr.sem_courant td.rcp_type_sem { - font-weight: bold; -} - -.recap_parcours tr.sem_autre td.rcp_type_sem { - color: rgb(100%, 70%, 70%); -} - -.recap_parcours tr.sem_autre_formation td.rcp_titre_sem { - background-image: repeating-linear-gradient(-45deg, rgb(100, 205, 193), rgb(100, 205, 193) 2px, transparent 5px, transparent 40px); -} - -.rcp_l2 td { - padding-bottom: 5px; -} - -td.sem_ects_tit { - text-align: right; -} - -span.ects_fond { - text-decoration: underline; -} - -span.ects_fond:before { - content: "("; -} - -span.ects_fond:after { - content: ")"; -} - -table.recap_parcours td.datedebut { - color: rgb(0, 0, 128); -} - -table.recap_parcours td.datefin { - color: rgb(0, 0, 128); -} - -table.recap_parcours td.rcp_type_sem { - padding-left: 4px; - padding-right: 4px; - color: red; -} - -td.ue_adm { - color: green; - font-weight: bold; -} - -td.ue_cmp { - color: green; -} - -td.ue_capitalized { - text-decoration: underline; -} - -h3.sfv { - margin-top: 0px; -} - -form.sfv_decisions { - border: 1px solid blue; - padding: 6px; - margin-right: 2px; -} - -form.sfv_decisions_manuelles { - margin-top: 10px; -} - -th.sfv_subtitle { - text-align: left; - font-style: italic; -} - - -tr.sfv_ass { - background-color: rgb(90%, 90%, 80%); -} - -tr.sfv_pbass { - background-color: rgb(90%, 70%, 80%); -} - -div.link_defaillance { - padding-top: 8px; - font-weight: bold; -} - -div.pas_sembox { - margin-top: 10px; - border: 2px solid #a0522d; - padding: 5px; - margin-right: 10px; - font-family: arial, verdana, sans-serif; -} - -span.sp_etape { - display: inline-block; - width: 4em; - font-family: "Andale Mono", "Courier"; - font-size: 75%; - color: black; -} - -.inscrailleurs { - font-weight: bold; - color: red !important; -} - -span.paspaye, -span.paspaye a { - color: #9400d3 !important; -} - -span.finalisationinscription { - color: green; -} - -.pas_sembox_title a { - font-weight: bold; - font-size: 100%; - color: #1C721C; -} - -.pas_sembox_subtitle { - font-weight: normal; - font-size: 100%; - color: blue; - border-bottom: 1px solid rgb(50%, 50%, 50%); - margin-bottom: 8px; -} - -.pas_recap { - font-weight: bold; - font-size: 110%; - margin-top: 10px; -} - -div.pas_help { - width: 80%; - font-size: 80%; - background-color: rgb(90%, 90%, 90%); - color: rgb(40%, 20%, 0%); - margin-top: 30px; - margin-bottom: 30px; -} - -div.pas_help_left { - float: left; -} - -span.libelle { - font-weight: bold; -} - -span.anomalie { - font-style: italic; -} - -/* ---- check absences / evaluations ---- */ -div.module_check_absences h2 { - font-size: 100%; - color: blue; - margin-bottom: 0px; -} - -div.module_check_absences h2.eval_check_absences { - font-size: 80%; - color: black; - margin-left: 20px; - margin-top: 0px; - margin-bottom: 5px; -} - -div.module_check_absences h3 { - font-size: 80%; - color: rgb(133, 0, 0); - margin-left: 40px; - margin-top: 0px; - margin-bottom: 0px; -} - -div.module_check_absences ul { - margin-left: 60px; - font-size: 80%; - margin-top: 0px; - margin-bottom: 0px; -} - -/* ----------------------------------------------- */ -/* Help bubbles (aka tooltips) */ -/* ----------------------------------------------- */ -.tooltip { - width: 200px; - color: #000; - font: lighter 11px/1.3 Arial, sans-serif; - text-decoration: none; - text-align: center; -} - -.tooltip span.top { - padding: 30px 8px 0; - background: url(/ScoDoc/static/icons/bt_gif.png) no-repeat top; -} - -.tooltip b.bottom { - padding: 3px 8px 15px; - color: #548912; - background: url(/ScoDoc/static/icons/bt_gif.png) no-repeat bottom; -} - -/* ----------------------------------------------- */ - -/* ----------------------------- */ -/* TABLES generees par gen_table */ -/* ----------------------------- */ -/* Voir gt_table.css les definitions s'appliquant à toutes les tables - */ - -table.table_cohorte tfoot tr td, -table.table_cohorte tfoot tr th { - background-color: rgb(90%, 95%, 100%); - border-right: 1px solid #dddddd; -} - -table.table_cohorte tfoot tr th { - text-align: left; - border-left: 1px solid #dddddd; - font-weight: normal; -} - -table.table_coldate tr td:first-child { - /* largeur col. date/time */ - width: 12em; - color: rgb(0%, 0%, 50%); -} - - -table.table_listegroupe tr td { - padding-left: 0.5em; - padding-right: 0.5em; -} - -table.list_users td.roles { - width: 22em; -} - -table.list_users td.date_modif_passwd { - white-space: nowrap; -} - -table.formsemestre_description tr.table_row_ue td { - font-weight: bold; -} - -table.formsemestre_description tr.evaluation td { - color: rgb(4, 16, 159); - font-size: 85%; -} - -table.formsemestre_description tr.evaluation td.poids a { - font-style: italic; - color: rgb(4, 16, 159); -} - -table.formsemestre_description tbody tr.evaluation td { - background-color: #cee4fa !important; -} - -/* --- */ -tr#tf_extue_decl>td, -tr#tf_extue_note>td { - padding-top: 20px; -} - -tr#tf_extue_titre>td, -tr#tf_extue_acronyme>td, -tr#tf_extue_type>td, -tr#tf_extue_ects>td { - padding-left: 20px; -} - -/* ----------------------------- */ - -div.form_rename_partition { - margin-top: 2em; - margin-bottom: 2em; -} - - -td.calday { - text-align: right; - vertical-align: top; -} - -div.cal_evaluations table.monthcalendar td.calcell { - padding-left: 0.6em; - width: 6em; -} - - -div.cal_evaluations table.monthcalendar td a { - color: rgb(128, 0, 0); -} - -#lyc_map_canvas { - width: 900px; - height: 600px; -} - -div.othersemlist { - margin-bottom: 10px; - margin-right: 5px; - padding-bottom: 4px; - padding-left: 5px; - border: 1px solid gray; -} - -div.othersemlist p { - font-weight: bold; - margin-top: 0px; -} - -div.othersemlist input { - margin-left: 20px; -} - - -div#update_warning { - display: none; - border: 1px solid red; - background-color: rgb(250, 220, 220); - margin: 3ex; - padding-left: 1ex; - padding-right: 1ex; - padding-bottom: 1ex; -} - -div#update_warning>div:first-child:before { - content: url(/ScoDoc/static/icons/warning_img.png); - vertical-align: -80%; -} - -div#update_warning>div:nth-child(2) { - font-size: 80%; - padding-left: 8ex; -} - -/* - Titres des tabs: - .nav-tabs li a { - font-variant: small-caps; - font-size: 13pt; - } - - #group-tabs { - clear: both; - } - - #group-tabs ul { - display: inline; - } - - #group-tabs ul li { - display: inline; - } -*/ - -/* Page accueil */ -#scodoc_attribution p { - font-size: 75%; -} - -div.maindiv { - margin: 1em; -} - -ul.main { - list-style-type: square; - margin-top: 1em; -} - -ul.main li { - padding-bottom: 2ex; -} - - -#scodoc_admin { - background-color: #EEFFFF; -} - -#message, -.message { - margin-top: 2px; - margin-bottom: 0px; - padding: 0.1em; - margin-left: auto; - margin-right: auto; - background-color: #ffff73; - -moz-border-radius: 8px; - -khtml-border-radius: 8px; - border-radius: 8px; - font-family: arial, verdana, sans-serif; - font-weight: bold; - width: 40%; - text-align: center; - color: red; -} - -h4.scodoc { - padding-top: 20px; - padding-bottom: 0px; -} - -tr#erroneous_ue td { - color: red; -} - -/* Export Apogee */ - -div.apo_csv_infos { - margin-bottom: 12px; -} - -div.apo_csv_infos span:first-of-type { - font-weight: bold; - margin-right: 2ex; -} - -div.apo_csv_infos span:last-of-type { - font-weight: bold; - font-family: "Andale Mono", "Courier"; - margin-right: 2ex; -} - -div.apo_csv_1 { - margin-bottom: 10px; -} - -div.apo_csv_status { - margin-top: 20px; - padding-left: 22px; -} - -div.apo_csv_status li { - margin: 10px 0; -} - -div.apo_csv_status span { - font-family: arial, verdana, sans-serif; - font-weight: bold; -} - -div.apo_csv_status_nok { - background: url(/ScoDoc/static/icons/bullet_warning_img.png) no-repeat left top 0px; -} - -div.apo_csv_status_missing_elems { - background: url(/ScoDoc/static/icons/bullet_warning_img.png) no-repeat left top 0px; - padding-left: 22px; -} - -div#param_export_res { - padding-top: 1em; -} - -div#apo_elements span.apo_elems { - font-family: "Andale Mono", "Courier"; - font-weight: normal; - font-size: 9pt; -} - -div#apo_elements span.apo_elems .missing { - color: red; - font-weight: normal; -} - -div.apo_csv_jury_nok li { - color: red; -} - - -pre.small_pre_acc { - font-size: 60%; - width: 90%; - height: 20em; - background-color: #fffff0; - overflow: scroll; -} - -.apo_csv_jury_ok input[type=submit] { - color: green; -} - -li.apo_csv_warning, -.apo_csv_problems li.apo_csv_warning { - color: darkorange; -} - -.apo_csv_problems li { - color: red; -} - -table.apo_maq_list { - margin-bottom: 12px; -} - -table.apo_maq_table tr.apo_not_scodoc td:last-of-type { - color: red; -} - -table.apo_maq_table th:last-of-type { - width: 4em; - text-align: left; -} - -div.apo_csv_list { - margin-top: 4px; - padding-left: 5px; - padding-bottom: 5px; - border: 1px dashed rgb(150, 10, 40); -} - -#apo_csv_download { - margin-top: 5px; -} - -div.apo_compare_csv_form_but { - margin-top: 10px; - margin-bottom: 10px; -} - -div.apo_compare_csv_form_submit input { - margin-top: 2ex; - margin-left: 5em; - font-size: 120%; -} - -.apo_compare_csv div.section .tit { - margin-top: 10px; - font-size: 120%; - font-weight: bold; -} - -.apo_compare_csv div.section .key { - font-size: 110%; -} - -.apo_compare_csv div.section .val_ok { - font-size: 110%; - color: green; - font-weight: bold; - font-family: "Courier New", Courier, monospace; -} - -.apo_compare_csv div.section .val_dif { - font-size: 110%; - color: red; - font-weight: bold; - font-family: "Courier New", Courier, monospace; -} - -.apo_compare_csv div.section .p_ok { - font-size: 100%; - font-style: italic; - color: green; - margin-left: 4em; -} - -.apo_compare_csv div.section .s_ok { - font-size: 100%; - font-style: italic; - color: green; -} - -.apo_compare_csv div.sec_table { - margin-bottom: 10px; - margin-top: 20px; -} - -.apo_compare_csv div.sec_table .gt_table { - font-size: 100%; -} - -.apo_compare_csv div.sec_table .gt_table td.val_A, -.apo_compare_csv div.sec_table .gt_table td.val_B { - color: red; - font-weight: bold; - text-align: center; -} - -.apo_compare_csv div.sec_table .gt_table td.type_res { - text-align: center; -} - -div.semset_descr { - border: 1px dashed rgb(10, 150, 40); - padding-left: 5px; -} - -div.semset_descr p { - margin: 5px; -} - -ul.semset_listsems li { - margin-top: 10px; -} - -ul.semset_listsems li:first-child { - margin-top: 0; -} - -span.box_title { - font-weight: bold; - font-size: 115%; -} - -div.apo_csv_status { - border: 1px dashed red; - padding-bottom: 5px; -} - -.form_apo_export input[type="submit"] { - -webkit-appearance: button; - font-size: 150%; - font-weight: bold; - color: green; - margin: 10px; -} - -span.vdi_label { - padding-left: 2em; -} - -/* Poursuites edtude PE */ -form#pe_view_sem_recap_form div.pe_template_up { - margin-top: 20px; - margin-bottom: 30px; -} - -/* Editable */ -span.span_apo_edit { - border-bottom: 1px dashed #84ae84; -} - -/* Tags */ -.notes_module_list span.sco_tag_edit { - display: none; -} - -span.sco_tag_edit .tag-editor { - background-color: rgb(210, 210, 210); - border: 0px; - margin-left: 40px; - margin-top: 2px; -} - -div.sco_tag_module_edit span.sco_tag_edit .tag-editor { - background-color: rgb(210, 210, 210); - border: 0px; - margin-left: 0px; - margin-top: 2px; -} - -span.sco_tag_edit .tag-editor-delete { - height: 20px; -} - -/* Autocomplete noms */ -.ui-menu-item { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 11pt; -} - -div#formsemestre_ext_edit_ue_validations { - margin-bottom: 2ex; -} - -form.tf_ext_edit_ue_validations table { - border-collapse: collapse; - width: 97%; - margin-left: 1em; - margin-right: 1em; -} - -form.tf_ext_edit_ue_validations table th, -form.tf_ext_edit_ue_validations table td { - border-bottom: 1px solid rgb(168, 168, 168); -} - -form.tf_ext_edit_ue_validations table th { - padding-left: 1em; - padding-right: 1em; -} - -form.tf_ext_edit_ue_validations table td.tf_field_note, -form.tf_ext_edit_ue_validations table td.tf_field_coef { - text-align: center; -} - -form.tf_ext_edit_ue_validations table td.tf_field_note input { - margin-left: 1em; -} - -span.ext_sem_moy { - font-weight: bold; - color: rgb(122, 40, 2); - font-size: 120%; -} - -/* DataTables */ - -table.dataTable tr.odd td { - background-color: #ecf5f4; -} - -table.dataTable tr.gt_lastrow th { - text-align: right; -} - -table.dataTable td.etudinfo, -table.dataTable td.group { - text-align: left; -} - -/* ------------- Nouveau tableau recap ------------ */ -div.table_recap { - margin-top: 6px; -} - -div.table_recap table.table_recap { - width: auto; -} - -table.table_recap tr.selected td { - border-bottom: 1px solid rgb(248, 0, 33); - border-top: 1px solid rgb(248, 0, 33); - background-color: rgb(253, 255, 155); -} - -table.table_recap tr.selected td:first-child { - border-left: 1px solid rgb(248, 0, 33); -} - -table.table_recap tr.selected td:last-child { - border-right: 1px solid rgb(248, 0, 33); -} - -table.table_recap tbody td { - padding-top: 4px !important; - padding-bottom: 4px !important; -} - -table.table_recap tbody td:hover { - color: rgb(163, 0, 0); - text-decoration: dashed underline; -} - -/* col moy gen en gras seulement pour les form. classiques */ -table.table_recap.classic td.col_moy_gen { - font-weight: bold; -} - -table.table_recap .identite_court { - white-space: nowrap; - text-align: left; -} - -table.table_recap .rang { - white-space: nowrap; - text-align: right; -} - -table.table_recap .col_ue, -table.table_recap .col_ue_code, -table.table_recap .col_moy_gen, -table.table_recap .group { - border-left: 1px solid blue; -} - -table.table_recap .col_ue { - font-weight: bold; -} - -table.table_recap.jury .col_ue { - font-weight: normal; -} - -table.table_recap.jury .col_rcue, -table.table_recap.jury .col_rcue_code { - font-weight: bold; -} - -table.table_recap.jury tr.even td.col_rcue, -table.table_recap.jury tr.even td.col_rcue_code { - background-color: #b0d4f8; -} - -table.table_recap.jury tr.odd td.col_rcue, -table.table_recap.jury tr.odd td.col_rcue_code { - background-color: #abcdef; -} - -table.table_recap.jury tr.odd td.col_rcues_validables { - background-color: #e1d3c5 !important; -} - -table.table_recap.jury tr.even td.col_rcues_validables { - background-color: #fcebda !important; -} - -table.table_recap .group { - border-left: 1px dashed rgb(160, 160, 160); - white-space: nowrap; -} - -table.table_recap .admission { - white-space: nowrap; - color: rgb(6, 73, 6); -} - -table.table_recap .admission_first { - border-left: 1px solid blue; -} - -table.table_recap tbody tr td a:hover { - color: red; - text-decoration: underline; -} - -/* noms des etudiants sur recap complet */ -table.table_recap a:link, -table.table_recap a:visited { - text-decoration: none; - color: black; -} - -table.table_recap a.stdlink:link, -table.table_recap a.stdlink:visited { - color: blue; - text-decoration: underline; -} - -table.table_recap tfoot th, -table.table_recap thead th { - text-align: left; - padding-left: 10px !important; -} - -table.table_recap td.moy_inf { - font-weight: bold; - color: rgb(225, 147, 0); -} - -table.table_recap td.moy_ue_valid { - color: rgb(0, 140, 0); -} - -table.table_recap td.moy_ue_warning { - color: rgb(255, 0, 0); -} - -table.table_recap td.col_ues_validables { - white-space: nowrap; - font-style: normal !important; -} - - -.green-arrow-up { - display: inline-block; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-bottom: 8px solid rgb(48, 239, 0); -} - -table.table_recap td.col_ue_bonus, -table.table_recap th.col_ue_bonus { - font-size: 80%; - font-weight: bold; - color: rgb(0, 128, 11); -} - -table.table_recap td.col_ue_bonus>span.sp2l { - margin-left: 2px; -} - -table.table_recap td.col_ue_bonus { - white-space: nowrap; -} - -table.table_recap td.col_malus, -table.table_recap th.col_malus { - font-size: 80%; - font-weight: bold; - color: rgb(165, 0, 0); -} - - -table.table_recap tr.ects td { - color: rgb(160, 86, 3); - font-weight: bold; - border-bottom: 1px solid blue; -} - -table.table_recap tr.coef td { - font-style: italic; - color: #9400d3; -} - -table.table_recap tr.coef td, -table.table_recap tr.min td, -table.table_recap tr.max td, -table.table_recap tr.moy td { - font-size: 80%; - padding-top: 3px; - padding-bottom: 3px; -} - -table.table_recap tr.dem td { - color: rgb(100, 100, 100); - font-style: italic; -} - -table.table_recap tr.def td { - color: rgb(121, 74, 74); - font-style: italic; -} - -table.table_recap td.evaluation, -table.table_recap tr.descr_evaluation { - font-size: 90%; - color: rgb(4, 16, 159); -} - -table.table_recap tr.descr_evaluation a { - color: rgb(4, 16, 159); - text-decoration: none; -} - -table.table_recap tr.descr_evaluation a:hover { - color: red; -} - -table.table_recap tr.descr_evaluation { - vertical-align: top; -} - -table.table_recap tr.apo { - font-size: 75%; - font-family: monospace; -} - -table.table_recap tr.apo td { - border: 1px solid gray; - background-color: #d8f5fe; -} - -table.table_recap td.evaluation.first, -table.table_recap th.evaluation.first { - border-left: 2px solid rgb(4, 16, 159); -} - -table.table_recap td.evaluation.first_of_mod, -table.table_recap th.evaluation.first_of_mod { - border-left: 1px dashed rgb(4, 16, 159); -} - - -table.table_recap td.evaluation.att { - color: rgb(255, 0, 217); - font-weight: bold; -} - -table.table_recap td.evaluation.abs { - color: rgb(231, 0, 0); - font-weight: bold; -} - -table.table_recap td.evaluation.exc { - font-style: italic; - color: rgb(0, 131, 0); -} - -table.table_recap td.evaluation.non_inscrit { - font-style: italic; - color: rgb(101, 101, 101); -} - -div.table_jury_but_links { - margin-top: 16px; - margin-bottom: 16px; -} - -/* ------------- Tableau etat evals ------------ */ - -div.evaluations_recap table.evaluations_recap { - width: auto !important; - border: 1px solid black; -} - -table.evaluations_recap tr.odd td { - background-color: #fff4e4; -} - -table.evaluations_recap tr.res td { - background-color: #f7d372; -} - -table.evaluations_recap tr.sae td { - background-color: #d8fcc8; -} - - -table.evaluations_recap tr.module td { - font-weight: bold; -} - -table.evaluations_recap tr.evaluation td.titre { - font-style: italic; - padding-left: 2em; -} - -table.evaluations_recap td.titre, -table.evaluations_recap th.titre { - max-width: 350px; -} - -table.evaluations_recap td.complete, -table.evaluations_recap th.complete { - text-align: center; -} - -table.evaluations_recap tr.evaluation.incomplete td, -table.evaluations_recap tr.evaluation.incomplete td a { - color: red; -} - -table.evaluations_recap tr.evaluation.incomplete td a.incomplete { - font-weight: bold; -} - -table.evaluations_recap td.inscrits, -table.evaluations_recap td.manquantes, -table.evaluations_recap td.nb_abs, -table.evaluations_recap td.nb_att, -table.evaluations_recap td.nb_exc { - text-align: center; -} - -/* ------------- Tableau récap formation ------------ */ -table.formation_table_recap tr.ue td { - font-weight: bold; -} - -table.formation_table_recap td.coef, -table.formation_table_recap td.ects, -table.formation_table_recap td.nb_moduleimpls, -table.formation_table_recap td.heures_cours, -table.formation_table_recap td.heures_td, -table.formation_table_recap td.heures_tp { - text-align: right; +/* # -*- mode: css -*- + ScoDoc, (c) Emmanuel Viennet 1998 - 2021 + */ + +html, +body { + margin: 0; + padding: 0; + width: 100%; + background-color: rgb(242, 242, 238); + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12pt; +} + + +@media print { + .noprint { + display: none; + } +} + +h1, +h2, +h3 { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +h3 { + font-size: 14pt; + font-weight: bold; +} + +div#gtrcontent { + margin-bottom: 4ex; +} + +.gtrcontent { + margin-left: 140px; + height: 100%; + margin-bottom: 10px; +} + +.gtrcontent a, +.gtrcontent a:visited { + color: rgb(4, 16, 159); + text-decoration: none; +} + +.gtrcontent a:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + +.scotext { + font-family: TimesNewRoman, "Times New Roman", Times, Baskerville, Georgia, serif; +} + +.sco-hidden { + display: None; +} + +div.tab-content { + margin-top: 10px; + margin-left: 15px; +} + +div.tab-content ul { + padding-bottom: 3px; +} + +div.tab-content h3 { + font-size: 1.17em; +} + +form#group_selector { + display: inline; +} + +#group_selector button { + padding-top: 3px; + padding-bottom: 3px; + margin-bottom: 3px; +} + +/* ----- bandeau haut ------ */ +span.bandeaugtr { + width: 100%; + margin: 0; + border-width: 0; + padding-left: 160px; + /* background-color: rgb(17,51,85); */ +} + +@media print { + span.bandeaugtr { + display: none; + } +} + +tr.bandeaugtr { + /* background-color: rgb(17,51,85); */ + color: rgb(255, 215, 0); + /* font-style: italic; */ + font-weight: bold; + border-width: 0; + margin: 0; +} + +#authuser { + margin-top: 16px; +} + +#authuserlink { + color: rgb(255, 0, 0); + text-decoration: none; +} + +#authuserlink:hover { + text-decoration: underline; +} + +#deconnectlink { + font-size: 75%; + font-style: normal; + color: rgb(255, 0, 0); + text-decoration: underline; +} + +.navbar-default .navbar-nav>li.logout a { + color: rgb(255, 0, 0); +} + +/* ----- page content ------ */ + +div.about-logo { + text-align: center; + padding-top: 10px; +} + + +div.head_message { + margin-top: 2px; + margin-bottom: 8px; + padding: 5px; + margin-left: auto; + margin-right: auto; + background-color: rgba(255, 255, 115, 0.9); + -moz-border-radius: 8px; + -khtml-border-radius: 8px; + border-radius: 8px; + font-family: arial, verdana, sans-serif; + font-weight: bold; + width: 70%; + text-align: center; +} + +#sco_msg { + padding: 0px; + position: fixed; + top: 0px; + right: 0px; + color: green; +} + + +div.passwd_warn { + font-weight: bold; + font-size: 200%; + background-color: #feb199; + width: 80%; + height: 200px; + text-align: center; + padding: 20px; + margin-left: auto; + margin-right: auto; + margin-top: 10px; +} + +div.scovalueerror { + padding-left: 20px; + padding-bottom: 100px; +} + +p.footer { + font-size: 80%; + color: rgb(60, 60, 60); + margin-top: 15px; + border-top: 1px solid rgb(60, 60, 60); +} + +div.part2 { + margin-top: 3ex; +} + +/* ---- (left) SIDEBAR ----- */ + +div.sidebar { + position: absolute; + top: 5px; + left: 5px; + width: 130px; + border: black 1px; + /* debug background-color: rgb(245,245,245); */ + border-right: 1px solid rgb(210, 210, 210); +} + +@media print { + div.sidebar { + display: none; + } +} + +a.sidebar:link, +.sidebar a:link { + color: rgb(4, 16, 159); + text-decoration: none; +} + +a.sidebar:visited, +.sidebar a:visited { + color: rgb(4, 16, 159); + text-decoration: none; +} + +a.sidebar:hover, +.sidebar a:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + +a.scodoc_title { + color: rgb(102, 102, 102); + font-family: arial, verdana, sans-serif; + font-size: large; + font-weight: bold; + text-transform: uppercase; + text-decoration: none; +} + +h2.insidebar { + color: rgb(102, 102, 102); + font-weight: bold; + font-size: large; + margin-bottom: 0; +} + +h3.insidebar { + color: rgb(102, 102, 102); + font-weight: bold; + font-size: medium; + margin-bottom: 0; + margin-top: 0; +} + +ul.insidebar { + padding-left: 1em; + list-style: circle; +} + +div.box-chercheetud { + margin-top: 12px; +} + +/* Page accueil général */ +span.dept_full_name { + font-style: italic; +} + +span.dept_visible { + color: rgb(6, 158, 6); +} + +span.dept_cache { + color: rgb(194, 5, 5); +} + +div.table_etud_in_accessible_depts { + margin-left: 3em; + margin-bottom: 2em; +} + +div.table_etud_in_dept { + margin-bottom: 2em; +} + +div.table_etud_in_dept table.gt_table { + width: 600px; +} + +.etud-insidebar { + font-size: small; + background-color: rgb(220, 220, 220); + width: 100%; + -moz-border-radius: 6px; + -khtml-border-radius: 6px; + border-radius: 6px; +} + +.etud-insidebar h2 { + color: rgb(153, 51, 51); + font-size: medium; +} + + +.etud-insidebar ul { + padding-left: 1.5em; + margin-left: 0; +} + +div.logo-insidebar { + margin-left: 0px; + width: 75px; + /* la marge fait 130px */ +} + +div.logo-logo { + margin-left: -5px; + text-align: center; +} + +div.logo-logo img { + box-sizing: content-box; + margin-top: 10px; + /* -10px */ + width: 80px; + /* adapter suivant image */ + padding-right: 5px; +} + +div.sidebar-bottom { + margin-top: 10px; +} + +div.etud_info_div { + border: 2px solid gray; + height: 94px; + background-color: #f7f7ff; +} + +div.eid_left { + display: inline-block; + + padding: 2px; + border: 0px; + vertical-align: top; + margin-right: 100px; +} + +span.eid_right { + padding: 0px; + border: 0px; + position: absolute; + right: 2px; + top: 2px; +} + +div.eid_nom { + display: inline; + color: navy; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 120%; +} + +div.eid_nom div { + margin-top: 4px; +} + +div.eid_info { + margin-left: 2px; + margin-top: 3px; +} + +div.eid_bac { + margin-top: 5px; +} + +div.eid_bac span.eid_bac { + font-weight: bold; +} + +div.eid_parcours { + margin-top: 3px; +} + +.qtip-etud { + border-width: 0px; + margin: 0px; + padding: 0px; +} + +.qtip-etud .qtip-content { + padding: 0px 0px; +} + +table.listesems th { + text-align: left; + padding-top: 0.5em; + padding-left: 0.5em; +} + +table.listesems td { + vertical-align: center; +} + +table.listesems td.semicon { + padding-left: 1.5em; +} + +table.listesems tr.firstsem td { + padding-top: 0.8em; +} + +td.datesem { + font-size: 80%; + white-space: nowrap; +} + +h2.listesems { + padding-top: 10px; + padding-bottom: 0px; + margin-bottom: 0px; +} + +/* table.semlist tr.gt_firstrow th {} */ + +table.semlist tr td { + border: none; +} + +table.semlist tbody tr a.stdlink, +table.semlist tbody tr a.stdlink:visited { + color: navy; + text-decoration: none; +} + +table.semlist tr a.stdlink:hover { + color: red; + text-decoration: underline; +} + +table.semlist tr td.semestre_id { + text-align: right; +} + +table.semlist tbody tr td.modalite { + text-align: left; + padding-right: 1em; +} + +/***************************/ +/* Statut des cellules */ +/***************************/ +.sco_selected { + outline: 1px solid #c09; +} + +.sco_modifying { + outline: 2px dashed #c09; + background-color: white !important; +} + +.sco_wait { + outline: 2px solid #c90; +} + +.sco_good { + outline: 2px solid #9c0; +} + +.sco_modified { + font-weight: bold; + color: indigo +} + +/***************************/ +/* Message */ +/***************************/ +.message { + position: fixed; + bottom: 100%; + left: 50%; + z-index: 10; + padding: 20px; + border-radius: 0 0 10px 10px; + background: #ec7068; + background: #90c; + color: #FFF; + font-size: 24px; + animation: message 3s; + transform: translate(-50%, 0); +} + +@keyframes message { + 20% { + transform: translate(-50%, 100%) + } + + 80% { + transform: translate(-50%, 100%) + } +} + + +div#gtrcontent table.semlist tbody tr.css_S-1 td { + background-color: rgb(251, 250, 216); +} + +div#gtrcontent table.semlist tbody tr.css_S1 td { + background-color: rgb(92%, 95%, 94%); +} + +div#gtrcontent table.semlist tbody tr.css_S2 td { + background-color: rgb(214, 223, 236); +} + +div#gtrcontent table.semlist tbody tr.css_S3 td { + background-color: rgb(167, 216, 201); +} + +div#gtrcontent table.semlist tbody tr.css_S4 td { + background-color: rgb(131, 225, 140); +} + +div#gtrcontent table.semlist tbody tr.css_MEXT td { + color: #0b6e08; +} + +/* ----- Liste des news ----- */ + +div.news { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 10pt; + margin-top: 1em; + margin-bottom: 0px; + margin-right: 16px; + margin-left: 16px; + padding: 0.5em; + background-color: rgb(255, 235, 170); + -moz-border-radius: 8px; + -khtml-border-radius: 8px; + border-radius: 8px; +} + +div.news a { + color: black; + text-decoration: none; +} + +div.news a:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + +span.newstitle { + font-weight: bold; +} + +ul.newslist { + padding-left: 1em; + padding-bottom: 0em; + list-style: circle; +} + +span.newsdate { + padding-right: 2em; + font-family: monospace; +} + +span.newstext { + font-style: normal; +} + + +span.gt_export_icons { + margin-left: 1.5em; +} + +/* --- infos sur premiere page Sco --- */ +div.scoinfos { + margin-top: 0.5em; + margin-bottom: 0px; + padding: 2px; + padding-bottom: 0px; + background-color: #F4F4B2; +} + +/* ----- fiches etudiants ------ */ + +div.ficheEtud { + background-color: #f5edc8; + /* rgb(255,240,128); */ + border: 1px solid gray; + width: 910px; + padding: 10px; + margin-top: 10px; +} + +div.menus_etud { + position: absolute; + margin-left: 1px; + margin-top: 1px; +} + +div.ficheEtud h2 { + padding-top: 10px; +} + +div.code_nip { + padding-top: 10px; + font-family: "Andale Mono", "Courier"; +} + +div.fichesituation { + background-color: rgb(231, 234, 218); + /* E7EADA */ + margin: 0.5em 0 0.5em 0; + padding: 0.5em; + -moz-border-radius: 8px; + -khtml-border-radius: 8px; + border-radius: 8px; +} + +div.ficheadmission { + background-color: rgb(231, 234, 218); + /* E7EADA */ + + margin: 0.5em 0 0.5em 0; + padding: 0.5em; + -moz-border-radius: 8px; + -khtml-border-radius: 8px; + border-radius: 8px; +} + +div#adm_table_description_format table.gt_table td { + font-size: 80%; +} + +div.ficheadmission div.note_rapporteur { + font-size: 80%; + font-style: italic; +} + +div.etudarchive ul { + padding: 0; + margin: 0; + margin-left: 1em; + list-style-type: none; +} + +div.etudarchive ul li { + background-image: url(/ScoDoc/static/icons/bullet_arrow.png); + background-repeat: no-repeat; + background-position: 0 .4em; + padding-left: .6em; +} + +div.etudarchive ul li.addetudarchive { + background-image: url(/ScoDoc/static/icons/bullet_plus.png); + padding-left: 1.2em +} + +span.etudarchive_descr { + margin-right: .4em; +} + +span.deletudarchive { + margin-left: 0.5em; +} + +div#fichedebouche { + background-color: rgb(183, 227, 254); + /* bleu clair */ + color: navy; + width: 910px; + margin: 0.5em 0 0.5em 0; + padding: 0.5em; + -moz-border-radius: 8px; + -khtml-border-radius: 8px; + border-radius: 8px; +} + +div#fichedebouche .ui-accordion-content { + background-color: rgb(183, 227, 254); + /* bleu clair */ + padding: 0px 10px 0px 0px; +} + +span.debouche_tit { + font-weight: bold; + padding-right: 1em; +} + +/* li.itemsuivi {} */ + +span.itemsuivi_tag_edit { + border: 2px; +} + +.listdebouches .itemsuivi_tag_edit .tag-editor { + background-color: rgb(183, 227, 254); + border: 0px; +} + +.itemsuivi_tag_edit ul.tag-editor { + display: inline-block; + width: 100%; +} + +/* .itemsuivi_tag_edit ul.tag-editor li {} */ + +.itemsuivi_tag_edit .tag-editor-delete { + height: 20px; +} + +.itemsuivi_suppress { + float: right; + padding-top: 9px; + padding-right: 5px; +} + +div.itemsituation { + background-color: rgb(224, 234, 241); + /* height: 2em;*/ + border: 1px solid rgb(204, 204, 204); + -moz-border-radius: 4px; + -khtml-border-radius: 4px; + border-radius: 4px; + padding-top: 1px; + padding-bottom: 1px; + padding-left: 10px; + padding-right: 10px; +} + +div.itemsituation em { + color: #bbb; +} + +/* tags readonly */ +span.ro_tag { + display: inline-block; + background-color: rgb(224, 234, 241); + color: #46799b; + margin-top: 3px; + margin-left: 5px; + margin-right: 3px; + padding-left: 3px; + padding-right: 3px; + border: 1px solid rgb(204, 204, 204); +} + +div.ficheinscriptions { + background-color: #eae3e2; + /* was EADDDA */ + margin: 0.5em 0 0.5em 0; + padding: 0.5em; + -moz-border-radius: 8px; + -khtml-border-radius: 8px; + border-radius: 8px; + overflow-x: scroll; +} + +.ficheinscriptions a.sem { + text-decoration: none; + font-weight: bold; + color: blue; +} + +.ficheinscriptions a.sem:hover { + color: red; +} + + +td.photocell { + padding-left: 32px; +} + +div.fichetitre { + font-weight: bold; +} + +span.etud_type_admission { + color: rgb(0, 0, 128); + font-style: normal; +} + +td.fichetitre2 { + font-weight: bold; + vertical-align: top; +} + +td.fichetitre2 .formula { + font-weight: normal; + color: rgb(0, 64, 0); + border: 1px solid red; + padding-left: 1em; + padding-right: 1em; + padding-top: 3px; + padding-bottom: 3px; + margin-right: 1em; +} + +span.formula { + font-size: 80%; + font-family: Courier, monospace; + font-weight: normal; +} + +td.fichetitre2 .fl { + font-weight: normal; +} + +.ficheannotations { + background-color: #f7d892; + width: 910px; + + margin: 0.5em 0 0.5em 0; + padding: 0.5em; + -moz-border-radius: 8px; + -khtml-border-radius: 8px; + border-radius: 8px; +} + +.ficheannotations table#etudannotations { + width: 100%; + border-collapse: collapse; +} + +.ficheannotations table#etudannotations tr:nth-child(odd) { + background: rgb(240, 240, 240); +} + +.ficheannotations table#etudannotations tr:nth-child(even) { + background: rgb(230, 230, 230); +} + +.ficheannotations span.annodate { + color: rgb(200, 50, 50); + font-size: 80%; +} + +.ficheannotations span.annoc { + color: navy; +} + +.ficheannotations td.annodel { + text-align: right; +} + +span.link_bul_pdf { + font-size: 80%; + padding-right: 2em; +} + +/* Page accueil Sco */ +span.infostitresem { + font-weight: normal; +} + +span.linktitresem { + font-weight: normal; +} + +span.linktitresem a:link { + color: red; +} + +span.linktitresem a:visited { + color: red; +} + +.listegroupelink a:link { + color: blue; +} + +.listegroupelink a:visited { + color: blue; +} + +.listegroupelink a:hover { + color: red; +} + +a.stdlink, +a.stdlink:visited { + color: blue; + text-decoration: underline; +} + +a.stdlink:hover { + color: red; + text-decoration: underline; +} + +/* a.link_accessible {} */ +a.link_unauthorized, +a.link_unauthorized:visited { + color: rgb(75, 75, 75); +} + +span.spanlink { + color: rgb(0, 0, 255); + text-decoration: underline; +} + +span.spanlink:hover { + color: red; +} + +/* Trombinoscope */ + +.trombi_legend { + font-size: 80%; + margin-bottom: 3px; + -ms-word-break: break-all; + word-break: break-all; + /* non std for webkit: */ + word-break: break-word; + -webkit-hyphens: auto; + -moz-hyphens: auto; + hyphens: auto; +} + +.trombi_box { + display: inline-block; + width: 110px; + vertical-align: top; + margin-left: 5px; + margin-top: 5px; +} + +span.trombi_legend { + display: inline-block; +} + +span.trombi-photo { + display: inline-block; +} + +span.trombi_box a { + display: inline-block; +} + +span.trombi_box a img { + display: inline-block; +} + +.trombi_nom { + display: block; + padding-top: 0px; + padding-bottom: 0px; + margin-top: -5px; + margin-bottom: 0px; +} + +.trombi_prenom { + display: inline-block; + padding-top: 0px; + padding-bottom: 0px; + margin-top: -2px; + margin-bottom: 0px; +} + + +/* markup non semantique pour les cas simples */ + +.fontred { + color: red; +} + +.fontorange { + color: rgb(215, 90, 0); +} + +.fontitalic { + font-style: italic; +} + +.redboldtext { + font-weight: bold; + color: red; +} + +.greenboldtext { + font-weight: bold; + color: green; +} + +a.redlink { + color: red; +} + +a.redlink:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + +a.discretelink { + color: black; + text-decoration: none; +} + +a.discretelink:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + +.rightcell { + text-align: right; +} + +.rightjust { + padding-left: 2em; +} + +.centercell { + text-align: center; +} + +.help { + font-style: italic; +} + +.help_important { + font-style: italic; + color: red; +} + +div.sco_help { + margin-top: 12px; + margin-bottom: 4px; + padding: 8px; + border-radius: 4px; + font-style: italic; + background-color: rgb(200, 200, 220); +} + +div.vertical_spacing_but { + margin-top: 12px; +} + +span.wtf-field ul.errors li { + color: red; +} + +#bonus_description { + color: rgb(6, 73, 6); + padding: 5px; + margin-top: 5px; + border: 2px solid blue; + border-radius: 5px; + background-color: cornsilk; +} + +#bonus_description div.bonus_description_head { + font-weight: bold; +} + +.configuration_logo summary { + display: list-item !important; +} + +.configuration_logo entete_dept { + display: inline-block; +} + +.configuration_logo .effectifs { + float: right; +} + +.configuration_logo h1 { + display: inline-block; +} + +.configuration_logo h2 { + display: inline-block; +} + +.configuration_logo h3 { + display: inline-block; +} + +.configuration_logo details>*:not(summary) { + margin-left: 32px; +} + +.configuration_logo .content { + display: grid; + grid-template-columns: auto auto 1fr; +} + +.configuration_logo .image_logo { + vertical-align: top; + grid-column: 1/2; + width: 256px; +} + +.configuration_logo div.img-container img { + max-width: 100%; +} + +.configuration_logo .infos_logo { + grid-column: 2/3; +} + +.configuration_logo .actions_logo { + grid-column: 3/5; + display: grid; + grid-template-columns: auto auto; + grid-column-gap: 10px; + align-self: start; + grid-row-gap: 10px; +} + +.configuration_logo .actions_logo .action_label { + grid-column: 1/2; + grid-template-columns: auto auto; +} + +.configuration_logo .actions_logo .action_button { + grid-column: 2/3; + align-self: start; +} + +.configuration_logo logo-edit titre { + background-color: lightblue; +} + +.configuration_logo logo-edit nom { + float: left; + vertical-align: baseline; +} + +.configuration_logo logo-edit description { + float: right; + vertical-align: baseline; +} + +p.indent { + padding-left: 2em; +} + +.blacktt { + font-family: Courier, monospace; + font-weight: normal; + color: black; +} + +p.msg { + color: red; + font-weight: bold; + border: 1px solid blue; + background-color: rgb(140, 230, 250); + padding: 10px; +} + +table.tablegrid { + border-color: black; + border-width: 0 0 1px 1px; + border-style: solid; + border-collapse: collapse; +} + +table.tablegrid td, +table.tablegrid th { + border-color: black; + border-width: 1px 1px 0 0; + border-style: solid; + margin: 0; + padding-left: 4px; + padding-right: 4px; +} + +/* ----- Notes ------ */ +a.smallbutton { + border-width: 0; + margin: 0; + margin-left: 2px; + text-decoration: none; +} + +span.evallink { + font-size: 80%; + font-weight: normal; +} + +.boldredmsg { + color: red; + font-weight: bold; +} + +tr.etuddem td { + color: rgb(100, 100, 100); + font-style: italic; +} + +td.etudabs, +td.etudabs a.discretelink, +tr.etudabs td.moyenne a.discretelink { + color: rgb(195, 0, 0); +} + +tr.moyenne td { + font-weight: bold; +} + +table.notes_evaluation th.eval_complete { + color: rgb(6, 90, 6); +} + +table.notes_evaluation th.eval_incomplete { + color: red; + width: 80px; + font-size: 80%; +} + +table.notes_evaluation td.eval_incomplete>a { + font-size: 80%; + color: rgb(166, 50, 159); +} + +table.notes_evaluation th.eval_attente { + color: rgb(215, 90, 0); + width: 80px; +} + +table.notes_evaluation td.att a { + color: rgb(255, 0, 217); + font-weight: bold; +} + +table.notes_evaluation td.exc a { + font-style: italic; + color: rgb(0, 131, 0); +} + + +table.notes_evaluation tr td a.discretelink:hover { + text-decoration: none; +} + +table.notes_evaluation tr td.tdlink a.discretelink:hover { + color: red; + text-decoration: underline; +} + +table.notes_evaluation tr td.tdlink a.discretelink, +table.notes_evaluation tr td.tdlink a.discretelink:visited { + color: blue; + text-decoration: underline; +} + +table.notes_evaluation tr td { + padding-left: 0.5em; + padding-right: 0.5em; +} + +div.notes_evaluation_stats { + margin-top: -15px; +} + +span.eval_title { + font-weight: bold; + font-size: 14pt; +} + +/* #saisie_notes span.eval_title { + border-bottom: 1px solid rgb(100,100,100); +} +*/ + +span.jurylink { + margin-left: 1.5em; +} + +span.jurylink a { + color: red; + text-decoration: underline; +} + +div.jury_footer { + display: flex; + justify-content: space-evenly; +} + +div.jury_footer>span { + border: 2px solid rgb(90, 90, 90); + border-radius: 4px; + padding: 4px; + background-color: rgb(230, 242, 230); +} + +.eval_description p { + margin-left: 15px; + margin-bottom: 2px; + margin-top: 0px; +} + +.eval_description span.resp { + font-weight: normal; +} + +.eval_description span.resp a { + font-weight: normal; +} + +.eval_description span.eval_malus { + font-weight: bold; + color: red; +} + + +span.eval_info { + font-style: italic; +} + +span.eval_complete { + color: green; +} + +span.eval_incomplete { + color: red; +} + +span.eval_attente { + color: rgb(215, 90, 0); +} + +table.tablenote { + border-collapse: collapse; + border: 2px solid blue; + /* width: 100%;*/ + margin-bottom: 20px; + margin-right: 20px; +} + +table.tablenote th { + padding-left: 1em; +} + +.tablenote a { + text-decoration: none; + color: black; +} + +.tablenote a:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + +table.tablenote_anonyme { + border-collapse: collapse; + border: 2px solid blue; +} + +tr.tablenote { + border: solid blue 1px; +} + +td.colnote { + text-align: right; + padding-right: 0.5em; + border: solid blue 1px; +} + +td.colnotemoy { + text-align: right; + padding-right: 0.5em; + font-weight: bold; +} + +td.colcomment, +span.colcomment { + text-align: left; + padding-left: 2em; + font-style: italic; + color: rgb(80, 100, 80); +} + +table.notes_evaluation table.eval_poids { + font-size: 50%; +} + +table.notes_evaluation td.moy_ue { + font-weight: bold; + color: rgb(1, 116, 96); +} + +td.coef_mod_ue { + font-style: normal; + font-weight: bold; + color: rgb(1, 116, 96); +} + +td.coef_mod_ue_non_conforme { + font-style: normal; + font-weight: bold; + color: red; + background-color: yellow; +} + +h2.formsemestre, +#gtrcontent h2 { + margin-top: 2px; + font-size: 130%; +} + +.formsemestre_page_title table.semtitle, +.formsemestre_page_title table.semtitle td { + padding: 0px; + margin-top: 0px; + margin-bottom: 0px; + border-width: 0; + border-collapse: collapse; +} + +.formsemestre_page_title { + width: 100%; + padding-top: 5px; + padding-bottom: 10px; +} + +.formsemestre_page_title table.semtitle td.infos table { + padding-top: 10px; +} + +.formsemestre_page_title a { + color: black; +} + +.formsemestre_page_title .eye, +formsemestre_page_title .eye img { + display: inline-block; + vertical-align: middle; + margin-bottom: 2px; +} + +.formsemestre_page_title .infos span.lock, +formsemestre_page_title .lock img { + display: inline-block; + vertical-align: middle; + margin-bottom: 5px; + padding-right: 5px; +} + +#formnotes .tf-explanation { + font-size: 80%; +} + +#formnotes .tf-explanation .sn_abs { + color: red; +} + +#formnotes .tf-ro-fieldlabel.formnote_bareme { + text-align: right; + font-weight: bold; +} + +#formnotes td.tf-ro-fieldlabel:after { + content: ''; +} + +#formnotes .tf-ro-field.formnote_bareme { + font-weight: bold; +} + +#formnotes td.tf-fieldlabel { + border-bottom: 1px dotted #fdcaca; +} + +.wtf-field li { + display: inline; +} + +.wtf-field ul { + padding-left: 0; +} + +.wtf-field .errors { + color: red; + font-weight: bold; +} + +/* +.formsemestre_menubar { + border-top: 3px solid #67A7E3; + background-color: #D6E9F8; + margin-top: 8px; +} + +.formsemestre_menubar .barrenav ul li a.menu { + font-size: 12px; +} +*/ +/* Barre menu semestre */ +#sco_menu { + overflow: hidden; + background: rgb(214, 233, 248); + border-top-color: rgb(103, 167, 227); + border-top-style: solid; + border-top-width: 3px; + margin-top: 5px; + margin-right: 3px; + margin-left: -1px; +} + +#sco_menu>li { + float: left; + width: auto; + /* 120px !important; */ + font-size: 12px; + font-family: Arial, Helvetica, sans-serif; + text-transform: uppercase; +} + +#sco_menu>li li { + text-transform: none; + font-size: 14px; + font-family: Arial, Helvetica, sans-serif; +} + +#sco_menu>li>a { + font-weight: bold !important; + padding-left: 15px; + padding-right: 15px; +} + +#sco_menu>li>a.ui-menu-item, +#sco_menu>li>a.ui-menu-item:visited { + text-decoration: none; +} + +#sco_menu ul .ui-menu { + width: 200px; +} + +.sco_dropdown_menu>li { + width: auto; + /* 120px !important; */ + font-size: 12px; + font-family: Arial, Helvetica, sans-serif; +} + +span.inscr_addremove_menu { + width: 150px; +} + +.formsemestre_page_title .infos span { + padding-right: 25px; +} + +.formsemestre_page_title span.semtitle { + font-size: 12pt; +} + +.formsemestre_page_title span.resp, +span.resp a { + color: red; + font-weight: bold; +} + +.formsemestre_page_title span.nbinscrits { + text-align: right; + font-weight: bold; + padding-right: 1px; +} + +div.formsemestre_status { + -moz-border-radius: 8px; + -khtml-border-radius: 8px; + border-radius: 8px; + padding: 2px 6px 2px 16px; + margin-right: 10px; +} + +table.formsemestre_status { + border-collapse: collapse; +} + +tr.formsemestre_status { + background-color: rgb(90%, 90%, 90%); +} + +tr.formsemestre_status_green { + background-color: #EFF7F2; +} + +tr.formsemestre_status_ue { + background-color: rgb(90%, 90%, 90%); +} + +tr.formsemestre_status_cat td { + padding-top: 2ex; +} + +table.formsemestre_status td { + border-top: 1px solid rgb(80%, 80%, 80%); + border-bottom: 1px solid rgb(80%, 80%, 80%); + border-left: 0px; +} + +table.formsemestre_status td.evals, +table.formsemestre_status th.evals, +table.formsemestre_status td.resp, +table.formsemestre_status th.resp, +table.formsemestre_status td.malus { + padding-left: 1em; +} + +table.formsemestre_status th { + font-weight: bold; + text-align: left; +} + +th.formsemestre_status_inscrits { + font-weight: bold; + text-align: center; +} + +td.formsemestre_status_code { + /* width: 2em; */ + padding-right: 1em; +} + +table.formsemestre_status td.malus a { + color: red; +} + +a.formsemestre_status_link { + text-decoration: none; + color: black; +} + +a.formsemestre_status_link:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + +td.formsemestre_status_inscrits { + text-align: center; +} + +td.formsemestre_status_cell { + white-space: nowrap; +} + +span.mod_coef_indicator, +span.ue_color_indicator { + display: inline-block; + box-sizing: border-box; + width: 10px; + height: 10px; +} + +span.mod_coef_indicator_zero { + display: inline-block; + box-sizing: border-box; + width: 10px; + height: 10px; + border: 1px solid rgb(156, 156, 156); +} + + +span.status_ue_acro { + font-weight: bold; +} + +span.status_ue_title { + font-style: italic; + padding-left: 1cm; +} + +span.status_module_cat { + font-weight: bold; +} + +table.formsemestre_inscr td { + padding-right: 1.25em; +} + +ul.ue_inscr_list li span.tit { + font-weight: bold; +} + +ul.ue_inscr_list li.tit { + padding-top: 1ex; +} + +ul.ue_inscr_list li.etud { + padding-top: 0.7ex; +} + +/* Liste des groupes sur tableau bord semestre */ +.formsemestre_status h3 { + border: 0px solid black; + margin-bottom: 5px; +} + +#grouplists h4 { + font-style: italic; + margin-bottom: 0px; + margin-top: 5px; +} + +#grouplists table { + /*border: 1px solid black;*/ + border-spacing: 1px; +} + +/* Tableau de bord module */ +div.moduleimpl_tableaubord { + padding: 7px; + border: 2px solid gray; +} + +div.moduleimpl_type_sae { + background-color: #cfeccf; +} + +div.moduleimpl_type_ressource { + background-color: #f5e9d2; +} + +div#modimpl_coefs { + position: absolute; + border: 1px solid; + padding-top: 3px; + padding-left: 3px; + padding-right: 5px; + background-color: #d3d3d378; +} + +.coefs_histo { + height: 32px; + display: flex; + gap: 4px; + color: rgb(0, 0, 0); + text-align: center; + align-items: flex-end; + font-weight: normal; + font-size: 60%; +} + +.coefs_histo>div { + --height: calc(32px * var(--coef) / var(--max)); + height: var(--height); + padding: var(--height) 4px 0 4px; + background: #09c; + box-sizing: border-box; +} + +.coefs_histo>div:nth-child(odd) { + background-color: #9c0; +} + +span.moduleimpl_abs_link { + padding-right: 2em; +} + +.moduleimpl_evaluations_top_links { + font-size: 80%; + margin-bottom: 3px; +} + +table.moduleimpl_evaluations { + width: 100%; + border-spacing: 0px; +} + +th.moduleimpl_evaluations { + font-weight: normal; + text-align: left; + color: rgb(0, 0, 128); +} + +th.moduleimpl_evaluations a, +th.moduleimpl_evaluations a:visited { + font-weight: normal; + color: red; + text-decoration: none; +} + +th.moduleimpl_evaluations a:hover { + text-decoration: underline; +} + +tr.mievr { + background-color: #eeeeee; +} + +tr.mievr_rattr { + background-color: #dddddd; +} + +span.mievr_rattr { + display: inline-block; + font-weight: bold; + font-size: 80%; + color: white; + background-color: orangered; + margin-left: 2em; + margin-top: 1px; + margin-bottom: 2px; + ; + border: 1px solid red; + padding: 1px 3px 1px 3px; +} + +tr.mievr td.mievr_tit { + font-weight: bold; + background-color: #cccccc; +} + +tr.mievr td { + text-align: left; + background-color: white; +} + +tr.mievr th { + background-color: white; +} + +tr.mievr td.mievr { + width: 90px; +} + +tr.mievr td.mievr_menu { + width: 110px; +} + +tr.mievr td.mievr_dur { + width: 60px; +} + +tr.mievr td.mievr_coef { + width: 60px; +} + +tr.mievr td.mievr_nbnotes { + width: 90px; +} + +tr td.mievr_grtit { + vertical-align: top; + text-align: right; + font-weight: bold; +} + +span.mievr_lastmodif { + padding-left: 2em; + font-weight: normal; + font-style: italic; +} + +a.mievr_evalnodate { + color: rgb(215, 90, 0); + font-style: italic; + text-decoration: none; +} + +a.mievr_evalnodate:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + +span.evalindex_cont { + float: right; +} + +span.evalindex { + font-weight: normal; + font-size: 80%; + margin-right: 5px; +} + +.eval_arrows_chld { + margin-right: 3px; + margin-top: 3px; +} + +.eval_arrows_chld a { + margin-left: 3px; +} + +table.moduleimpl_evaluations td.eval_poids { + color: rgb(0, 0, 255); +} + +span.eval_coef_ue { + color: rgb(6, 73, 6); + font-style: normal; + font-size: 80%; + margin-right: 2em; +} + +span.eval_coef_ue_titre {} + +/* Formulaire edition des partitions */ +form#editpart table { + border: 1px solid gray; + border-collapse: collapse; +} + +form#editpart tr.eptit th { + font-size: 110%; + border-bottom: 1px solid gray; +} + +form#editpart td { + border-bottom: 1px dashed gray; +} + +form#editpart table td { + padding-left: 1em; +} + +form#editpart table td.epnav { + padding-left: 0; +} + +/* Liste des formations */ +ul.notes_formation_list { + list-style-type: none; + font-size: 110%; +} + +li.notes_formation_list { + padding-top: 10px; +} + +table.formation_list_table { + width: 100%; + border-collapse: collapse; + background-color: rgb(0%, 90%, 90%); +} + +table#formation_list_table tr.gt_hl { + background-color: rgb(96%, 96%, 96%); +} + +.formation_list_table img.delete_small_img { + width: 16px; + height: 16px; +} + +.formation_list_table td.acronyme { + width: 10%; + font-weight: bold; +} + +.formation_list_table td.formation_code { + font-family: Courier, monospace; + font-weight: normal; + color: black; + font-size: 100%; +} + +.formation_list_table td.version { + text-align: center; +} + +.formation_list_table td.titre { + width: 50%; +} + +.formation_list_table td.sems_list_txt { + font-size: 90%; +} + +/* Presentation formation (ue_list) */ +div.formation_descr { + background-color: rgb(250, 250, 240); + border: 1px solid rgb(128, 128, 128); + padding-left: 5px; + padding-bottom: 5px; + margin-right: 12px; +} + +div.formation_descr span.fd_t { + font-weight: bold; + margin-right: 5px; +} + +div.formation_descr span.fd_n { + font-weight: bold; + font-style: italic; + color: green; + margin-left: 6em; +} + +div.formation_ue_list { + border: 1px solid black; + margin-top: 5px; + margin-right: 12px; + padding-left: 5px; +} + +div.formation_list_ues_titre { + padding-left: 24px; + padding-right: 24px; + font-size: 120%; + font-weight: bold; +} + +div.formation_list_modules, +div.formation_list_ues { + border-radius: 18px; + margin-left: 10px; + margin-right: 10px; + margin-bottom: 10px; + padding-bottom: 1px; +} + +div.formation_list_ues { + background-color: #b7d2fa; + margin-top: 20px +} + +div.formation_list_modules { + margin-top: 20px; +} + +div.formation_list_modules_RESSOURCE { + background-color: #f8c844; +} + +div.formation_list_modules_SAE { + background-color: #c6ffab; +} + +div.formation_list_modules_STANDARD { + background-color: #afafc2; +} + +div.formation_list_modules_titre { + padding-left: 24px; + padding-right: 24px; + font-weight: bold; + font-size: 120%; +} + +div.formation_list_ues ul.notes_module_list { + margin-top: 0px; + margin-bottom: -1px; + padding-top: 5px; + padding-bottom: 5px; +} + +div.formation_list_modules ul.notes_module_list { + margin-top: 0px; + margin-bottom: -1px; + padding-top: 5px; + padding-bottom: 5px; +} + +span.missing_ue_ects { + color: red; + font-weight: bold; +} + +li.module_malus span.formation_module_tit { + color: red; + font-weight: bold; + text-decoration: underline; +} + +span.formation_module_ue { + background-color: #b7d2fa; +} + +span.notes_module_list_buts { + margin-right: 5px; +} + +.formation_apc_infos ul li:not(:last-child) { + margin-bottom: 6px; +} + +div.ue_list_tit { + font-weight: bold; + margin-top: 5px; +} + +ul.apc_ue_list { + background-color: rgba(180, 189, 191, 0.14); + margin-left: 8px; + margin-right: 8px; +} + +ul.notes_ue_list { + margin-top: 4px; + margin-right: 1em; + margin-left: 1em; + /* padding-top: 1em; */ + padding-bottom: 1em; + font-weight: bold; +} + +.formation_classic_infos ul.notes_ue_list { + padding-top: 0px; +} + +.formation_classic_infos li.notes_ue_list { + margin-top: 9px; + list-style-type: none; + border: 1px solid maroon; + border-radius: 10px; + padding-bottom: 5px; +} + +span.ue_type_1 { + color: green; + font-weight: bold; +} + +span.ue_code { + font-family: Courier, monospace; + font-weight: normal; + color: black; + font-size: 80%; +} + +span.ue_type { + color: green; + margin-left: 1.5em; + margin-right: 1.5em; +} + +ul.notes_module_list span.ue_coefs_list { + color: blue; + font-size: 70%; +} + +div.formation_ue_list_externes { + background-color: #98cc98; +} + +div.formation_ue_list_externes ul.notes_ue_list, +div.formation_ue_list_externes li.notes_ue_list { + background-color: #98cc98; +} + +span.ue_is_external span { + color: orange; +} + +span.ue_is_external a { + font-weight: normal; +} + +li.notes_matiere_list { + margin-top: 2px; +} + +ul.notes_matiere_list { + background-color: rgb(220, 220, 220); + font-weight: normal; + font-style: italic; + border-top: 1px solid maroon; +} + +ul.notes_module_list { + background-color: rgb(210, 210, 210); + font-weight: normal; + font-style: normal; +} + +div.ue_list_div { + border: 3px solid rgb(35, 0, 160); + padding-left: 5px; + padding-top: 5px; + margin-bottom: 5px; + margin-right: 5px; +} + +div.ue_list_tit_sem { + font-size: 120%; + font-weight: bold; + color: orangered; + display: list-item; + /* This has to be "list-item" */ + list-style-type: disc; + /* See https://developer.mozilla.org/en-US/docs/Web/CSS/list-style-type */ + list-style-position: inside; +} + +input.sco_tag_checkbox { + margin-bottom: 10px; +} + +.notes_ue_list a.stdlink { + color: #001084; + text-decoration: underline; +} + +.notes_ue_list span.locked { + font-weight: normal; +} + +.notes_ue_list a.smallbutton img { + position: relative; + top: 2px; +} + +div#ue_list_code { + background-color: rgb(155, 218, 155); + padding: 10px; + border: 1px solid blue; + border-radius: 10px; + padding: 10px; + margin-top: 10px; + margin-right: 15px; +} + +ul.notes_module_list { + list-style-type: none; +} + +/*Choix niveau dans form edit UE */ +div.ue_choix_niveau { + background-color: rgb(191, 242, 255); + border: 1px solid blue; + border-radius: 10px; + padding: 10px; + margin-top: 10px; + margin-right: 15px; +} + +/* Choix niveau dans edition programme (ue_table) */ +div.formation_list_ues div.ue_choix_niveau { + margin-left: 64px; + margin-right: 64px; + margin-top: 2px; + padding: 4px; + font-size: 14px; +} + +div.formation_list_ues div.ue_choix_niveau b { + font-weight: normal; +} + +div#ue_list_modules { + background-color: rgb(251, 225, 165); + border: 1px solid blue; + border-radius: 10px; + padding: 10px; + margin-top: 10px; + margin-right: 15px; +} + +div#ue_list_etud_validations { + background-color: rgb(220, 250, 220); + padding-left: 4px; + padding-bottom: 1px; + margin: 3ex; +} + +div#ue_list_etud_validations span { + font-weight: bold; +} + +span.ue_share { + font-weight: bold; +} + +div.ue_warning { + border: 1px solid red; + border-radius: 10px; + background-color: rgb(250, 220, 220); + margin-top: 10px; + margin-right: 15px; + margin-bottom: 10px; + padding: 10px; +} + +div.ue_warning:first-child { + font-weight: bold; +} + +div.ue_warning span:before { + content: url(/ScoDoc/static/icons/warning_img.png); + vertical-align: -80%; +} + +div.ue_warning span { + font-weight: bold; +} + +span.missing_value { + font-weight: bold; + color: red; +} + +span.code_parcours { + color: white; + background-color: rgb(254, 95, 246); + padding-left: 4px; + padding-right: 4px; + border-radius: 2px; +} + +tr#tf_module_parcours>td { + background-color: rgb(229, 229, 229); +} + +tr#tf_module_app_critiques>td { + background-color: rgb(194, 209, 228); +} + +/* tableau recap notes */ +table.notes_recapcomplet { + border: 2px solid blue; + border-spacing: 0px 0px; + border-collapse: collapse; + white-space: nowrap; +} + +tr.recap_row_even { + background-color: rgb(210, 210, 210); +} + +@media print { + tr.recap_row_even { + /* bordures noires pour impression */ + border-top: 1px solid black; + border-bottom: 1px solid black; + } +} + +tr.recap_row_min { + border-top: 1px solid blue; +} + +tr.recap_row_min, +tr.recap_row_max { + font-weight: normal; + font-style: italic; +} + +tr.recap_row_moy { + font-weight: bold; +} + +tr.recap_row_nbeval { + color: green; +} + +tr.recap_row_ects { + color: rgb(160, 86, 3); + border-bottom: 1px solid blue; +} + +td.recap_tit { + font-weight: bold; + text-align: left; + padding-right: 1.2em; +} + +td.recap_tit_ue { + font-weight: bold; + text-align: left; + padding-right: 1.2em; + padding-left: 2px; + border-left: 1px solid blue; +} + +td.recap_col { + padding-right: 1.2em; + text-align: left; +} + +td.recap_col_moy { + padding-right: 1.5em; + text-align: left; + font-weight: bold; + color: rgb(80, 0, 0); +} + +td.recap_col_moy_inf { + padding-right: 1.5em; + text-align: left; + font-weight: bold; + color: rgb(255, 0, 0); +} + +td.recap_col_ue { + padding-right: 1.2em; + padding-left: 4px; + text-align: left; + font-weight: bold; + border-left: 1px solid blue; +} + +td.recap_col_ue_inf { + padding-right: 1.2em; + padding-left: 4px; + text-align: left; + color: rgb(255, 0, 0); + border-left: 1px solid blue; +} + +td.recap_col_ue_val { + padding-right: 1.2em; + padding-left: 4px; + text-align: left; + color: rgb(0, 140, 0); + border-left: 1px solid blue; +} + +/* noms des etudiants sur recap complet */ +table.notes_recapcomplet a:link, +table.notes_recapcomplet a:visited { + text-decoration: none; + color: black; +} + +table.notes_recapcomplet a:hover { + color: red; + text-decoration: underline; +} + +/* bulletin */ +div.notes_bulletin { + margin-right: 5px; +} + +div.bull_head { + display: grid; + justify-content: space-between; + grid-template-columns: auto auto; +} + +div.bull_photo { + display: inline-block; + margin-right: 10px; +} + +span.bulletin_menubar_but { + display: inline-block; + margin-left: 2em; + margin-right: 2em; +} + +table.notes_bulletin { + border-collapse: collapse; + border: 2px solid rgb(100, 100, 240); + width: 100%; + margin-right: 100px; + background-color: rgb(240, 250, 255); + font-family: arial, verdana, sans-serif; + font-size: 13px; +} + +tr.notes_bulletin_row_gen { + border-top: 1px solid black; + font-weight: bold; +} + +tr.notes_bulletin_row_rang { + font-weight: bold; +} + +tr.notes_bulletin_row_ue { + /* background-color: rgb(170,187,204); voir sco_utils.UE_COLORS */ + font-weight: bold; + border-top: 1px solid black; +} + +tr.bul_row_ue_cur { + background-color: rgb(180, 180, 180); +} + +tr.bul_row_ue_cap { + background-color: rgb(150, 170, 200); + color: rgb(50, 50, 50); +} + +tr.notes_bulletin_row_mat { + border-top: 2px solid rgb(140, 140, 140); + color: blue; +} + +tr.notes_bulletin_row_mod { + border-top: 1px solid rgb(140, 140, 140); +} + +tr.notes_bulletin_row_sum_ects { + border-top: 1px solid black; + font-weight: bold; + background-color: rgb(170, 190, 200); +} + +tr.notes_bulletin_row_mod td.titre, +tr.notes_bulletin_row_mat td.titre { + padding-left: 1em; +} + +tr.notes_bulletin_row_eval { + font-style: italic; + color: rgb(60, 60, 80); +} + +tr.notes_bulletin_row_eval_incomplete .discretelink { + color: rgb(200, 0, 0); +} + +tr.b_eval_first td { + border-top: 1px dashed rgb(170, 170, 170); +} + +tr.b_eval_first td.titre { + border-top: 0px; +} + +tr.notes_bulletin_row_eval td.module { + padding-left: 5px; + border-left: 1px dashed rgb(170, 170, 170); +} + +span.bul_ue_descr { + font-weight: normal; + font-style: italic; +} + +table.notes_bulletin td.note { + padding-left: 1em; +} + +table.notes_bulletin td.min, +table.notes_bulletin td.max, +table.notes_bulletin td.moy { + font-size: 80%; +} + +table.notes_bulletin tr.notes_bulletin_row_ue_cur td.note, +table.notes_bulletin tr.notes_bulletin_row_ue_cur td.min, +table.notes_bulletin tr.notes_bulletin_row_ue_cur td.max { + font-style: italic; +} + +table.notes_bulletin tr.bul_row_ue_cur td, +table.notes_bulletin tr.bul_row_ue_cur td a { + color: rgb(114, 89, 89); +} + +.note_bold { + font-weight: bold; +} + +td.bull_coef_eval, +td.bull_nom_eval { + font-style: italic; + color: rgb(60, 60, 80); +} + +tr.notes_bulletin_row_eval td.note { + font-style: italic; + color: rgb(40, 40, 40); + font-size: 90%; +} + +tr.notes_bulletin_row_eval td.note .note_nd { + font-weight: bold; + color: red; +} + +/* --- Bulletins UCAC */ +tr.bul_ucac_row_tit, +tr.bul_ucac_row_ue, +tr.bul_ucac_row_total, +tr.bul_ucac_row_decision, +tr.bul_ucac_row_mention { + font-weight: bold; + border: 1px solid black; +} + +tr.bul_ucac_row_tit { + background-color: rgb(170, 187, 204); +} + +tr.bul_ucac_row_total, +tr.bul_ucac_row_decision, +tr.bul_ucac_row_mention { + background-color: rgb(220, 220, 220); +} + +/* ---- /ucac */ + +span.bul_minmax { + font-weight: normal; + font-size: 66%; +} + +span.bul_minmax:before { + content: " "; +} + +a.invisible_link, +a.invisible_link:hover { + text-decoration: none; + color: rgb(20, 30, 30); +} + +a.bull_link { + text-decoration: none; + color: rgb(20, 30, 30); +} + +a.bull_link:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + + +div.bulletin_menubar { + padding-left: 25px; +} + +.bull_liensemestre { + font-weight: bold; +} + +.bull_liensemestre a { + color: rgb(255, 0, 0); + text-decoration: none; +} + +.bull_liensemestre a:hover { + color: rgb(153, 51, 51); + text-decoration: underline; +} + +.bull_appreciations p { + margin: 0; + font-style: italic; +} + +.bull_appreciations_link { + margin-left: 1em; +} + +span.bull_appreciations_date { + margin-right: 1em; + font-style: normal; + font-size: 75%; +} + +div.eval_description { + color: rgb(20, 20, 20); + /* border: 1px solid rgb(30,100,0); */ + padding: 3px; +} + +div.bul_foot { + max-width: 1000px; + background: #FFE7D5; + border-radius: 16px; + border: 1px solid #AAA; + padding: 16px 32px; + margin: auto; +} + +div.bull_appreciations { + border-left: 1px solid black; + padding-left: 5px; +} + +/* Saisie des notes */ +div.saisienote_etape1 { + border: 2px solid blue; + padding: 5px; + background-color: rgb(231, 234, 218); + /* E7EADA */ +} + +div.saisienote_etape2 { + border: 2px solid green; + margin-top: 1em; + padding: 5px; + background-color: rgb(234, 221, 218); + /* EADDDA */ +} + +span.titredivsaisienote { + font-weight: bold; + font-size: 115%; +} + + +.etud_dem { + color: rgb(130, 130, 130); +} + +input.note_invalid { + color: red; + background-color: yellow; +} + +input.note_valid_new { + color: blue; +} + +input.note_saved { + color: green; +} + +span.history { + font-style: italic; +} + +span.histcomment { + font-style: italic; +} + +/* ----- Absences ------ */ +td.matin { + background-color: rgb(203, 242, 255); +} + +td.absent { + background-color: rgb(255, 156, 156); +} + +td.present { + background-color: rgb(230, 230, 230); +} + +span.capstr { + color: red; +} + +b.etuddem { + font-weight: normal; + font-style: italic; +} + +tr.row_1 { + background-color: white; +} + +tr.row_2 { + background-color: white; +} + +tr.row_3 { + background-color: #dfdfdf; +} + +td.matin_1 { + background-color: #e1f7ff; +} + +td.matin_2 { + background-color: #e1f7ff; +} + +td.matin_3 { + background-color: #c1efff; +} + +table.abs_form_table tr:hover td { + border: 1px solid red; +} + + +/* ----- Formulator ------- */ +ul.tf-msg { + color: rgb(6, 80, 18); + border: 1px solid red; +} + +li.tf-msg { + list-style-image: url(/ScoDoc/static/icons/warning16_img.png); + padding-top: 5px; + padding-bottom: 5px; +} + +.warning { + font-weight: bold; + color: red; +} + +.warning::before { + content: url(/ScoDoc/static/icons/warning_img.png); + vertical-align: -80%; +} + +.infop { + font-weight: normal; + color: rgb(26, 150, 26); + font-style: italic; +} + +.infop::before { + content: url(/ScoDoc/static/icons/info-16_img.png); + vertical-align: -15%; + padding-right: 5px; +} + + +form.sco_pref table.tf { + border-spacing: 5px 15px; +} + +td.tf-ro-fieldlabel { + /* font-weight: bold; */ + vertical-align: top; + margin-top: 20px; +} + +td.tf-ro-fieldlabel:after { + content: ' :'; +} + +td.tf-ro-field { + vertical-align: top; +} + +span.tf-ro-value { + background-color: white; + color: grey; + margin-right: 2em; +} + +div.tf-ro-textarea { + border: 1px solid grey; + padding-left: 8px; +} + +select.tf-selglobal { + margin-left: 10px; +} + +td.tf-fieldlabel { + /* font-weight: bold; */ + vertical-align: top; +} + +.tf-comment { + font-size: 80%; + font-style: italic; +} + +.tf-explanation { + font-style: italic; +} + +.radio_green { + background-color: green; +} + +.radio_red { + background-color: red; +} + +td.fvs_val { + border-left: 1px solid rgb(80, 80, 80); + text-align: center; + padding-left: 1em; + padding-right: 1em; +} + +td.fvs_val_inf { + border-left: 1px solid rgb(80, 80, 80); + text-align: center; + padding-left: 1em; + padding-right: 1em; + color: red; +} + +td.fvs_tit { + font-weight: bold; + text-align: left; + border-left: 1px solid rgb(80, 80, 80); + text-align: center; + padding-left: 1em; + padding-right: 1em; +} + +td.fvs_tit_chk { + font-weight: bold; +} + +span.table_nav_mid { + flex-grow: 1; + /* Set the middle element to grow and stretch */ +} + +span.table_nav_prev, +span.table_nav_next { + width: 11em; + /* A fixed width as the default */ +} + +div.table_nav { + width: 100%; + display: flex; + justify-content: space-between; +} + +P.gtr_interdit:before { + content: url(/ScoDoc/static/icons/interdit_img.png); + vertical-align: -80%; +} + +P.gtr_devel:before { + content: url(/ScoDoc/static/icons/devel_img.png); + vertical-align: -80%; +} + +/* ---- Sortable tables --- */ +/* Sortable tables */ +table.sortable a.sortheader { + background-color: #E6E6E6; + color: black; + font-weight: bold; + text-decoration: none; + display: block; +} + +table.sortable span.sortarrow { + color: black; + text-decoration: none; +} + +/* Horizontal bar graph */ +.graph { + width: 100px; + height: 12px; + /* background-color: rgb(200, 200, 250); */ + padding-bottom: 0px; + margin-bottom: 0; + margin-right: 0px; + margin-top: 3px; + margin-left: 10px; + border: 1px solid black; + position: absolute; +} + +.bar { + background-color: rgb(100, 150, 255); + margin: 0; + padding: 0; + position: absolute; + left: 0; + top: 2px; + height: 8px; + z-index: 2; +} + +.mark { + background-color: rgb(0, 150, 0); + margin: 0; + padding: 0; + position: absolute; + top: 0; + width: 2px; + height: 100%; + z-index: 2; +} + +td.cell_graph { + width: 170px; +} + +/* ------------------ Formulaire validation semestre ---------- */ +table.recap_parcours { + color: black; + border-collapse: collapse; +} + +table.recap_parcours td { + padding-left: 8px; + padding-right: 8px; +} + +.recap_parcours tr.sem_courant { + background-color: rgb(255, 241, 118); +} + +.recap_parcours tr.sem_precedent { + background-color: rgb(90%, 95%, 90%); +} + +.recap_parcours tr.sem_autre { + background-color: rgb(90%, 90%, 90%); +} + +.rcp_l1 td { + padding-top: 5px; + border-top: 3px solid rgb(50%, 50%, 50%); + border-right: 0px; + border-left: 0px; + color: blue; + vertical-align: top; +} + +td.rcp_dec { + color: rgb(0%, 0%, 50%); + ; +} + +td.rcp_nonass, +td.rcp_but { + color: red; +} + +.recap_hide_details tr.rcp_l2 { + display: none; +} + +table.recap_hide_details td.ue_acro span { + display: none; +} + +.sco_hide { + display: none; +} + +table.recap_hide_details tr.sem_courant, +table.recap_hide_details tr.sem_precedent { + display: table-row; +} + +table.recap_hide_details tr.sem_courant td.ue_acro span, +table.recap_hide_details tr.sem_precedent td.ue_acro span { + display: inline; +} + +.recap_parcours tr.sem_courant td.rcp_type_sem { + font-weight: bold; +} + +.recap_parcours tr.sem_autre td.rcp_type_sem { + color: rgb(100%, 70%, 70%); +} + +.recap_parcours tr.sem_autre_formation td.rcp_titre_sem { + background-image: repeating-linear-gradient(-45deg, rgb(100, 205, 193), rgb(100, 205, 193) 2px, transparent 5px, transparent 40px); +} + +.rcp_l2 td { + padding-bottom: 5px; +} + +td.sem_ects_tit { + text-align: right; +} + +span.ects_fond { + text-decoration: underline; +} + +span.ects_fond:before { + content: "("; +} + +span.ects_fond:after { + content: ")"; +} + +table.recap_parcours td.datedebut { + color: rgb(0, 0, 128); +} + +table.recap_parcours td.datefin { + color: rgb(0, 0, 128); +} + +table.recap_parcours td.rcp_type_sem { + padding-left: 4px; + padding-right: 4px; + color: red; +} + +td.ue_adm { + color: green; + font-weight: bold; +} + +td.ue_cmp { + color: green; +} + +td.ue_capitalized { + text-decoration: underline; +} + +h3.sfv { + margin-top: 0px; +} + +form.sfv_decisions { + border: 1px solid blue; + padding: 6px; + margin-right: 2px; +} + +form.sfv_decisions_manuelles { + margin-top: 10px; +} + +th.sfv_subtitle { + text-align: left; + font-style: italic; +} + + +tr.sfv_ass { + background-color: rgb(90%, 90%, 80%); +} + +tr.sfv_pbass { + background-color: rgb(90%, 70%, 80%); +} + +div.link_defaillance { + padding-top: 8px; + font-weight: bold; +} + +div.pas_sembox { + margin-top: 10px; + border: 2px solid #a0522d; + padding: 5px; + margin-right: 10px; + font-family: arial, verdana, sans-serif; +} + +span.sp_etape { + display: inline-block; + width: 4em; + font-family: "Andale Mono", "Courier"; + font-size: 75%; + color: black; +} + +.inscrailleurs { + font-weight: bold; + color: red !important; +} + +span.paspaye, +span.paspaye a { + color: #9400d3 !important; +} + +span.finalisationinscription { + color: green; +} + +.pas_sembox_title a { + font-weight: bold; + font-size: 100%; + color: #1C721C; +} + +.pas_sembox_subtitle { + font-weight: normal; + font-size: 100%; + color: blue; + border-bottom: 1px solid rgb(50%, 50%, 50%); + margin-bottom: 8px; +} + +.pas_recap { + font-weight: bold; + font-size: 110%; + margin-top: 10px; +} + +div.pas_help { + width: 80%; + font-size: 80%; + background-color: rgb(90%, 90%, 90%); + color: rgb(40%, 20%, 0%); + margin-top: 30px; + margin-bottom: 30px; +} + +div.pas_help_left { + float: left; +} + +span.libelle { + font-weight: bold; +} + +span.anomalie { + font-style: italic; +} + +/* ---- check absences / evaluations ---- */ +div.module_check_absences h2 { + font-size: 100%; + color: blue; + margin-bottom: 0px; +} + +div.module_check_absences h2.eval_check_absences { + font-size: 80%; + color: black; + margin-left: 20px; + margin-top: 0px; + margin-bottom: 5px; +} + +div.module_check_absences h3 { + font-size: 80%; + color: rgb(133, 0, 0); + margin-left: 40px; + margin-top: 0px; + margin-bottom: 0px; +} + +div.module_check_absences ul { + margin-left: 60px; + font-size: 80%; + margin-top: 0px; + margin-bottom: 0px; +} + +/* ----------------------------------------------- */ +/* Help bubbles (aka tooltips) */ +/* ----------------------------------------------- */ +.tooltip { + width: 200px; + color: #000; + font: lighter 11px/1.3 Arial, sans-serif; + text-decoration: none; + text-align: center; +} + +.tooltip span.top { + padding: 30px 8px 0; + background: url(/ScoDoc/static/icons/bt_gif.png) no-repeat top; +} + +.tooltip b.bottom { + padding: 3px 8px 15px; + color: #548912; + background: url(/ScoDoc/static/icons/bt_gif.png) no-repeat bottom; +} + +/* ----------------------------------------------- */ + +/* ----------------------------- */ +/* TABLES generees par gen_table */ +/* ----------------------------- */ +/* Voir gt_table.css les definitions s'appliquant à toutes les tables + */ + +table.table_cohorte tfoot tr td, +table.table_cohorte tfoot tr th { + background-color: rgb(90%, 95%, 100%); + border-right: 1px solid #dddddd; +} + +table.table_cohorte tfoot tr th { + text-align: left; + border-left: 1px solid #dddddd; + font-weight: normal; +} + +table.table_coldate tr td:first-child { + /* largeur col. date/time */ + width: 12em; + color: rgb(0%, 0%, 50%); +} + + +table.table_listegroupe tr td { + padding-left: 0.5em; + padding-right: 0.5em; +} + +table.list_users td.roles { + width: 22em; +} + +table.list_users td.date_modif_passwd { + white-space: nowrap; +} + +table.formsemestre_description tr.table_row_ue td { + font-weight: bold; +} + +table.formsemestre_description tr.evaluation td { + color: rgb(4, 16, 159); + font-size: 85%; +} + +table.formsemestre_description tr.evaluation td.poids a { + font-style: italic; + color: rgb(4, 16, 159); +} + +table.formsemestre_description tbody tr.evaluation td { + background-color: #cee4fa !important; +} + +/* --- */ +tr#tf_extue_decl>td, +tr#tf_extue_note>td { + padding-top: 20px; +} + +tr#tf_extue_titre>td, +tr#tf_extue_acronyme>td, +tr#tf_extue_type>td, +tr#tf_extue_ects>td { + padding-left: 20px; +} + +/* ----------------------------- */ + +div.form_rename_partition { + margin-top: 2em; + margin-bottom: 2em; +} + + +td.calday { + text-align: right; + vertical-align: top; +} + +div.cal_evaluations table.monthcalendar td.calcell { + padding-left: 0.6em; + width: 6em; +} + + +div.cal_evaluations table.monthcalendar td a { + color: rgb(128, 0, 0); +} + +#lyc_map_canvas { + width: 900px; + height: 600px; +} + +div.othersemlist { + margin-bottom: 10px; + margin-right: 5px; + padding-bottom: 4px; + padding-left: 5px; + border: 1px solid gray; +} + +div.othersemlist p { + font-weight: bold; + margin-top: 0px; +} + +div.othersemlist input { + margin-left: 20px; +} + + +div#update_warning { + display: none; + border: 1px solid red; + background-color: rgb(250, 220, 220); + margin: 3ex; + padding-left: 1ex; + padding-right: 1ex; + padding-bottom: 1ex; +} + +div#update_warning>div:first-child:before { + content: url(/ScoDoc/static/icons/warning_img.png); + vertical-align: -80%; +} + +div#update_warning>div:nth-child(2) { + font-size: 80%; + padding-left: 8ex; +} + +/* + Titres des tabs: + .nav-tabs li a { + font-variant: small-caps; + font-size: 13pt; + } + + #group-tabs { + clear: both; + } + + #group-tabs ul { + display: inline; + } + + #group-tabs ul li { + display: inline; + } +*/ + +/* Page accueil */ +#scodoc_attribution p { + font-size: 75%; +} + +div.maindiv { + margin: 1em; +} + +ul.main { + list-style-type: square; + margin-top: 1em; +} + +ul.main li { + padding-bottom: 2ex; +} + + +#scodoc_admin { + background-color: #EEFFFF; +} + +#message, +.message { + margin-top: 2px; + margin-bottom: 0px; + padding: 0.1em; + margin-left: auto; + margin-right: auto; + background-color: #ffff73; + -moz-border-radius: 8px; + -khtml-border-radius: 8px; + border-radius: 8px; + font-family: arial, verdana, sans-serif; + font-weight: bold; + width: 40%; + text-align: center; + color: red; +} + +h4.scodoc { + padding-top: 20px; + padding-bottom: 0px; +} + +tr#erroneous_ue td { + color: red; +} + +/* Export Apogee */ + +div.apo_csv_infos { + margin-bottom: 12px; +} + +div.apo_csv_infos span:first-of-type { + font-weight: bold; + margin-right: 2ex; +} + +div.apo_csv_infos span:last-of-type { + font-weight: bold; + font-family: "Andale Mono", "Courier"; + margin-right: 2ex; +} + +div.apo_csv_1 { + margin-bottom: 10px; +} + +div.apo_csv_status { + margin-top: 20px; + padding-left: 22px; +} + +div.apo_csv_status li { + margin: 10px 0; +} + +div.apo_csv_status span { + font-family: arial, verdana, sans-serif; + font-weight: bold; +} + +div.apo_csv_status_nok { + background: url(/ScoDoc/static/icons/bullet_warning_img.png) no-repeat left top 0px; +} + +div.apo_csv_status_missing_elems { + background: url(/ScoDoc/static/icons/bullet_warning_img.png) no-repeat left top 0px; + padding-left: 22px; +} + +div#param_export_res { + padding-top: 1em; +} + +div#apo_elements span.apo_elems { + font-family: "Andale Mono", "Courier"; + font-weight: normal; + font-size: 9pt; +} + +div#apo_elements span.apo_elems .missing { + color: red; + font-weight: normal; +} + +div.apo_csv_jury_nok li { + color: red; +} + + +pre.small_pre_acc { + font-size: 60%; + width: 90%; + height: 20em; + background-color: #fffff0; + overflow: scroll; +} + +.apo_csv_jury_ok input[type=submit] { + color: green; +} + +li.apo_csv_warning, +.apo_csv_problems li.apo_csv_warning { + color: darkorange; +} + +.apo_csv_problems li { + color: red; +} + +table.apo_maq_list { + margin-bottom: 12px; +} + +table.apo_maq_table tr.apo_not_scodoc td:last-of-type { + color: red; +} + +table.apo_maq_table th:last-of-type { + width: 4em; + text-align: left; +} + +div.apo_csv_list { + margin-top: 4px; + padding-left: 5px; + padding-bottom: 5px; + border: 1px dashed rgb(150, 10, 40); +} + +#apo_csv_download { + margin-top: 5px; +} + +div.apo_compare_csv_form_but { + margin-top: 10px; + margin-bottom: 10px; +} + +div.apo_compare_csv_form_submit input { + margin-top: 2ex; + margin-left: 5em; + font-size: 120%; +} + +.apo_compare_csv div.section .tit { + margin-top: 10px; + font-size: 120%; + font-weight: bold; +} + +.apo_compare_csv div.section .key { + font-size: 110%; +} + +.apo_compare_csv div.section .val_ok { + font-size: 110%; + color: green; + font-weight: bold; + font-family: "Courier New", Courier, monospace; +} + +.apo_compare_csv div.section .val_dif { + font-size: 110%; + color: red; + font-weight: bold; + font-family: "Courier New", Courier, monospace; +} + +.apo_compare_csv div.section .p_ok { + font-size: 100%; + font-style: italic; + color: green; + margin-left: 4em; +} + +.apo_compare_csv div.section .s_ok { + font-size: 100%; + font-style: italic; + color: green; +} + +.apo_compare_csv div.sec_table { + margin-bottom: 10px; + margin-top: 20px; +} + +.apo_compare_csv div.sec_table .gt_table { + font-size: 100%; +} + +.apo_compare_csv div.sec_table .gt_table td.val_A, +.apo_compare_csv div.sec_table .gt_table td.val_B { + color: red; + font-weight: bold; + text-align: center; +} + +.apo_compare_csv div.sec_table .gt_table td.type_res { + text-align: center; +} + +div.semset_descr { + border: 1px dashed rgb(10, 150, 40); + padding-left: 5px; +} + +div.semset_descr p { + margin: 5px; +} + +ul.semset_listsems li { + margin-top: 10px; +} + +ul.semset_listsems li:first-child { + margin-top: 0; +} + +span.box_title { + font-weight: bold; + font-size: 115%; +} + +div.apo_csv_status { + border: 1px dashed red; + padding-bottom: 5px; +} + +.form_apo_export input[type="submit"] { + -webkit-appearance: button; + font-size: 150%; + font-weight: bold; + color: green; + margin: 10px; +} + +span.vdi_label { + padding-left: 2em; +} + +/* Poursuites edtude PE */ +form#pe_view_sem_recap_form div.pe_template_up { + margin-top: 20px; + margin-bottom: 30px; +} + +/* Editable */ +span.span_apo_edit { + border-bottom: 1px dashed #84ae84; +} + +/* Tags */ +.notes_module_list span.sco_tag_edit { + display: none; +} + +span.sco_tag_edit .tag-editor { + background-color: rgb(210, 210, 210); + border: 0px; + margin-left: 40px; + margin-top: 2px; +} + +div.sco_tag_module_edit span.sco_tag_edit .tag-editor { + background-color: rgb(210, 210, 210); + border: 0px; + margin-left: 0px; + margin-top: 2px; +} + +span.sco_tag_edit .tag-editor-delete { + height: 20px; +} + +/* Autocomplete noms */ +.ui-menu-item { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 11pt; +} + +div#formsemestre_ext_edit_ue_validations { + margin-bottom: 2ex; +} + +form.tf_ext_edit_ue_validations table { + border-collapse: collapse; + width: 97%; + margin-left: 1em; + margin-right: 1em; +} + +form.tf_ext_edit_ue_validations table th, +form.tf_ext_edit_ue_validations table td { + border-bottom: 1px solid rgb(168, 168, 168); +} + +form.tf_ext_edit_ue_validations table th { + padding-left: 1em; + padding-right: 1em; +} + +form.tf_ext_edit_ue_validations table td.tf_field_note, +form.tf_ext_edit_ue_validations table td.tf_field_coef { + text-align: center; +} + +form.tf_ext_edit_ue_validations table td.tf_field_note input { + margin-left: 1em; +} + +span.ext_sem_moy { + font-weight: bold; + color: rgb(122, 40, 2); + font-size: 120%; +} + +/* DataTables */ + +table.dataTable tr.odd td { + background-color: #ecf5f4; +} + +table.dataTable tr.gt_lastrow th { + text-align: right; +} + +table.dataTable td.etudinfo, +table.dataTable td.group { + text-align: left; +} + +/* ------------- Nouveau tableau recap ------------ */ +div.table_recap { + margin-top: 6px; +} + +div.table_recap table.table_recap { + width: auto; +} + +table.table_recap tr.selected td { + border-bottom: 1px solid rgb(248, 0, 33); + border-top: 1px solid rgb(248, 0, 33); + background-color: rgb(253, 255, 155); +} + +table.table_recap tr.selected td:first-child { + border-left: 1px solid rgb(248, 0, 33); +} + +table.table_recap tr.selected td:last-child { + border-right: 1px solid rgb(248, 0, 33); +} + +table.table_recap tbody td { + padding-top: 4px !important; + padding-bottom: 4px !important; +} + +table.table_recap tbody td:hover { + color: rgb(163, 0, 0); + text-decoration: dashed underline; +} + +/* col moy gen en gras seulement pour les form. classiques */ +table.table_recap.classic td.col_moy_gen { + font-weight: bold; +} + +table.table_recap .identite_court { + white-space: nowrap; + text-align: left; +} + +table.table_recap .rang { + white-space: nowrap; + text-align: right; +} + +table.table_recap .col_ue, +table.table_recap .col_ue_code, +table.table_recap .col_moy_gen, +table.table_recap .group { + border-left: 1px solid blue; +} + +table.table_recap .col_ue { + font-weight: bold; +} + +table.table_recap.jury .col_ue { + font-weight: normal; +} + +table.table_recap.jury .col_rcue, +table.table_recap.jury .col_rcue_code { + font-weight: bold; +} + +table.table_recap.jury tr.even td.col_rcue, +table.table_recap.jury tr.even td.col_rcue_code { + background-color: #b0d4f8; +} + +table.table_recap.jury tr.odd td.col_rcue, +table.table_recap.jury tr.odd td.col_rcue_code { + background-color: #abcdef; +} + +table.table_recap.jury tr.odd td.col_rcues_validables { + background-color: #e1d3c5 !important; +} + +table.table_recap.jury tr.even td.col_rcues_validables { + background-color: #fcebda !important; +} + +table.table_recap .group { + border-left: 1px dashed rgb(160, 160, 160); + white-space: nowrap; +} + +table.table_recap .admission { + white-space: nowrap; + color: rgb(6, 73, 6); +} + +table.table_recap .admission_first { + border-left: 1px solid blue; +} + +table.table_recap tbody tr td a:hover { + color: red; + text-decoration: underline; +} + +/* noms des etudiants sur recap complet */ +table.table_recap a:link, +table.table_recap a:visited { + text-decoration: none; + color: black; +} + +table.table_recap a.stdlink:link, +table.table_recap a.stdlink:visited { + color: blue; + text-decoration: underline; +} + +table.table_recap tfoot th, +table.table_recap thead th { + text-align: left; + padding-left: 10px !important; +} + +table.table_recap td.moy_inf { + font-weight: bold; + color: rgb(225, 147, 0); +} + +table.table_recap td.moy_ue_valid { + color: rgb(0, 140, 0); +} + +table.table_recap td.moy_ue_warning { + color: rgb(255, 0, 0); +} + +table.table_recap td.col_ues_validables { + white-space: nowrap; + font-style: normal !important; +} + + +.green-arrow-up { + display: inline-block; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-bottom: 8px solid rgb(48, 239, 0); +} + +table.table_recap td.col_ue_bonus, +table.table_recap th.col_ue_bonus { + font-size: 80%; + font-weight: bold; + color: rgb(0, 128, 11); +} + +table.table_recap td.col_ue_bonus>span.sp2l { + margin-left: 2px; +} + +table.table_recap td.col_ue_bonus { + white-space: nowrap; +} + +table.table_recap td.col_malus, +table.table_recap th.col_malus { + font-size: 80%; + font-weight: bold; + color: rgb(165, 0, 0); +} + + +table.table_recap tr.ects td { + color: rgb(160, 86, 3); + font-weight: bold; + border-bottom: 1px solid blue; +} + +table.table_recap tr.coef td { + font-style: italic; + color: #9400d3; +} + +table.table_recap tr.coef td, +table.table_recap tr.min td, +table.table_recap tr.max td, +table.table_recap tr.moy td { + font-size: 80%; + padding-top: 3px; + padding-bottom: 3px; +} + +table.table_recap tr.dem td { + color: rgb(100, 100, 100); + font-style: italic; +} + +table.table_recap tr.def td { + color: rgb(121, 74, 74); + font-style: italic; +} + +table.table_recap td.evaluation, +table.table_recap tr.descr_evaluation { + font-size: 90%; + color: rgb(4, 16, 159); +} + +table.table_recap tr.descr_evaluation a { + color: rgb(4, 16, 159); + text-decoration: none; +} + +table.table_recap tr.descr_evaluation a:hover { + color: red; +} + +table.table_recap tr.descr_evaluation { + vertical-align: top; +} + +table.table_recap tr.apo { + font-size: 75%; + font-family: monospace; +} + +table.table_recap tr.apo td { + border: 1px solid gray; + background-color: #d8f5fe; +} + +table.table_recap td.evaluation.first, +table.table_recap th.evaluation.first { + border-left: 2px solid rgb(4, 16, 159); +} + +table.table_recap td.evaluation.first_of_mod, +table.table_recap th.evaluation.first_of_mod { + border-left: 1px dashed rgb(4, 16, 159); +} + + +table.table_recap td.evaluation.att { + color: rgb(255, 0, 217); + font-weight: bold; +} + +table.table_recap td.evaluation.abs { + color: rgb(231, 0, 0); + font-weight: bold; +} + +table.table_recap td.evaluation.exc { + font-style: italic; + color: rgb(0, 131, 0); +} + +table.table_recap td.evaluation.non_inscrit { + font-style: italic; + color: rgb(101, 101, 101); +} + +div.table_jury_but_links { + margin-top: 16px; + margin-bottom: 16px; +} + +/* ------------- Tableau etat evals ------------ */ + +div.evaluations_recap table.evaluations_recap { + width: auto !important; + border: 1px solid black; +} + +table.evaluations_recap tr.odd td { + background-color: #fff4e4; +} + +table.evaluations_recap tr.res td { + background-color: #f7d372; +} + +table.evaluations_recap tr.sae td { + background-color: #d8fcc8; +} + + +table.evaluations_recap tr.module td { + font-weight: bold; +} + +table.evaluations_recap tr.evaluation td.titre { + font-style: italic; + padding-left: 2em; +} + +table.evaluations_recap td.titre, +table.evaluations_recap th.titre { + max-width: 350px; +} + +table.evaluations_recap td.complete, +table.evaluations_recap th.complete { + text-align: center; +} + +table.evaluations_recap tr.evaluation.incomplete td, +table.evaluations_recap tr.evaluation.incomplete td a { + color: red; +} + +table.evaluations_recap tr.evaluation.incomplete td a.incomplete { + font-weight: bold; +} + +table.evaluations_recap td.inscrits, +table.evaluations_recap td.manquantes, +table.evaluations_recap td.nb_abs, +table.evaluations_recap td.nb_att, +table.evaluations_recap td.nb_exc { + text-align: center; +} + +/* ------------- Tableau récap formation ------------ */ +table.formation_table_recap tr.ue td { + font-weight: bold; +} + +table.formation_table_recap td.coef, +table.formation_table_recap td.ects, +table.formation_table_recap td.nb_moduleimpls, +table.formation_table_recap td.heures_cours, +table.formation_table_recap td.heures_td, +table.formation_table_recap td.heures_tp { + text-align: right; } \ No newline at end of file diff --git a/app/views/scolar.py b/app/views/scolar.py index 03674909..505cff5b 100644 --- a/app/views/scolar.py +++ b/app/views/scolar.py @@ -1,2309 +1,2299 @@ -# -*- coding: utf-8 -*- -############################################################################## -# -# ScoDoc -# -# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation; either version 2 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -# -# Emmanuel Viennet emmanuel.viennet@viennet.net -# -############################################################################## - -""" -Module scolar: vues de .../ScoDoc//Scolarite - -issu de ScoDoc7 / ZScolar.py - -Emmanuel Viennet, 2021 -""" -import datetime -import requests -import time - -import flask -from flask import jsonify, url_for, flash, render_template, make_response -from flask import g, request -from flask_login import current_user -from flask_wtf import FlaskForm -from flask_wtf.file import FileField, FileAllowed -from wtforms import SubmitField - -from app import db -from app import log -from app.decorators import ( - scodoc, - scodoc7func, - permission_required, - permission_required_compat_scodoc7, - admin_required, - login_required, -) -from app.models import formsemestre -from app.models.etudiants import Identite -from app.models.etudiants import make_etud_args -from app.models.events import ScolarNews -from app.models.formsemestre import FormSemestre - -from app.views import scolar_bp as bp -from app.views import ScoData - -import app.scodoc.sco_utils as scu -import app.scodoc.notesdb as ndb -from app.scodoc.scolog import logdb -from app.scodoc.sco_permissions import Permission -from app.scodoc.sco_exceptions import ( - AccessDenied, - ScoException, - ScoValueError, -) - -from app.scodoc.TrivialFormulator import DMY_REGEXP, TrivialFormulator, tf_error_message -from app.scodoc.gen_tables import GenTable -from app.scodoc import html_sco_header -from app.scodoc import sco_import_etuds -from app.scodoc import sco_archives_etud -from app.scodoc import sco_codes_parcours -from app.scodoc import sco_cache -from app.scodoc import sco_debouche -from app.scodoc import sco_dept -from app.scodoc import sco_dump_db -from app.scodoc import sco_etud -from app.scodoc import sco_find_etud -from app.scodoc import sco_formsemestre -from app.scodoc import sco_formsemestre_inscriptions -from app.scodoc import sco_groups -from app.scodoc import sco_groups_edit -from app.scodoc import sco_groups_exports -from app.scodoc import sco_groups_view -from app.scodoc import sco_page_etud -from app.scodoc import sco_permissions_check -from app.scodoc import sco_photos -from app.scodoc import sco_portal_apogee -from app.scodoc import sco_preferences -from app.scodoc import sco_synchro_etuds -from app.scodoc import sco_trombino -from app.scodoc import sco_trombino_tours -from app.scodoc import sco_up_to_date - - -def sco_publish(route, function, permission, methods=["GET"]): - """Declare a route for a python function, - protected by permission and called following ScoDoc 7 Zope standards. - """ - return bp.route(route, methods=methods)( - scodoc(permission_required(permission)(scodoc7func(function))) - ) - - -# -------------------------------------------------------------------- -# -# SCOLARITE (/ScoDoc//Scolarite/...) -# -# -------------------------------------------------------------------- - - -# -------------------------------------------------------------------- -# -# PREFERENCES -# -# -------------------------------------------------------------------- - - -@bp.route("/edit_preferences", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoChangePreferences) -@scodoc7func -def edit_preferences(): - """Edit global preferences (lien "Paramétrage" département)""" - return sco_preferences.get_base_preferences().edit() - - -@bp.route("/formsemestre_edit_preferences", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def formsemestre_edit_preferences(formsemestre_id): - """Edit preferences for a semestre""" - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - ok = ( - current_user.has_permission(Permission.ScoImplement) - or ((current_user.id in sem["responsables"]) and sem["resp_can_edit"]) - ) and (sem["etat"]) - if ok: - return sco_preferences.SemPreferences(formsemestre_id=formsemestre_id).edit() - else: - raise AccessDenied( - "Modification impossible pour %s" % current_user.get_nomplogin() - ) - - -@bp.route("/doc_preferences") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def doc_preferences(): - """List preferences for wiki documentation""" - response = make_response(sco_preferences.doc_preferences()) - response.headers["Content-Type"] = "text/plain" - return response - - -class DeptLogosConfigurationForm(FlaskForm): - "Panneau de configuration logos dept" - - logo_header = FileField( - label="Modifier l'image:", - description="logo placé en haut des documents PDF", - validators=[ - FileAllowed( - scu.LOGOS_IMAGES_ALLOWED_TYPES, - f"n'accepte que les fichiers image {','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", - ) - ], - ) - - logo_footer = FileField( - label="Modifier l'image:", - description="logo placé en pied des documents PDF", - validators=[ - FileAllowed( - scu.LOGOS_IMAGES_ALLOWED_TYPES, - f"n'accepte que les fichiers image {','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", - ) - ], - ) - - submit = SubmitField("Enregistrer") - - -# @bp.route("/config_logos", methods=["GET", "POST"]) -# @permission_required(Permission.ScoChangePreferences) -# def config_logos(scodoc_dept): -# "Panneau de configuration général" -# form = DeptLogosConfigurationForm() -# if form.validate_on_submit(): -# if form.logo_header.data: -# sco_logos.store_image( -# form.logo_header.data, -# os.path.join( -# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" -# ), -# ) -# if form.logo_footer.data: -# sco_logos.store_image( -# form.logo_footer.data, -# os.path.join( -# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" -# ), -# ) -# app.clear_scodoc_cache() -# flash(f"Logos enregistrés") -# return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) -# -# return render_template( -# "configuration.html", -# title="Configuration Logos du département", -# form=form, -# scodoc_dept=scodoc_dept, -# ) -# -# -# class DeptLogosConfigurationForm(FlaskForm): -# "Panneau de configuration logos dept" -# -# logo_header = FileField( -# label="Modifier l'image:", -# description="logo placé en haut des documents PDF", -# validators=[ -# FileAllowed( -# scu.LOGOS_IMAGES_ALLOWED_TYPES, -# f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", -# ) -# ], -# ) -# -# logo_footer = FileField( -# label="Modifier l'image:", -# description="logo placé en pied des documents PDF", -# validators=[ -# FileAllowed( -# scu.LOGOS_IMAGES_ALLOWED_TYPES, -# f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", -# ) -# ], -# ) -# -# submit = SubmitField("Enregistrer") - - -# @bp.route("/config_logos", methods=["GET", "POST"]) -# @permission_required(Permission.ScoChangePreferences) -# def config_logos(scodoc_dept): -# "Panneau de configuration général" -# form = DeptLogosConfigurationForm() -# if form.validate_on_submit(): -# if form.logo_header.data: -# sco_logos.store_image( -# form.logo_header.data, -# os.path.join( -# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" -# ), -# ) -# if form.logo_footer.data: -# sco_logos.store_image( -# form.logo_footer.data, -# os.path.join( -# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" -# ), -# ) -# app.clear_scodoc_cache() -# flash(f"Logos enregistrés") -# return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) -# -# return render_template( -# "configuration.html", -# title="Configuration Logos du département", -# form=form, -# scodoc_dept=scodoc_dept, -# ) - - -# -------------------------------------------------------------------- -# -# ETUDIANTS -# -# -------------------------------------------------------------------- - - -@bp.route("/showEtudLog") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def showEtudLog(etudid, format="html"): - """Display log of operations on this student""" - etud = sco_etud.get_etud_info(filled=True)[0] - - ops = sco_etud.list_scolog(etudid) - - tab = GenTable( - titles={ - "date": "Date", - "authenticated_user": "Utilisateur", - "remote_addr": "IP", - "method": "Opération", - "msg": "Message", - }, - columns_ids=("date", "authenticated_user", "remote_addr", "method", "msg"), - rows=ops, - html_sortable=True, - html_class="table_leftalign", - base_url="%s?etudid=%s" % (request.base_url, etudid), - page_title="Opérations sur %(nomprenom)s" % etud, - html_title="

            Opérations effectuées sur l'étudiant %(nomprenom)s

            " % etud, - filename="log_" + scu.make_filename(etud["nomprenom"]), - html_next_section=f""" - """, - preferences=sco_preferences.SemPreferences(), - ) - - return tab.make_page(format=format) - - -# ---------- PAGE ACCUEIL (listes) -------------- - - -@bp.route("/", alias=True) -@bp.route("/index_html") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def index_html(showcodes=0, showsemtable=0): - return sco_dept.index_html(showcodes=showcodes, showsemtable=showsemtable) - - -@bp.route("/install_info") -@scodoc -@permission_required(Permission.ScoView) -def install_info(): - """Information on install status (html str)""" - return sco_up_to_date.is_up_to_date() - - -@bp.route("/dept_news") -@scodoc -@permission_required(Permission.ScoView) -def dept_news(): - "Affiche table des dernières opérations" - return render_template( - "dept_news.html", title=f"Opérations {g.scodoc_dept}", sco=ScoData() - ) - - -@bp.route("/dept_news_json") -@scodoc -@permission_required(Permission.ScoView) -def dept_news_json(): - "Table des news du département" - start = request.args.get("start", type=int) - length = request.args.get("length", type=int) - - log(f"dept_news_json( start={start}, length={length})") - query = ScolarNews.query.filter_by(dept_id=g.scodoc_dept_id) - # search - search = request.args.get("search[value]") - if search: - query = query.filter( - db.or_( - ScolarNews.authenticated_user.like(f"%{search}%"), - ScolarNews.text.like(f"%{search}%"), - ) - ) - total_filtered = query.count() - # sorting - order = [] - i = 0 - while True: - col_index = request.args.get(f"order[{i}][column]") - if col_index is None: - break - col_name = request.args.get(f"columns[{col_index}][data]") - if col_name not in ["date", "type", "authenticated_user"]: - col_name = "date" - descending = request.args.get(f"order[{i}][dir]") == "desc" - col = getattr(ScolarNews, col_name) - if descending: - col = col.desc() - order.append(col) - i += 1 - if order: - query = query.order_by(*order) - - # pagination - query = query.offset(start).limit(length) - data = [news.to_dict() for news in query] - # response - return { - "data": data, - "recordsFiltered": total_filtered, - "recordsTotal": ScolarNews.query.count(), - "draw": request.args.get("draw", type=int), - } - - -sco_publish( - "/trombino", sco_trombino.trombino, Permission.ScoView, methods=["GET", "POST"] -) - -sco_publish( - "/pdf_trombino_tours", sco_trombino_tours.pdf_trombino_tours, Permission.ScoView -) - -sco_publish( - "/pdf_feuille_releve_absences", - sco_trombino_tours.pdf_feuille_releve_absences, - Permission.ScoView, -) - -sco_publish( - "/trombino_copy_photos", - sco_trombino.trombino_copy_photos, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/groups_export_annotations", - sco_groups_exports.groups_export_annotations, - Permission.ScoView, -) - - -@bp.route("/groups_view") -@scodoc -@permission_required_compat_scodoc7(Permission.ScoView) -# @permission_required(Permission.ScoView) -@scodoc7func -def groups_view( - group_ids=(), - format="html", - # Options pour listes: - with_codes=0, - etat=None, - with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) - with_archives=0, # ajoute colonne avec noms fichiers archivés - with_annotations=0, - formsemestre_id=None, -): - return sco_groups_view.groups_view( - group_ids=group_ids, - format=format, - # Options pour listes: - with_codes=with_codes, - etat=etat, - with_paiement=with_paiement, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) - with_archives=with_archives, # ajoute colonne avec noms fichiers archivés - with_annotations=with_annotations, - formsemestre_id=formsemestre_id, - ) - - -sco_publish( - "/export_groups_as_moodle_csv", - sco_groups_view.export_groups_as_moodle_csv, - Permission.ScoView, -) - - -# -------------------------- INFOS SUR ETUDIANTS -------------------------- -@bp.route("/getEtudInfo") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def getEtudInfo(etudid=False, code_nip=False, filled=False, format=None): - """infos sur un etudiant (API) - On peut specifier etudid ou code_nip - ou bien cherche dans les arguments de la requête: etudid, code_nip, code_ine - (dans cet ordre). - """ - etud = sco_etud.get_etud_info(etudid=etudid, code_nip=code_nip, filled=filled) - if format is None: - return etud - else: - return scu.sendResult(etud, name="etud", format=format) - - -sco_publish( - "/search_etud_in_dept", - sco_find_etud.search_etud_in_dept, - Permission.ScoView, - methods=["GET", "POST"], -) - - -@bp.route("/search_etud_by_name") -@bp.route("/Notes/search_etud_by_name") # for JS apis -@scodoc -@permission_required(Permission.ScoView) -def search_etud_by_name(): - term = request.args["term"] - data = sco_find_etud.search_etud_by_name(term) - return jsonify(data) - - -# XMLgetEtudInfos était le nom dans l'ancienne API ScoDoc 6 -@bp.route("/etud_info", methods=["GET", "POST"]) # pour compat anciens clients PHP) -@bp.route( - "/XMLgetEtudInfos", methods=["GET", "POST"] -) # pour compat anciens clients PHP) -@bp.route( - "/Absences/XMLgetEtudInfos", methods=["GET", "POST"] -) # pour compat anciens clients PHP -@bp.route( - "/Notes/XMLgetEtudInfos", methods=["GET", "POST"] -) # pour compat anciens clients PHP -@scodoc -@permission_required_compat_scodoc7(Permission.ScoView) -@scodoc7func -def etud_info(etudid=None, format="xml"): - "Donne les informations sur un etudiant" - if not format in ("xml", "json"): - raise ScoValueError("format demandé non supporté par cette fonction.") - t0 = time.time() - args = make_etud_args(etudid=etudid) - cnx = ndb.GetDBConnexion() - etuds = sco_etud.etudident_list(cnx, args) - if not etuds: - # etudiant non trouvé: message d'erreur - d = { - "etudid": etudid, - "nom": "?", - "nom_usuel": "", - "prenom": "?", - "civilite": "?", - "sexe": "?", # for backward compat - "email": "?", - "emailperso": "", - "error": "code etudiant inconnu", - } - return scu.sendResult( - d, name="etudiant", format=format, force_outer_xml_tag=False - ) - d = {} - etud = etuds[0] - sco_etud.fill_etuds_info([etud]) - etud["date_naissance_iso"] = ndb.DateDMYtoISO(etud["date_naissance"]) - for a in ( - "etudid", - "code_nip", - "code_ine", - "nom", - "nom_usuel", - "prenom", - "nomprenom", - "email", - "emailperso", - "domicile", - "codepostaldomicile", - "villedomicile", - "paysdomicile", - "telephone", - "telephonemobile", - "fax", - "bac", - "specialite", - "annee_bac", - "nomlycee", - "villelycee", - "codepostallycee", - "codelycee", - "date_naissance_iso", - ): - d[a] = etud[a] # ne pas quoter car ElementTree.tostring quote déjà - d["civilite"] = etud["civilite_str"] # exception: ne sort pas la civilite brute - d["sexe"] = d["civilite"] # backward compat pour anciens clients - d["photo_url"] = sco_photos.etud_photo_url(etud) - - sem = etud["cursem"] - if sem: - sco_groups.etud_add_group_infos(etud, sem["formsemestre_id"] if sem else None) - d["insemestre"] = [ - { - "current": "1", - "formsemestre_id": sem["formsemestre_id"], - "date_debut": ndb.DateDMYtoISO(sem["date_debut"]), - "date_fin": ndb.DateDMYtoISO(sem["date_fin"]), - "etat": sem["ins"]["etat"], - "groupes": etud["groupes"], # slt pour semestre courant - } - ] - else: - d["insemestre"] = [] - for sem in etud["sems"]: - if sem != etud["cursem"]: - d["insemestre"].append( - { - "formsemestre_id": sem["formsemestre_id"], - "date_debut": ndb.DateDMYtoISO(sem["date_debut"]), - "date_fin": ndb.DateDMYtoISO(sem["date_fin"]), - "etat": sem["ins"]["etat"], - } - ) - - log("etud_info (%gs)" % (time.time() - t0)) - return scu.sendResult( - d, name="etudiant", format=format, force_outer_xml_tag=False, quote_xml=False - ) - - -# -------------------------- FICHE ETUDIANT -------------------------- -sco_publish("/ficheEtud", sco_page_etud.ficheEtud, Permission.ScoView) - -sco_publish( - "/etud_upload_file_form", - sco_archives_etud.etud_upload_file_form, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/etud_delete_archive", - sco_archives_etud.etud_delete_archive, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/etud_get_archived_file", - sco_archives_etud.etud_get_archived_file, - Permission.ScoView, -) - -sco_publish( - "/etudarchive_import_files_form", - sco_archives_etud.etudarchive_import_files_form, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/etudarchive_generate_excel_sample", - sco_archives_etud.etudarchive_generate_excel_sample, - Permission.ScoView, -) - - -# Debouche / devenir etudiant -sco_publish( - "/itemsuivi_suppress", - sco_debouche.itemsuivi_suppress, - Permission.ScoEtudChangeAdr, - methods=["GET", "POST"], -) -sco_publish( - "/itemsuivi_create", - sco_debouche.itemsuivi_create, - Permission.ScoEtudChangeAdr, - methods=["GET", "POST"], -) -sco_publish( - "/itemsuivi_set_date", - sco_debouche.itemsuivi_set_date, - Permission.ScoEtudChangeAdr, - methods=["GET", "POST"], -) -sco_publish( - "/itemsuivi_set_situation", - sco_debouche.itemsuivi_set_situation, - Permission.ScoEtudChangeAdr, - methods=["GET", "POST"], -) -sco_publish( - "/itemsuivi_list_etud", sco_debouche.itemsuivi_list_etud, Permission.ScoView -) -sco_publish("/itemsuivi_tag_list", sco_debouche.itemsuivi_tag_list, Permission.ScoView) -sco_publish( - "/itemsuivi_tag_search", sco_debouche.itemsuivi_tag_search, Permission.ScoView -) -sco_publish( - "/itemsuivi_tag_set", - sco_debouche.itemsuivi_tag_set, - Permission.ScoEtudChangeAdr, - methods=["GET", "POST"], -) - - -@bp.route("/doAddAnnotation", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEtudAddAnnotations) -@scodoc7func -def doAddAnnotation(etudid, comment): - "ajoute annotation sur etudiant" - etud = Identite.query.get_or_404(etudid) # check existence - if comment: - cnx = ndb.GetDBConnexion() - sco_etud.etud_annotations_create( - cnx, - args={ - "etudid": etudid, - "comment": comment, - "author": current_user.user_name, - }, - ) - logdb(cnx, method="addAnnotation", etudid=etudid) - return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - - -@bp.route("/doSuppressAnnotation", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def doSuppressAnnotation(etudid, annotation_id): - """Suppression annotation.""" - if not sco_permissions_check.can_suppress_annotation(annotation_id): - raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") - - cnx = ndb.GetDBConnexion() - annos = sco_etud.etud_annotations_list(cnx, args={"id": annotation_id}) - if len(annos) != 1: - raise ScoValueError("annotation inexistante !") - anno = annos[0] - log("suppress annotation: %s" % str(anno)) - logdb(cnx, method="SuppressAnnotation", etudid=etudid) - sco_etud.etud_annotations_delete(cnx, annotation_id) - - return flask.redirect( - url_for( - "scolar.ficheEtud", - scodoc_dept=g.scodoc_dept, - etudid=etudid, - head_message="Annotation%%20supprimée", - ) - ) - - -@bp.route("/form_change_coordonnees", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEtudChangeAdr) -@scodoc7func -def form_change_coordonnees(etudid): - "edit coordonnees etudiant" - etud = Identite.query.get_or_404(etudid) - cnx = ndb.GetDBConnexion() - adrs = sco_etud.adresse_list(cnx, {"etudid": etudid}) - if adrs: - adr = adrs[0] - else: - adr = {} # no data for this student - H = [ - f"""{html_sco_header.sco_header( - page_title=f"Changement coordonnées de {etud.nomprenom}" - )} -

            Changement des coordonnées de {etud.nomprenom}

            -

            """ - ] - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("adresse_id", {"input_type": "hidden"}), - ("etudid", {"input_type": "hidden"}), - ( - "email", - { - "size": 40, - "title": "e-mail", - "explanation": "adresse institutionnelle", - }, - ), - ( - "emailperso", - { - "size": 40, - "title": "e-mail", - "explanation": "adresse personnelle", - }, - ), - ( - "domicile", - {"size": 65, "explanation": "numéro, rue", "title": "Adresse"}, - ), - ("codepostaldomicile", {"size": 6, "title": "Code postal"}), - ("villedomicile", {"size": 20, "title": "Ville"}), - ("paysdomicile", {"size": 20, "title": "Pays"}), - ("", {"input_type": "separator", "default": " "}), - ("telephone", {"size": 13, "title": "Téléphone"}), - ("telephonemobile", {"size": 13, "title": "Mobile"}), - ), - initvalues=adr, - submitlabel="Valider le formulaire", - ) - dest_url = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - if tf[0] == 0: - return "\n".join(H) + tf[1] + html_sco_header.sco_footer() - elif tf[0] == -1: - return flask.redirect(dest_url) - else: - if adrs: - sco_etud.adresse_edit(cnx, args=tf[2]) - else: - sco_etud.adresse_create(cnx, args=tf[2]) - logdb(cnx, method="changeCoordonnees", etudid=etudid) - return flask.redirect(dest_url) - - -# --- Gestion des groupes: -sco_publish( - "/affect_groups", - sco_groups_edit.affect_groups, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/XMLgetGroupsInPartition", sco_groups.XMLgetGroupsInPartition, Permission.ScoView -) - -sco_publish( - "/formsemestre_partition_list", - sco_groups.formsemestre_partition_list, - Permission.ScoView, -) - -sco_publish("/setGroups", sco_groups.setGroups, Permission.ScoView, methods=["POST"]) - - -sco_publish( - "/group_rename", - sco_groups.group_rename, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/groups_auto_repartition", - sco_groups.groups_auto_repartition, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/edit_partition_form", - sco_groups.edit_partition_form, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/partition_delete", - sco_groups.partition_delete, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/partition_set_attr", - sco_groups.partition_set_attr, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/partition_move", - sco_groups.partition_move, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/partition_set_name", - sco_groups.partition_set_name, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/partition_rename", - sco_groups.partition_rename, - Permission.ScoView, - methods=["GET", "POST"], -) - -sco_publish( - "/partition_create", - sco_groups.partition_create, - Permission.ScoView, # controle d'access ad-hoc - methods=["GET", "POST"], -) - -# Nouvel éditeur de partitions et groupe, @SebL Jul 2022 -@bp.route("/partition_editor", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def partition_editor(formsemestre_id: int): - formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) - H = [ - html_sco_header.sco_header( - cssstyles=["css/partition_editor.css"], - javascripts=[ - "js/partition_editor.js", - ], - page_title=f"Partitions de {formsemestre.titre_annee()}", - init_datatables=False, - ), - f"""

            -

            - """, - render_template( - "scolar/partition_editor.html", - formsemestre=formsemestre, - read_only=not sco_groups.sco_permissions_check.can_change_groups( - formsemestre_id - ), - ), - html_sco_header.sco_footer(), - ] - - return "\n".join(H) - - -@bp.route("/create_partition_parcours", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def create_partition_parcours(formsemestre_id): - """Création d'une partitions nommée "Parcours" (PARTITION_PARCOURS) - avec un groupe par parcours.""" - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - formsemestre.setup_parcours_groups() - return flask.redirect( - url_for( - "scolar.edit_partition_form", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - - -sco_publish("/etud_info_html", sco_page_etud.etud_info_html, Permission.ScoView) - -# --- Gestion des photos: -sco_publish("/get_photo_image", sco_photos.get_photo_image, Permission.ScoView) - -sco_publish("/etud_photo_html", sco_photos.etud_photo_html, Permission.ScoView) - - -@bp.route("/etud_photo_orig_page") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def etud_photo_orig_page(etudid=None): - "Page with photo in orig. size" - etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] - H = [ - html_sco_header.sco_header(page_title=etud["nomprenom"]), - "

            %s

            " % etud["nomprenom"], - '", - html_sco_header.sco_footer(), - ] - return "\n".join(H) - - -@bp.route("/formChangePhoto", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEtudChangeAdr) -@scodoc7func -def formChangePhoto(etudid=None): - """Formulaire changement photo étudiant""" - etud = sco_etud.get_etud_info(filled=True)[0] - if sco_photos.etud_photo_is_local(etud): - etud["photoloc"] = "dans ScoDoc" - else: - etud["photoloc"] = "externe" - H = [ - html_sco_header.sco_header(page_title="Changement de photo"), - """

            Changement de la photo de %(nomprenom)s

            -

            Photo actuelle (%(photoloc)s): - """ - % etud, - sco_photos.etud_photo_html(etud, title="photo actuelle"), - """

            Le fichier ne doit pas dépasser 500Ko (recadrer l'image, format "portrait" de préférence).

            -

            L'image sera automagiquement réduite pour obtenir une hauteur de 90 pixels.

            - """, - ] - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ("etudid", {"default": etudid, "input_type": "hidden"}), - ( - "photofile", - {"input_type": "file", "title": "Fichier image", "size": 20}, - ), - ), - submitlabel="Valider", - cancelbutton="Annuler", - ) - dest_url = url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] - ) - if tf[0] == 0: - return ( - "\n".join(H) - + tf[1] - + '

            Supprimer cette photo

            ' - % etudid - + html_sco_header.sco_footer() - ) - elif tf[0] == -1: - return flask.redirect(dest_url) - else: - data = tf[2]["photofile"].read() - status, err_msg = sco_photos.store_photo( - etud, data, tf[2]["photofile"].filename - ) - if status: - return flask.redirect(dest_url) - else: - H.append(f"""

            Erreur: {err_msg}

            """) - return "\n".join(H) + html_sco_header.sco_footer() - - -@bp.route("/formSuppressPhoto", methods=["POST", "GET"]) -@scodoc -@permission_required(Permission.ScoEtudChangeAdr) -@scodoc7func -def formSuppressPhoto(etudid=None, dialog_confirmed=False): - """Formulaire suppression photo étudiant""" - etud = Identite.query.get_or_404(etudid) - if not dialog_confirmed: - return scu.confirm_dialog( - f"

            Confirmer la suppression de la photo de {etud.nom_disp()} ?

            ", - dest_url="", - cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id - ), - parameters={"etudid": etud.id}, - ) - - sco_photos.suppress_photo(etud) - - return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id) - ) - - -# -@bp.route("/formDem") -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def formDem(etudid, formsemestre_id): - "Formulaire Démission Etudiant" - return _formDem_of_Def( - etudid, - formsemestre_id, - operation_name="Démission", - operation_method="doDemEtudiant", - ) - - -@bp.route("/formDef") -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def formDef(etudid, formsemestre_id): - "Formulaire Défaillance Etudiant" - return _formDem_of_Def( - etudid, - formsemestre_id, - operation_name="Défaillance", - operation_method="doDefEtudiant", - ) - - -def _formDem_of_Def( - etudid, - formsemestre_id, - operation_name="", - operation_method="", -): - "Formulaire démission ou défaillance Etudiant" - etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - if not sem["etat"]: - raise ScoValueError("Modification impossible: semestre verrouille") - - etud["formsemestre_id"] = formsemestre_id - etud["semtitre"] = sem["titremois"] - etud["nowdmy"] = time.strftime("%d/%m/%Y") - etud["operation_name"] = operation_name - # - header = html_sco_header.sco_header( - page_title="%(operation_name)s de %(nomprenom)s (du semestre %(semtitre)s)" - % etud, - ) - H = [ - '

            %(operation_name)s de %(nomprenom)s (semestre %(semtitre)s)

            ' - % etud - ] - H.append( - """

            - Date de la %s (J/M/AAAA):  - """ - % (operation_method, operation_name.lower()) - ) - H.append( - """ - - - -

            - -

            """ - % etud - ) - return header + "\n".join(H) + html_sco_header.sco_footer() - - -@bp.route("/doDemEtudiant") -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def doDemEtudiant(etudid, formsemestre_id, event_date=None): - "Déclare la démission d'un etudiant dans le semestre" - return _do_dem_or_def_etud( - etudid, - formsemestre_id, - event_date=event_date, - etat_new="D", - operation_method="demEtudiant", - event_type="DEMISSION", - ) - - -@bp.route("/doDefEtudiant") -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def doDefEtudiant(etudid, formsemestre_id, event_date=None): - "Déclare la défaillance d'un etudiant dans le semestre" - return _do_dem_or_def_etud( - etudid, - formsemestre_id, - event_date=event_date, - etat_new=sco_codes_parcours.DEF, - operation_method="defailleEtudiant", - event_type="DEFAILLANCE", - ) - - -def _do_dem_or_def_etud( - etudid, - formsemestre_id, - event_date=None, - etat_new="D", # 'D' or DEF - operation_method="demEtudiant", - event_type="DEMISSION", - redirect=True, -): - "Démission ou défaillance d'un étudiant" - sco_formsemestre_inscriptions.do_formsemestre_demission( - etudid, - formsemestre_id, - event_date=event_date, - etat_new=etat_new, # 'D' or DEF - operation_method=operation_method, - event_type=event_type, - ) - if redirect: - return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - - -@bp.route("/doCancelDem", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def doCancelDem(etudid, formsemestre_id, dialog_confirmed=False, args=None): - "Annule une démission" - return _do_cancel_dem_or_def( - etudid, - formsemestre_id, - dialog_confirmed=dialog_confirmed, - args=args, - operation_name="démission", - etat_current="D", - etat_new="I", - operation_method="cancelDem", - event_type="DEMISSION", - ) - - -@bp.route("/doCancelDef", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def doCancelDef(etudid, formsemestre_id, dialog_confirmed=False, args=None): - "Annule la défaillance de l'étudiant" - return _do_cancel_dem_or_def( - etudid, - formsemestre_id, - dialog_confirmed=dialog_confirmed, - args=args, - operation_name="défaillance", - etat_current=sco_codes_parcours.DEF, - etat_new="I", - operation_method="cancelDef", - event_type="DEFAILLANCE", - ) - - -def _do_cancel_dem_or_def( - etudid, - formsemestre_id, - dialog_confirmed=False, - args=None, - operation_name="", # "démission" ou "défaillance" - etat_current="D", - etat_new="I", - operation_method="cancelDem", - event_type="DEMISSION", -): - "Annule une demission ou une défaillance" - # check lock - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - if not sem["etat"]: - raise ScoValueError("Modification impossible: semestre verrouille") - # verif - info = sco_etud.get_etud_info(etudid, filled=True)[0] - ok = False - for i in info["ins"]: - if i["formsemestre_id"] == formsemestre_id: - if i["etat"] != etat_current: - raise ScoValueError("etudiant non %s !" % operation_name) - ok = True - break - if not ok: - raise ScoValueError("etudiant non inscrit ???") - if not dialog_confirmed: - return scu.confirm_dialog( - "

            Confirmer l'annulation de la %s ?

            " % operation_name, - dest_url="", - cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid - ), - parameters={"etudid": etudid, "formsemestre_id": formsemestre_id}, - ) - # - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - {"etudid": etudid, "formsemestre_id": formsemestre_id} - )[0] - if ins["etat"] != etat_current: - raise ScoException("etudiant non %s !!!" % etat_current) # obviously a bug - ins["etat"] = etat_new - cnx = ndb.GetDBConnexion() - sco_formsemestre_inscriptions.do_formsemestre_inscription_edit( - args=ins, formsemestre_id=formsemestre_id - ) - logdb(cnx, method=operation_method, etudid=etudid) - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - cursor.execute( - "delete from scolar_events where etudid=%(etudid)s and formsemestre_id=%(formsemestre_id)s and event_type='" - + event_type - + "'", - {"etudid": etudid, "formsemestre_id": formsemestre_id}, - ) - cnx.commit() - return flask.redirect( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - - -@bp.route("/etudident_create_form", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def etudident_create_form(): - "formulaire creation individuelle etudiant" - return _etudident_create_or_edit_form(edit=False) - - -@bp.route("/etudident_edit_form", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def etudident_edit_form(): - "formulaire edition individuelle etudiant" - return _etudident_create_or_edit_form(edit=True) - - -def _etudident_create_or_edit_form(edit): - "Le formulaire HTML" - H = [html_sco_header.sco_header()] - F = html_sco_header.sco_footer() - vals = scu.get_request_args() - etudid = vals.get("etudid", None) - cnx = ndb.GetDBConnexion() - descr = [] - if not edit: - # creation nouvel etudiant - initvalues = {} - submitlabel = "Ajouter cet étudiant" - H.append( - """

            Création d'un étudiant

            -

            En général, il est recommandé d'importer les - étudiants depuis Apogée ou via un fichier Excel (menu Inscriptions - dans le semestre). -

            -

            - N'utilisez ce formulaire au cas par cas que pour les cas particuliers - ou si votre établissement n'utilise pas d'autre logiciel de gestion des - inscriptions. -

            -

            L'étudiant créé ne sera pas inscrit. - Pensez à l'inscrire dans un semestre !

            - """ - ) - else: - # edition donnees d'un etudiant existant - # setup form init values - if not etudid: - raise ValueError("missing etudid parameter") - descr.append(("etudid", {"default": etudid, "input_type": "hidden"})) - H.append( - '

            Modification d\'un étudiant (fiche)

            ' - % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) - ) - initvalues = sco_etud.etudident_list(cnx, {"etudid": etudid}) - assert len(initvalues) == 1 - initvalues = initvalues[0] - submitlabel = "Modifier les données" - - vals = scu.get_request_args() - nom = vals.get("nom", None) - if nom is None: - nom = initvalues.get("nom", None) - if nom is None: - infos = [] - else: - prenom = vals.get("prenom", "") - if vals.get("tf_submitted", False) and not prenom: - prenom = initvalues.get("prenom", "") - infos = sco_portal_apogee.get_infos_apogee(nom, prenom) - - if infos: - formatted_infos = [ - """ - -
              """ - ] - nanswers = len(infos) - nmax = 10 # nb max de reponse montrées - infos = infos[:nmax] - for i in infos: - formatted_infos.append("
              • ") - for k in i.keys(): - if k != "nip": - item = "
              • %s : %s
              • " % (k, i[k]) - else: - item = ( - '
              • %s : %s
              • ' - % (k, i[k], i[k]) - ) - formatted_infos.append(item) - - formatted_infos.append("
            1. ") - formatted_infos.append("
            ") - m = "%d étudiants trouvés" % nanswers - if len(infos) != nanswers: - m += " (%d montrés)" % len(infos) - A = """
            -
            Informations Apogée
            -

            %s

            - %s -
            """ % ( - m, - "\n".join(formatted_infos), - ) - else: - A = """

            Pas d'informations d'Apogée

            """ - - require_ine = sco_preferences.get_preference("always_require_ine") - - descr += [ - ("adm_id", {"input_type": "hidden"}), - ("nom", {"size": 25, "title": "Nom", "allow_null": False}), - ("nom_usuel", {"size": 25, "title": "Nom usuel", "allow_null": True}), - ( - "prenom", - { - "size": 25, - "title": "Prénom", - "allow_null": scu.CONFIG.ALLOW_NULL_PRENOM, - }, - ), - ( - "civilite", - { - "input_type": "menu", - "labels": ["Homme", "Femme", "Autre/neutre"], - "allowed_values": ["M", "F", "X"], - "title": "Civilité", - }, - ), - ( - "date_naissance", - { - "title": "Date de naissance", - "input_type": "date", - "explanation": "j/m/a", - "validator": lambda val, _: DMY_REGEXP.match(val) - and (ndb.DateDMYtoISO(val) < datetime.date.today().isoformat()), - }, - ), - ("lieu_naissance", {"title": "Lieu de naissance", "size": 32}), - ("dept_naissance", {"title": "Département de naissance", "size": 5}), - ("nationalite", {"size": 25, "title": "Nationalité"}), - ( - "statut", - { - "size": 25, - "title": "Statut", - "explanation": '("salarie", ...) inutilisé par ScoDoc', - }, - ), - ( - "annee", - { - "size": 5, - "title": "Année admission IUT", - "type": "int", - "allow_null": False, - "explanation": "année 1ere inscription (obligatoire)", - }, - ), - # - ("sep", {"input_type": "separator", "title": "Scolarité antérieure:"}), - ("bac", {"size": 5, "explanation": "série du bac (S, STI, STT, ...)"}), - ( - "specialite", - { - "size": 25, - "title": "Spécialité", - "explanation": "spécialité bac: SVT M, GENIE ELECTRONIQUE, ...", - }, - ), - ( - "annee_bac", - { - "size": 5, - "title": "Année bac", - "type": "int", - "min_value": 1945, - "max_value": datetime.date.today().year + 1, - "explanation": "année obtention du bac", - }, - ), - ( - "math", - { - "size": 3, - "type": "float", - "title": "Note de mathématiques", - "explanation": "note sur 20 en terminale", - }, - ), - ( - "physique", - { - "size": 3, - "type": "float", - "title": "Note de physique", - "explanation": "note sur 20 en terminale", - }, - ), - ( - "anglais", - { - "size": 3, - "type": "float", - "title": "Note d'anglais", - "explanation": "note sur 20 en terminale", - }, - ), - ( - "francais", - { - "size": 3, - "type": "float", - "title": "Note de français", - "explanation": "note sur 20 obtenue au bac", - }, - ), - ( - "type_admission", - { - "input_type": "menu", - "title": "Voie d'admission", - "allowed_values": scu.TYPES_ADMISSION, - }, - ), - ( - "boursier_prec", - { - "input_type": "boolcheckbox", - "labels": ["non", "oui"], - "title": "Boursier ?", - "explanation": "dans le cycle précédent (lycée)", - }, - ), - ( - "rang", - { - "size": 1, - "type": "int", - "title": "Position établissement", - "explanation": "rang de notre établissement dans les voeux du candidat (si connu)", - }, - ), - ( - "qualite", - { - "size": 3, - "type": "float", - "title": "Qualité", - "explanation": "Note de qualité attribuée au dossier (par le jury d'adm.)", - }, - ), - ( - "decision", - { - "input_type": "menu", - "title": "Décision", - "allowed_values": [ - "ADMIS", - "ATTENTE 1", - "ATTENTE 2", - "ATTENTE 3", - "REFUS", - "?", - ], - }, - ), - ( - "score", - { - "size": 3, - "type": "float", - "title": "Score", - "explanation": "score calculé lors de l'admission", - }, - ), - ( - "classement", - { - "size": 3, - "type": "int", - "title": "Classement", - "explanation": "Classement par le jury d'admission (de 1 à N)", - }, - ), - ("apb_groupe", {"size": 15, "title": "Groupe APB ou PS"}), - ( - "apb_classement_gr", - { - "size": 3, - "type": "int", - "title": "Classement", - "explanation": "Classement par le jury dans le groupe ABP ou PS (de 1 à Ng)", - }, - ), - ("rapporteur", {"size": 50, "title": "Enseignant rapporteur"}), - ( - "commentaire", - { - "input_type": "textarea", - "rows": 4, - "cols": 50, - "title": "Note du rapporteur", - }, - ), - ("nomlycee", {"size": 20, "title": "Lycée d'origine"}), - ("villelycee", {"size": 15, "title": "Commune du lycée"}), - ("codepostallycee", {"size": 15, "title": "Code Postal lycée"}), - ( - "codelycee", - { - "size": 15, - "title": "Code Lycée", - "explanation": "Code national établissement du lycée ou établissement d'origine", - }, - ), - ("sep", {"input_type": "separator", "title": "Codes Apogée: (optionnels)"}), - ( - "code_nip", - { - "size": 25, - "title": "Numéro NIP", - "allow_null": True, - "explanation": "numéro identité étudiant (Apogée)", - }, - ), - ( - "code_ine", - { - "size": 25, - "title": "Numéro INE", - "allow_null": not require_ine, - "explanation": "numéro INE", - }, - ), - ( - "dont_check_homonyms", - { - "title": "Autoriser les homonymes", - "input_type": "boolcheckbox", - "explanation": "ne vérifie pas les noms et prénoms proches", - }, - ), - ] - initvalues["dont_check_homonyms"] = False - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - descr, - submitlabel=submitlabel, - cancelbutton="Re-interroger Apogee", - initvalues=initvalues, - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + "

            " + A + F - elif tf[0] == -1: - return "\n".join(H) + tf[1] + "

            " + A + F - # return '\n'.join(H) + '

            annulation

            ' + F - else: - # form submission - if edit: - etudid = tf[2]["etudid"] - else: - etudid = None - ok, NbHomonyms = sco_etud.check_nom_prenom( - cnx, nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid - ) - if not ok: - return ( - "\n".join(H) - + tf_error_message("Nom ou prénom invalide") - + tf[1] - + "

            " - + A - + F - ) - # log('NbHomonyms=%s' % NbHomonyms) - if not tf[2]["dont_check_homonyms"] and NbHomonyms > 0: - return ( - "\n".join(H) - + tf_error_message( - """Attention: il y a déjà un étudiant portant des noms et prénoms proches. Vous pouvez forcer la présence d'un homonyme en cochant "autoriser les homonymes" en bas du formulaire.""" - ) - + tf[1] - + "

            " - + A - + F - ) - - if not edit: - etud = sco_etud.create_etud(cnx, args=tf[2]) - etudid = etud["etudid"] - else: - # modif d'un etudiant - sco_etud.etudident_edit(cnx, tf[2]) - etud = sco_etud.etudident_list(cnx, {"etudid": etudid})[0] - sco_etud.fill_etuds_info([etud]) - # Inval semesters with this student: - to_inval = [s["formsemestre_id"] for s in etud["sems"]] - for formsemestre_id in to_inval: - sco_cache.invalidate_formsemestre( - formsemestre_id=formsemestre_id - ) # > etudident_create_or_edit - # - return flask.redirect("ficheEtud?etudid=" + str(etudid)) - - -@bp.route("/etudident_delete", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def etudident_delete(etudid, dialog_confirmed=False): - "Delete a student" - cnx = ndb.GetDBConnexion() - etuds = sco_etud.etudident_list(cnx, {"etudid": etudid}) - if not etuds: - raise ScoValueError("Étudiant inexistant !") - else: - etud = etuds[0] - sco_etud.fill_etuds_info([etud]) - if not dialog_confirmed: - return scu.confirm_dialog( - """

            Confirmer la suppression de l'étudiant {e[nomprenom]} ?

            -

            -

            Prenez le temps de vérifier - que vous devez vraiment supprimer cet étudiant ! -

            -

            Cette opération irréversible - efface toute trace de l'étudiant: inscriptions, notes, absences... - dans tous les semestres qu'il a fréquenté. -

            -

            Dans la plupart des cas, vous avez seulement besoin de le

              désinscrire
            - d'un semestre ? (dans ce cas passez par sa fiche, menu associé au semestre)

            - -

            Vérifier la fiche de {e[nomprenom]} -

            """.format( - e=etud, - fiche_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid - ), - ), - dest_url="", - cancel_url=url_for( - "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid - ), - OK="Supprimer définitivement cet étudiant", - parameters={"etudid": etudid}, - ) - log("etudident_delete: etudid=%(etudid)s nomprenom=%(nomprenom)s" % etud) - # delete in all tables ! - tables = [ - "notes_appreciations", - "scolar_autorisation_inscription", - "scolar_formsemestre_validation", - "scolar_events", - "notes_notes_log", - "notes_notes", - "notes_moduleimpl_inscription", - "notes_formsemestre_inscription", - "group_membership", - "etud_annotations", - "scolog", - "admissions", - "adresse", - "absences", - "absences_notifications", - "billet_absence", - ] - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - for table in tables: - cursor.execute("delete from %s where etudid=%%(etudid)s" % table, etud) - cursor.execute("delete from identite where id=%(etudid)s", etud) - cnx.commit() - # Inval semestres où il était inscrit: - to_inval = [s["formsemestre_id"] for s in etud["sems"]] - for formsemestre_id in to_inval: - sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) # > - return flask.redirect(scu.ScoURL() + r"?head_message=Etudiant%20supprimé") - - -@bp.route("/check_group_apogee") -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def check_group_apogee(group_id, etat=None, fix=False, fixmail=False): - """Verification des codes Apogee et mail de tout un groupe. - Si fix == True, change les codes avec Apogée. - - XXX A re-écrire pour API 2: prendre liste dans l'étape et vérifier à partir de cela. - """ - etat = etat or None - members, group, _, sem, _ = sco_groups.get_group_infos(group_id, etat=etat) - formsemestre_id = group["formsemestre_id"] - - cnx = ndb.GetDBConnexion() - H = [ - html_sco_header.html_sem_header( - "Étudiants du %s" % (group["group_name"] or "semestre") - ), - '', - "", - ] - nerrs = 0 # nombre d'anomalies détectées - nfix = 0 # nb codes changes - nmailmissing = 0 # nb etuds sans mail - for t in members: - nom, nom_usuel, prenom, etudid, email, code_nip = ( - t["nom"], - t["nom_usuel"], - t["prenom"], - t["etudid"], - t["email"], - t["code_nip"], - ) - infos = sco_portal_apogee.get_infos_apogee(nom, prenom) - if not infos: - info_apogee = ( - 'Pas d\'information (Modifier identité)' - % etudid - ) - nerrs += 1 - else: - if len(infos) == 1: - nip_apogee = infos[0]["nip"] - if code_nip != nip_apogee: - if fix: - # Update database - sco_etud.identite_edit( - cnx, - args={"etudid": etudid, "code_nip": nip_apogee}, - ) - info_apogee = ( - 'copié %s' % nip_apogee - ) - nfix += 1 - else: - info_apogee = '%s' % nip_apogee - nerrs += 1 - else: - info_apogee = "ok" - else: - info_apogee = ( - '%d correspondances (Choisir)' - % (len(infos), etudid) - ) - nerrs += 1 - # check mail - if email: - mailstat = "ok" - else: - if fixmail and len(infos) == 1 and "mail" in infos[0]: - mail_apogee = infos[0]["mail"] - adrs = sco_etud.adresse_list(cnx, {"etudid": etudid}) - if adrs: - adr = adrs[0] # modif adr existante - args = {"adresse_id": adr["adresse_id"], "email": mail_apogee} - sco_etud.adresse_edit(cnx, args=args, disable_notify=True) - else: - # creation adresse - args = {"etudid": etudid, "email": mail_apogee} - sco_etud.adresse_create(cnx, args=args) - mailstat = 'copié' - else: - mailstat = "inconnu" - nmailmissing += 1 - H.append( - '' - % ( - url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), - nom, - nom_usuel, - prenom, - mailstat, - code_nip, - info_apogee, - ) - ) - H.append("
            NomNom usuelPrénomMailNIP (ScoDoc)Apogée
            %s%s%s%s%s%s
            ") - H.append("
              ") - if nfix: - H.append("
            • %d codes modifiés
            • " % nfix) - H.append("
            • Codes NIP: %d anomalies détectées
            • " % nerrs) - H.append("
            • Adresse mail: %d étudiants sans adresse
            • " % nmailmissing) - H.append("
            ") - H.append( - """ -
            - - - - - -
            -

            Retour au semestre - """ - % ( - request.base_url, - formsemestre_id, - scu.strnone(group_id), - scu.strnone(etat), - formsemestre_id, - ) - ) - H.append( - """ -

            - - - - - -
            -

            Retour au semestre - """ - % ( - request.base_url, - formsemestre_id, - scu.strnone(group_id), - scu.strnone(etat), - formsemestre_id, - ) - ) - - return "\n".join(H) + html_sco_header.sco_footer() - - -@bp.route("/form_students_import_excel", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def form_students_import_excel(formsemestre_id=None): - "formulaire import xls" - formsemestre_id = int(formsemestre_id) if formsemestre_id else None - if formsemestre_id: - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - dest_url = ( - # scu.ScoURL() + "/formsemestre_status?formsemestre_id=%s" % formsemestre_id # TODO: Remplacer par for_url ? - url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ) - ) - else: - sem = None - dest_url = scu.ScoURL() - if sem and not sem["etat"]: - raise ScoValueError("Modification impossible: semestre verrouille") - H = [ - html_sco_header.sco_header(page_title="Import etudiants"), - """

            Téléchargement d\'une nouvelle liste d\'etudiants

            -
            -

            A utiliser pour importer de nouveaux étudiants (typiquement au - premier semestre).

            -

            Si les étudiants à inscrire sont déjà dans un autre - semestre, utiliser le menu "Inscriptions (passage des étudiants) - depuis d'autres semestres à partir du semestre destination. -

            -

            Si vous avez un portail Apogée, il est en général préférable d'importer les - étudiants depuis Apogée, via le menu "Synchroniser avec étape Apogée". -

            -
            -

            - L'opération se déroule en deux étapes. Dans un premier temps, - vous téléchargez une feuille Excel type. Vous devez remplir - cette feuille, une ligne décrivant chaque étudiant. Ensuite, - vous indiquez le nom de votre fichier dans la case "Fichier Excel" - ci-dessous, et cliquez sur "Télécharger" pour envoyer au serveur - votre liste. -

            - """, - ] # ' - if sem: - H.append( - """

            Les étudiants importés seront inscrits dans - le semestre %s

            """ - % sem["titremois"] - ) - else: - H.append( - """ -

            Pour inscrire directement les étudiants dans un semestre de - formation, il suffit d'indiquer le code de ce semestre - (qui doit avoir été créé au préalable). Cliquez ici pour afficher les codes -

            - """ - % (scu.ScoURL()) - ) - - H.append("""
            1. """) - if formsemestre_id: - H.append( - """ - - """ - ) - else: - H.append("""""") - H.append( - """Obtenir la feuille excel à remplir
            2. -
            3. """ - ) - - F = html_sco_header.sco_footer() - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ( - "csvfile", - {"title": "Fichier Excel:", "input_type": "file", "size": 40}, - ), - ( - "check_homonyms", - { - "title": "Vérifier les homonymes", - "input_type": "boolcheckbox", - "explanation": "arrète l'importation si plus de 10% d'homonymes", - }, - ), - ( - "require_ine", - { - "title": "Importer INE", - "input_type": "boolcheckbox", - "explanation": "n'importe QUE les étudiants avec nouveau code INE", - }, - ), - ("formsemestre_id", {"input_type": "hidden"}), - ), - initvalues={"check_homonyms": True, "require_ine": False}, - submitlabel="Télécharger", - ) - S = [ - """

              Le fichier Excel décrivant les étudiants doit comporter les colonnes suivantes. -

              Les colonnes peuvent être placées dans n'importe quel ordre, mais -le titre exact (tel que ci-dessous) doit être sur la première ligne. -

              -

              -Les champs avec un astérisque (*) doivent être présents (nulls non autorisés). -

              - - -

              - -""" - ] - for t in sco_import_etuds.sco_import_format( - with_codesemestre=(formsemestre_id == None) - ): - if int(t[3]): - ast = "" - else: - ast = "*" - S.append( - "" - % (t[0], t[1], t[4], ast) - ) - if tf[0] == 0: - return "\n".join(H) + tf[1] + "" + "\n".join(S) + F - elif tf[0] == -1: - return flask.redirect(dest_url) - else: - return sco_import_etuds.students_import_excel( - tf[2]["csvfile"], - formsemestre_id=int(formsemestre_id) if formsemestre_id else None, - check_homonyms=tf[2]["check_homonyms"], - require_ine=tf[2]["require_ine"], - ) - - -@bp.route("/import_generate_excel_sample") -@scodoc -@permission_required(Permission.ScoEtudInscrit) -@scodoc7func -def import_generate_excel_sample(with_codesemestre="1"): - "une feuille excel pour importation etudiants" - if with_codesemestre: - with_codesemestre = int(with_codesemestre) - else: - with_codesemestre = 0 - format = sco_import_etuds.sco_import_format() - data = sco_import_etuds.sco_import_generate_excel_sample( - format, with_codesemestre, exclude_cols=["photo_filename"] - ) - return scu.send_file( - data, "ImportEtudiants", scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE - ) - # return sco_excel.send_excel_file(data, "ImportEtudiants" + scu.XLSX_SUFFIX) - - -# --- Données admission -@bp.route("/import_generate_admission_sample") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def import_generate_admission_sample(formsemestre_id): - "une feuille excel pour importation données admissions" - group = sco_groups.get_group(sco_groups.get_default_group(formsemestre_id)) - fmt = sco_import_etuds.sco_import_format() - data = sco_import_etuds.sco_import_generate_excel_sample( - fmt, - only_tables=["identite", "admissions", "adresse"], - exclude_cols=["nationalite", "foto", "photo_filename"], - group_ids=[group["group_id"]], - ) - return scu.send_file( - data, "AdmissionEtudiants", scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE - ) - # return sco_excel.send_excel_file(data, "AdmissionEtudiants" + scu.XLSX_SUFFIX) - - -# --- Données admission depuis fichier excel (version nov 2016) -@bp.route("/form_students_import_infos_admissions", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def form_students_import_infos_admissions(formsemestre_id=None): - "formulaire import xls" - authuser = current_user - F = html_sco_header.sco_footer() - if not authuser.has_permission(Permission.ScoEtudInscrit): - # autorise juste l'export - H = [ - html_sco_header.sco_header( - page_title="Export données admissions (Parcoursup ou autre)", - ), - """

              Téléchargement des informations sur l'admission des étudiants

              -

              - Exporter les informations de ScoDoc (classeur Excel) (ce fichier peut être ré-importé après d'éventuelles modifications) -

              -

              Vous n'avez pas le droit d'importer les données

              - """ - % {"formsemestre_id": formsemestre_id}, - ] - return "\n".join(H) + F - - # On a le droit d'importer: - H = [ - html_sco_header.sco_header(page_title="Import données admissions Parcoursup"), - """

              Téléchargement des informations sur l'admission des étudiants depuis feuilles import Parcoursup

              -
              -

              A utiliser pour renseigner les informations sur l'origine des étudiants (lycées, bac, etc). Ces informations sont facultatives mais souvent utiles pour mieux connaitre les étudiants et aussi pour effectuer des statistiques (résultats suivant le type de bac...). Les données sont affichées sur les fiches individuelles des étudiants.

              -
              -

              - Importer ici la feuille excel utilisée pour envoyer le classement Parcoursup. - Seuls les étudiants actuellement inscrits dans ce semestre ScoDoc seront affectés, - les autres lignes de la feuille seront ignorées. Et seules les colonnes intéressant ScoDoc - seront importées: il est inutile d'éliminer les autres. -
              - Seules les données "admission" seront modifiées (et pas l'identité de l'étudiant). -
              - Les colonnes "nom" et "prenom" sont requises, ou bien une colonne "etudid". -

              -

              - Avant d'importer vos données, il est recommandé d'enregistrer les informations actuelles: - exporter les données actuelles de ScoDoc (ce fichier peut être ré-importé après d'éventuelles modifications) -

              - """ - % {"formsemestre_id": formsemestre_id}, - ] # ' - - type_admission_list = ( - "Autre", - "Parcoursup", - "Parcoursup PC", - "APB", - "APB PC", - "CEF", - "Direct", - ) - - tf = TrivialFormulator( - request.base_url, - scu.get_request_args(), - ( - ( - "csvfile", - {"title": "Fichier Excel:", "input_type": "file", "size": 40}, - ), - ( - "type_admission", - { - "title": "Type d'admission", - "explanation": "sera attribué aux étudiants modifiés par cet import n'ayant pas déjà un type", - "input_type": "menu", - "allowed_values": type_admission_list, - }, - ), - ("formsemestre_id", {"input_type": "hidden"}), - ), - submitlabel="Télécharger", - ) - - help_text = ( - """

              Les colonnes importables par cette fonction sont indiquées dans la table ci-dessous. - Seule la première feuille du classeur sera utilisée. -

              - """ - + sco_import_etuds.adm_table_description_format().html() - + """
              """ - ) - - if tf[0] == 0: - return "\n".join(H) + tf[1] + help_text + F - elif tf[0] == -1: - return flask.redirect( - scu.ScoURL() - + "/formsemestre_status?formsemestre_id=" - + str(formsemestre_id) - ) - else: - return sco_import_etuds.students_import_admission( - tf[2]["csvfile"], - type_admission=tf[2]["type_admission"], - formsemestre_id=formsemestre_id, - ) - - -@bp.route("/formsemestre_import_etud_admission") -@scodoc -@permission_required(Permission.ScoEtudChangeAdr) -@scodoc7func -def formsemestre_import_etud_admission(formsemestre_id, import_email=True): - """Reimporte donnees admissions par synchro Portail Apogée""" - ( - no_nip, - unknowns, - changed_mails, - ) = sco_synchro_etuds.formsemestre_import_etud_admission( - formsemestre_id, import_identite=True, import_email=import_email - ) - H = [ - html_sco_header.html_sem_header("Reimport données admission"), - "

              Opération effectuée

              ", - ] - if no_nip: - H.append("

              Attention: étudiants sans NIP: " + str(no_nip) + "

              ") - if unknowns: - H.append( - "

              Attention: étudiants inconnus du portail: codes NIP=" - + str(unknowns) - + "

              " - ) - if changed_mails: - H.append("

              Adresses mails modifiées:

              ") - for (info, new_mail) in changed_mails: - H.append( - "%s: %s devient %s
              " - % (info["nom"], info["email"], new_mail) - ) - return "\n".join(H) + html_sco_header.sco_footer() - - -sco_publish( - "/photos_import_files_form", - sco_trombino.photos_import_files_form, - Permission.ScoEtudChangeAdr, - methods=["GET", "POST"], -) -sco_publish( - "/photos_generate_excel_sample", - sco_trombino.photos_generate_excel_sample, - Permission.ScoEtudChangeAdr, -) - -# --- Statistiques -@bp.route("/stat_bac") -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def stat_bac(formsemestre_id): - "Renvoie statistisques sur nb d'etudiants par bac" - cnx = ndb.GetDBConnexion() - ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( - args={"formsemestre_id": formsemestre_id} - ) - Bacs = {} # type bac : nb etud - for i in ins: - etud = sco_etud.etudident_list(cnx, {"etudid": i["etudid"]})[0] - typebac = "%(bac)s %(specialite)s" % etud - Bacs[typebac] = Bacs.get(typebac, 0) + 1 - return Bacs - - -# --- Dump (assistance) -@bp.route("/sco_dump_and_send_db", methods=["GET", "POST"]) -@scodoc -@permission_required(Permission.ScoView) -@scodoc7func -def sco_dump_and_send_db(message="", request_url="", traceback_str_base64=""): - "Send anonymized data to supervision" - - status_code = sco_dump_db.sco_dump_and_send_db( - message, request_url, traceback_str_base64=traceback_str_base64 - ) - H = [html_sco_header.sco_header(page_title="Assistance technique")] - if status_code == requests.codes.INSUFFICIENT_STORAGE: # pylint: disable=no-member - H.append( - """

              - Erreur: espace serveur trop plein. - Merci de contacter {0}

              """.format( - scu.SCO_DEV_MAIL - ) - ) - elif status_code == requests.codes.OK: # pylint: disable=no-member - H.append("""

              Opération effectuée.

              """) - else: - H.append( - f"""

              - Erreur: code {status_code} - Merci de contacter {scu.SCO_DEV_MAIL}

              """ - ) - flash("Données envoyées au serveur d'assistance") - return "\n".join(H) + html_sco_header.sco_footer() +# -*- coding: utf-8 -*- +############################################################################## +# +# ScoDoc +# +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Emmanuel Viennet emmanuel.viennet@viennet.net +# +############################################################################## + +""" +Module scolar: vues de .../ScoDoc//Scolarite + +issu de ScoDoc7 / ZScolar.py + +Emmanuel Viennet, 2021 +""" +import datetime +import requests +import time + +import flask +from flask import jsonify, url_for, flash, render_template, make_response +from flask import g, request +from flask_login import current_user +from flask_wtf import FlaskForm +from flask_wtf.file import FileField, FileAllowed +from wtforms import SubmitField + +from app import db +from app import log +from app.decorators import ( + scodoc, + scodoc7func, + permission_required, + permission_required_compat_scodoc7, + admin_required, + login_required, +) +from app.models.etudiants import Identite +from app.models.etudiants import make_etud_args +from app.models.events import ScolarNews, Scolog +from app.models.formsemestre import FormSemestre +from app.models.validations import ScolarEvent +from app.views import scolar_bp as bp +from app.views import ScoData + +import app.scodoc.sco_utils as scu +import app.scodoc.notesdb as ndb +from app.scodoc.scolog import logdb +from app.scodoc.sco_permissions import Permission +from app.scodoc.sco_exceptions import ( + AccessDenied, + ScoException, + ScoValueError, +) + +from app.scodoc.TrivialFormulator import DMY_REGEXP, TrivialFormulator, tf_error_message +from app.scodoc.gen_tables import GenTable +from app.scodoc import html_sco_header +from app.scodoc import sco_import_etuds +from app.scodoc import sco_archives_etud +from app.scodoc import sco_codes_parcours +from app.scodoc import sco_cache +from app.scodoc import sco_debouche +from app.scodoc import sco_dept +from app.scodoc import sco_dump_db +from app.scodoc import sco_etud +from app.scodoc import sco_find_etud +from app.scodoc import sco_formsemestre +from app.scodoc import sco_formsemestre_inscriptions +from app.scodoc import sco_groups +from app.scodoc import sco_groups_edit +from app.scodoc import sco_groups_exports +from app.scodoc import sco_groups_view +from app.scodoc import sco_page_etud +from app.scodoc import sco_permissions_check +from app.scodoc import sco_photos +from app.scodoc import sco_portal_apogee +from app.scodoc import sco_preferences +from app.scodoc import sco_synchro_etuds +from app.scodoc import sco_trombino +from app.scodoc import sco_trombino_tours +from app.scodoc import sco_up_to_date + + +def sco_publish(route, function, permission, methods=["GET"]): + """Declare a route for a python function, + protected by permission and called following ScoDoc 7 Zope standards. + """ + return bp.route(route, methods=methods)( + scodoc(permission_required(permission)(scodoc7func(function))) + ) + + +# -------------------------------------------------------------------- +# +# SCOLARITE (/ScoDoc//Scolarite/...) +# +# -------------------------------------------------------------------- + + +# -------------------------------------------------------------------- +# +# PREFERENCES +# +# -------------------------------------------------------------------- + + +@bp.route("/edit_preferences", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoChangePreferences) +@scodoc7func +def edit_preferences(): + """Edit global preferences (lien "Paramétrage" département)""" + return sco_preferences.get_base_preferences().edit() + + +@bp.route("/formsemestre_edit_preferences", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def formsemestre_edit_preferences(formsemestre_id): + """Edit preferences for a semestre""" + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + ok = ( + current_user.has_permission(Permission.ScoImplement) + or ((current_user.id in sem["responsables"]) and sem["resp_can_edit"]) + ) and (sem["etat"]) + if ok: + return sco_preferences.SemPreferences(formsemestre_id=formsemestre_id).edit() + else: + raise AccessDenied( + "Modification impossible pour %s" % current_user.get_nomplogin() + ) + + +@bp.route("/doc_preferences") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def doc_preferences(): + """List preferences for wiki documentation""" + response = make_response(sco_preferences.doc_preferences()) + response.headers["Content-Type"] = "text/plain" + return response + + +class DeptLogosConfigurationForm(FlaskForm): + "Panneau de configuration logos dept" + + logo_header = FileField( + label="Modifier l'image:", + description="logo placé en haut des documents PDF", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", + ) + ], + ) + + logo_footer = FileField( + label="Modifier l'image:", + description="logo placé en pied des documents PDF", + validators=[ + FileAllowed( + scu.LOGOS_IMAGES_ALLOWED_TYPES, + f"n'accepte que les fichiers image {','.join(scu.LOGOS_IMAGES_ALLOWED_TYPES)}", + ) + ], + ) + + submit = SubmitField("Enregistrer") + + +# @bp.route("/config_logos", methods=["GET", "POST"]) +# @permission_required(Permission.ScoChangePreferences) +# def config_logos(scodoc_dept): +# "Panneau de configuration général" +# form = DeptLogosConfigurationForm() +# if form.validate_on_submit(): +# if form.logo_header.data: +# sco_logos.store_image( +# form.logo_header.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" +# ), +# ) +# if form.logo_footer.data: +# sco_logos.store_image( +# form.logo_footer.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" +# ), +# ) +# app.clear_scodoc_cache() +# flash(f"Logos enregistrés") +# return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) +# +# return render_template( +# "configuration.html", +# title="Configuration Logos du département", +# form=form, +# scodoc_dept=scodoc_dept, +# ) +# +# +# class DeptLogosConfigurationForm(FlaskForm): +# "Panneau de configuration logos dept" +# +# logo_header = FileField( +# label="Modifier l'image:", +# description="logo placé en haut des documents PDF", +# validators=[ +# FileAllowed( +# scu.LOGOS_IMAGES_ALLOWED_TYPES, +# f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", +# ) +# ], +# ) +# +# logo_footer = FileField( +# label="Modifier l'image:", +# description="logo placé en pied des documents PDF", +# validators=[ +# FileAllowed( +# scu.LOGOS_IMAGES_ALLOWED_TYPES, +# f"n'accepte que les fichiers image {','.join([e for e in scu.LOGOS_IMAGES_ALLOWED_TYPES])}", +# ) +# ], +# ) +# +# submit = SubmitField("Enregistrer") + + +# @bp.route("/config_logos", methods=["GET", "POST"]) +# @permission_required(Permission.ScoChangePreferences) +# def config_logos(scodoc_dept): +# "Panneau de configuration général" +# form = DeptLogosConfigurationForm() +# if form.validate_on_submit(): +# if form.logo_header.data: +# sco_logos.store_image( +# form.logo_header.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_header" +# ), +# ) +# if form.logo_footer.data: +# sco_logos.store_image( +# form.logo_footer.data, +# os.path.join( +# scu.SCODOC_LOGOS_DIR, "logos_" + scodoc_dept, "logo_footer" +# ), +# ) +# app.clear_scodoc_cache() +# flash(f"Logos enregistrés") +# return flask.redirect(url_for("scolar.index_html", scodoc_dept=scodoc_dept)) +# +# return render_template( +# "configuration.html", +# title="Configuration Logos du département", +# form=form, +# scodoc_dept=scodoc_dept, +# ) + + +# -------------------------------------------------------------------- +# +# ETUDIANTS +# +# -------------------------------------------------------------------- + + +@bp.route("/showEtudLog") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def showEtudLog(etudid, format="html"): + """Display log of operations on this student""" + etud = sco_etud.get_etud_info(filled=True)[0] + + ops = sco_etud.list_scolog(etudid) + + tab = GenTable( + titles={ + "date": "Date", + "authenticated_user": "Utilisateur", + "remote_addr": "IP", + "method": "Opération", + "msg": "Message", + }, + columns_ids=("date", "authenticated_user", "remote_addr", "method", "msg"), + rows=ops, + html_sortable=True, + html_class="table_leftalign", + base_url="%s?etudid=%s" % (request.base_url, etudid), + page_title="Opérations sur %(nomprenom)s" % etud, + html_title="

              Opérations effectuées sur l'étudiant %(nomprenom)s

              " % etud, + filename="log_" + scu.make_filename(etud["nomprenom"]), + html_next_section=f""" + """, + preferences=sco_preferences.SemPreferences(), + ) + + return tab.make_page(format=format) + + +# ---------- PAGE ACCUEIL (listes) -------------- + + +@bp.route("/", alias=True) +@bp.route("/index_html") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def index_html(showcodes=0, showsemtable=0): + return sco_dept.index_html(showcodes=showcodes, showsemtable=showsemtable) + + +@bp.route("/install_info") +@scodoc +@permission_required(Permission.ScoView) +def install_info(): + """Information on install status (html str)""" + return sco_up_to_date.is_up_to_date() + + +@bp.route("/dept_news") +@scodoc +@permission_required(Permission.ScoView) +def dept_news(): + "Affiche table des dernières opérations" + return render_template( + "dept_news.html", title=f"Opérations {g.scodoc_dept}", sco=ScoData() + ) + + +@bp.route("/dept_news_json") +@scodoc +@permission_required(Permission.ScoView) +def dept_news_json(): + "Table des news du département" + start = request.args.get("start", type=int) + length = request.args.get("length", type=int) + + log(f"dept_news_json( start={start}, length={length})") + query = ScolarNews.query.filter_by(dept_id=g.scodoc_dept_id) + # search + search = request.args.get("search[value]") + if search: + query = query.filter( + db.or_( + ScolarNews.authenticated_user.like(f"%{search}%"), + ScolarNews.text.like(f"%{search}%"), + ) + ) + total_filtered = query.count() + # sorting + order = [] + i = 0 + while True: + col_index = request.args.get(f"order[{i}][column]") + if col_index is None: + break + col_name = request.args.get(f"columns[{col_index}][data]") + if col_name not in ["date", "type", "authenticated_user"]: + col_name = "date" + descending = request.args.get(f"order[{i}][dir]") == "desc" + col = getattr(ScolarNews, col_name) + if descending: + col = col.desc() + order.append(col) + i += 1 + if order: + query = query.order_by(*order) + + # pagination + query = query.offset(start).limit(length) + data = [news.to_dict() for news in query] + # response + return { + "data": data, + "recordsFiltered": total_filtered, + "recordsTotal": ScolarNews.query.count(), + "draw": request.args.get("draw", type=int), + } + + +sco_publish( + "/trombino", sco_trombino.trombino, Permission.ScoView, methods=["GET", "POST"] +) + +sco_publish( + "/pdf_trombino_tours", sco_trombino_tours.pdf_trombino_tours, Permission.ScoView +) + +sco_publish( + "/pdf_feuille_releve_absences", + sco_trombino_tours.pdf_feuille_releve_absences, + Permission.ScoView, +) + +sco_publish( + "/trombino_copy_photos", + sco_trombino.trombino_copy_photos, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/groups_export_annotations", + sco_groups_exports.groups_export_annotations, + Permission.ScoView, +) + + +@bp.route("/groups_view") +@scodoc +@permission_required_compat_scodoc7(Permission.ScoView) +# @permission_required(Permission.ScoView) +@scodoc7func +def groups_view( + group_ids=(), + format="html", + # Options pour listes: + with_codes=0, + etat=None, + with_paiement=0, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) + with_archives=0, # ajoute colonne avec noms fichiers archivés + with_annotations=0, + formsemestre_id=None, +): + return sco_groups_view.groups_view( + group_ids=group_ids, + format=format, + # Options pour listes: + with_codes=with_codes, + etat=etat, + with_paiement=with_paiement, # si vrai, ajoute colonnes infos paiement droits et finalisation inscription (lent car interrogation portail) + with_archives=with_archives, # ajoute colonne avec noms fichiers archivés + with_annotations=with_annotations, + formsemestre_id=formsemestre_id, + ) + + +sco_publish( + "/export_groups_as_moodle_csv", + sco_groups_view.export_groups_as_moodle_csv, + Permission.ScoView, +) + + +# -------------------------- INFOS SUR ETUDIANTS -------------------------- +@bp.route("/getEtudInfo") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def getEtudInfo(etudid=False, code_nip=False, filled=False, format=None): + """infos sur un etudiant (API) + On peut specifier etudid ou code_nip + ou bien cherche dans les arguments de la requête: etudid, code_nip, code_ine + (dans cet ordre). + """ + etud = sco_etud.get_etud_info(etudid=etudid, code_nip=code_nip, filled=filled) + if format is None: + return etud + else: + return scu.sendResult(etud, name="etud", format=format) + + +sco_publish( + "/search_etud_in_dept", + sco_find_etud.search_etud_in_dept, + Permission.ScoView, + methods=["GET", "POST"], +) + + +@bp.route("/search_etud_by_name") +@bp.route("/Notes/search_etud_by_name") # for JS apis +@scodoc +@permission_required(Permission.ScoView) +def search_etud_by_name(): + term = request.args["term"] + data = sco_find_etud.search_etud_by_name(term) + return jsonify(data) + + +# XMLgetEtudInfos était le nom dans l'ancienne API ScoDoc 6 +@bp.route("/etud_info", methods=["GET", "POST"]) # pour compat anciens clients PHP) +@bp.route( + "/XMLgetEtudInfos", methods=["GET", "POST"] +) # pour compat anciens clients PHP) +@bp.route( + "/Absences/XMLgetEtudInfos", methods=["GET", "POST"] +) # pour compat anciens clients PHP +@bp.route( + "/Notes/XMLgetEtudInfos", methods=["GET", "POST"] +) # pour compat anciens clients PHP +@scodoc +@permission_required_compat_scodoc7(Permission.ScoView) +@scodoc7func +def etud_info(etudid=None, format="xml"): + "Donne les informations sur un etudiant" + if not format in ("xml", "json"): + raise ScoValueError("format demandé non supporté par cette fonction.") + t0 = time.time() + args = make_etud_args(etudid=etudid) + cnx = ndb.GetDBConnexion() + etuds = sco_etud.etudident_list(cnx, args) + if not etuds: + # etudiant non trouvé: message d'erreur + d = { + "etudid": etudid, + "nom": "?", + "nom_usuel": "", + "prenom": "?", + "civilite": "?", + "sexe": "?", # for backward compat + "email": "?", + "emailperso": "", + "error": "code etudiant inconnu", + } + return scu.sendResult( + d, name="etudiant", format=format, force_outer_xml_tag=False + ) + d = {} + etud = etuds[0] + sco_etud.fill_etuds_info([etud]) + etud["date_naissance_iso"] = ndb.DateDMYtoISO(etud["date_naissance"]) + for a in ( + "etudid", + "code_nip", + "code_ine", + "nom", + "nom_usuel", + "prenom", + "nomprenom", + "email", + "emailperso", + "domicile", + "codepostaldomicile", + "villedomicile", + "paysdomicile", + "telephone", + "telephonemobile", + "fax", + "bac", + "specialite", + "annee_bac", + "nomlycee", + "villelycee", + "codepostallycee", + "codelycee", + "date_naissance_iso", + ): + d[a] = etud[a] # ne pas quoter car ElementTree.tostring quote déjà + d["civilite"] = etud["civilite_str"] # exception: ne sort pas la civilite brute + d["sexe"] = d["civilite"] # backward compat pour anciens clients + d["photo_url"] = sco_photos.etud_photo_url(etud) + + sem = etud["cursem"] + if sem: + sco_groups.etud_add_group_infos(etud, sem["formsemestre_id"] if sem else None) + d["insemestre"] = [ + { + "current": "1", + "formsemestre_id": sem["formsemestre_id"], + "date_debut": ndb.DateDMYtoISO(sem["date_debut"]), + "date_fin": ndb.DateDMYtoISO(sem["date_fin"]), + "etat": sem["ins"]["etat"], + "groupes": etud["groupes"], # slt pour semestre courant + } + ] + else: + d["insemestre"] = [] + for sem in etud["sems"]: + if sem != etud["cursem"]: + d["insemestre"].append( + { + "formsemestre_id": sem["formsemestre_id"], + "date_debut": ndb.DateDMYtoISO(sem["date_debut"]), + "date_fin": ndb.DateDMYtoISO(sem["date_fin"]), + "etat": sem["ins"]["etat"], + } + ) + + log("etud_info (%gs)" % (time.time() - t0)) + return scu.sendResult( + d, name="etudiant", format=format, force_outer_xml_tag=False, quote_xml=False + ) + + +# -------------------------- FICHE ETUDIANT -------------------------- +sco_publish("/ficheEtud", sco_page_etud.ficheEtud, Permission.ScoView) + +sco_publish( + "/etud_upload_file_form", + sco_archives_etud.etud_upload_file_form, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/etud_delete_archive", + sco_archives_etud.etud_delete_archive, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/etud_get_archived_file", + sco_archives_etud.etud_get_archived_file, + Permission.ScoView, +) + +sco_publish( + "/etudarchive_import_files_form", + sco_archives_etud.etudarchive_import_files_form, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/etudarchive_generate_excel_sample", + sco_archives_etud.etudarchive_generate_excel_sample, + Permission.ScoView, +) + + +# Debouche / devenir etudiant +sco_publish( + "/itemsuivi_suppress", + sco_debouche.itemsuivi_suppress, + Permission.ScoEtudChangeAdr, + methods=["GET", "POST"], +) +sco_publish( + "/itemsuivi_create", + sco_debouche.itemsuivi_create, + Permission.ScoEtudChangeAdr, + methods=["GET", "POST"], +) +sco_publish( + "/itemsuivi_set_date", + sco_debouche.itemsuivi_set_date, + Permission.ScoEtudChangeAdr, + methods=["GET", "POST"], +) +sco_publish( + "/itemsuivi_set_situation", + sco_debouche.itemsuivi_set_situation, + Permission.ScoEtudChangeAdr, + methods=["GET", "POST"], +) +sco_publish( + "/itemsuivi_list_etud", sco_debouche.itemsuivi_list_etud, Permission.ScoView +) +sco_publish("/itemsuivi_tag_list", sco_debouche.itemsuivi_tag_list, Permission.ScoView) +sco_publish( + "/itemsuivi_tag_search", sco_debouche.itemsuivi_tag_search, Permission.ScoView +) +sco_publish( + "/itemsuivi_tag_set", + sco_debouche.itemsuivi_tag_set, + Permission.ScoEtudChangeAdr, + methods=["GET", "POST"], +) + + +@bp.route("/doAddAnnotation", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEtudAddAnnotations) +@scodoc7func +def doAddAnnotation(etudid, comment): + "ajoute annotation sur etudiant" + etud = Identite.query.get_or_404(etudid) # check existence + if comment: + cnx = ndb.GetDBConnexion() + sco_etud.etud_annotations_create( + cnx, + args={ + "etudid": etudid, + "comment": comment, + "author": current_user.user_name, + }, + ) + logdb(cnx, method="addAnnotation", etudid=etudid) + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + + +@bp.route("/doSuppressAnnotation", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def doSuppressAnnotation(etudid, annotation_id): + """Suppression annotation.""" + if not sco_permissions_check.can_suppress_annotation(annotation_id): + raise AccessDenied("Vous n'avez pas le droit d'effectuer cette opération !") + + cnx = ndb.GetDBConnexion() + annos = sco_etud.etud_annotations_list(cnx, args={"id": annotation_id}) + if len(annos) != 1: + raise ScoValueError("annotation inexistante !") + anno = annos[0] + log("suppress annotation: %s" % str(anno)) + logdb(cnx, method="SuppressAnnotation", etudid=etudid) + sco_etud.etud_annotations_delete(cnx, annotation_id) + + return flask.redirect( + url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etudid, + head_message="Annotation%%20supprimée", + ) + ) + + +@bp.route("/form_change_coordonnees", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEtudChangeAdr) +@scodoc7func +def form_change_coordonnees(etudid): + "edit coordonnees etudiant" + etud = Identite.query.get_or_404(etudid) + cnx = ndb.GetDBConnexion() + adrs = sco_etud.adresse_list(cnx, {"etudid": etudid}) + if adrs: + adr = adrs[0] + else: + adr = {} # no data for this student + H = [ + f"""{html_sco_header.sco_header( + page_title=f"Changement coordonnées de {etud.nomprenom}" + )} +

              Changement des coordonnées de {etud.nomprenom}

              +

              """ + ] + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("adresse_id", {"input_type": "hidden"}), + ("etudid", {"input_type": "hidden"}), + ( + "email", + { + "size": 40, + "title": "e-mail", + "explanation": "adresse institutionnelle", + }, + ), + ( + "emailperso", + { + "size": 40, + "title": "e-mail", + "explanation": "adresse personnelle", + }, + ), + ( + "domicile", + {"size": 65, "explanation": "numéro, rue", "title": "Adresse"}, + ), + ("codepostaldomicile", {"size": 6, "title": "Code postal"}), + ("villedomicile", {"size": 20, "title": "Ville"}), + ("paysdomicile", {"size": 20, "title": "Pays"}), + ("", {"input_type": "separator", "default": " "}), + ("telephone", {"size": 13, "title": "Téléphone"}), + ("telephonemobile", {"size": 13, "title": "Mobile"}), + ), + initvalues=adr, + submitlabel="Valider le formulaire", + ) + dest_url = url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + if tf[0] == 0: + return "\n".join(H) + tf[1] + html_sco_header.sco_footer() + elif tf[0] == -1: + return flask.redirect(dest_url) + else: + if adrs: + sco_etud.adresse_edit(cnx, args=tf[2]) + else: + sco_etud.adresse_create(cnx, args=tf[2]) + logdb(cnx, method="changeCoordonnees", etudid=etudid) + return flask.redirect(dest_url) + + +# --- Gestion des groupes: +sco_publish( + "/affect_groups", + sco_groups_edit.affect_groups, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/XMLgetGroupsInPartition", sco_groups.XMLgetGroupsInPartition, Permission.ScoView +) + +sco_publish( + "/formsemestre_partition_list", + sco_groups.formsemestre_partition_list, + Permission.ScoView, +) + +sco_publish("/setGroups", sco_groups.setGroups, Permission.ScoView, methods=["POST"]) + + +sco_publish( + "/group_rename", + sco_groups.group_rename, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/groups_auto_repartition", + sco_groups.groups_auto_repartition, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/edit_partition_form", + sco_groups.edit_partition_form, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/partition_delete", + sco_groups.partition_delete, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/partition_set_attr", + sco_groups.partition_set_attr, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/partition_move", + sco_groups.partition_move, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/partition_set_name", + sco_groups.partition_set_name, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/partition_rename", + sco_groups.partition_rename, + Permission.ScoView, + methods=["GET", "POST"], +) + +sco_publish( + "/partition_create", + sco_groups.partition_create, + Permission.ScoView, # controle d'access ad-hoc + methods=["GET", "POST"], +) + +# Nouvel éditeur de partitions et groupe, @SebL Jul 2022 +@bp.route("/partition_editor", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def partition_editor(formsemestre_id: int): + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + H = [ + html_sco_header.sco_header( + cssstyles=["css/partition_editor.css"], + javascripts=[ + "js/partition_editor.js", + ], + page_title=f"Partitions de {formsemestre.titre_annee()}", + init_datatables=False, + ), + f"""

              +

              + """, + render_template( + "scolar/partition_editor.html", + formsemestre=formsemestre, + read_only=not sco_groups.sco_permissions_check.can_change_groups( + formsemestre_id + ), + ), + html_sco_header.sco_footer(), + ] + + return "\n".join(H) + + +@bp.route("/create_partition_parcours", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def create_partition_parcours(formsemestre_id): + """Création d'une partitions nommée "Parcours" (PARTITION_PARCOURS) + avec un groupe par parcours.""" + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + formsemestre.setup_parcours_groups() + return flask.redirect( + url_for( + "scolar.edit_partition_form", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + + +sco_publish("/etud_info_html", sco_page_etud.etud_info_html, Permission.ScoView) + +# --- Gestion des photos: +sco_publish("/get_photo_image", sco_photos.get_photo_image, Permission.ScoView) + +sco_publish("/etud_photo_html", sco_photos.etud_photo_html, Permission.ScoView) + + +@bp.route("/etud_photo_orig_page") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def etud_photo_orig_page(etudid=None): + "Page with photo in orig. size" + etud = sco_etud.get_etud_info(filled=True, etudid=etudid)[0] + H = [ + html_sco_header.sco_header(page_title=etud["nomprenom"]), + "

              %s

              " % etud["nomprenom"], + '", + html_sco_header.sco_footer(), + ] + return "\n".join(H) + + +@bp.route("/form_change_photo", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEtudChangeAdr) +@scodoc7func +def form_change_photo(etudid=None): + """Formulaire changement photo étudiant""" + etud = sco_etud.get_etud_info(filled=True)[0] + if sco_photos.etud_photo_is_local(etud): + etud["photoloc"] = "dans ScoDoc" + else: + etud["photoloc"] = "externe" + H = [ + html_sco_header.sco_header(page_title="Changement de photo"), + """

              Changement de la photo de %(nomprenom)s

              +

              Photo actuelle (%(photoloc)s): + """ + % etud, + sco_photos.etud_photo_html(etud, title="photo actuelle"), + """

              Le fichier ne doit pas dépasser 500Ko (recadrer l'image, format "portrait" de préférence).

              +

              L'image sera automagiquement réduite pour obtenir une hauteur de 90 pixels.

              + """, + ] + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ("etudid", {"default": etudid, "input_type": "hidden"}), + ( + "photofile", + {"input_type": "file", "title": "Fichier image", "size": 20}, + ), + ), + submitlabel="Valider", + cancelbutton="Annuler", + ) + dest_url = url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud["etudid"] + ) + if tf[0] == 0: + return ( + "\n".join(H) + + tf[1] + + '

              Supprimer cette photo

              ' + % etudid + + html_sco_header.sco_footer() + ) + elif tf[0] == -1: + return flask.redirect(dest_url) + else: + data = tf[2]["photofile"].read() + status, err_msg = sco_photos.store_photo( + etud, data, tf[2]["photofile"].filename + ) + if status: + return flask.redirect(dest_url) + else: + H.append(f"""

              Erreur: {err_msg}

              """) + return "\n".join(H) + html_sco_header.sco_footer() + + +@bp.route("/form_suppress_photo", methods=["POST", "GET"]) +@scodoc +@permission_required(Permission.ScoEtudChangeAdr) +@scodoc7func +def form_suppress_photo(etudid=None, dialog_confirmed=False): + """Formulaire suppression photo étudiant""" + etud: Identite = Identite.query.filter_by( + id=etudid, dept_id=g.scodoc_dept_id + ).first_or_404() + if not dialog_confirmed: + return scu.confirm_dialog( + f"

              Confirmer la suppression de la photo de {etud.nom_disp()} ?

              ", + dest_url="", + cancel_url=url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id + ), + parameters={"etudid": etud.id}, + ) + + sco_photos.suppress_photo(etud) + + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id) + ) + + +# +@bp.route("/form_dem") +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def form_dem(etudid, formsemestre_id): + "Formulaire Démission Etudiant" + return _form_dem_of_def( + etudid, + formsemestre_id, + operation_name="Démission", + operation_method="do_dem_etudiant", + ) + + +@bp.route("/form_def") +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def form_def(etudid, formsemestre_id): + "Formulaire Défaillance Etudiant" + return _form_dem_of_def( + etudid, + formsemestre_id, + operation_name="Défaillance", + operation_method="do_def_etudiant", + ) + + +def _form_dem_of_def( + etudid: int, + formsemestre_id: int, + operation_name: str = "", + operation_method: str = "", +): + "Formulaire démission ou défaillance Etudiant" + etud: Identite = Identite.query.filter_by( + id=etudid, dept_id=g.scodoc_dept_id + ).first_or_404() + formsemestre: FormSemestre = FormSemestre.query.filter_by( + id=formsemestre_id, dept_id=g.scodoc_dept_id + ).first_or_404() + if not formsemestre.etat: + raise ScoValueError("Modification impossible: semestre verrouille") + nowdmy = time.strftime("%d/%m/%Y") + # + header = html_sco_header.sco_header( + page_title=f"""{operation_name} de {etud.nomprenom} (du semestre {formsemestre.titre_mois()})""" + ) + return f""" + {header} +

              {operation_name} de {etud.nomprenom} ({formsemestre.titre_mois()})

              + +
              +
              Date de la {operation_name.lower()} (J/M/AAAA):  + +
              + + +
              + + {html_sco_header.sco_footer()} + """ + + +@bp.route("/do_dem_etudiant") +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def do_dem_etudiant(etudid, formsemestre_id, event_date=None): + "Déclare la démission d'un etudiant dans le semestre" + return _do_dem_or_def_etud( + etudid, + formsemestre_id, + event_date=event_date, + etat_new=scu.DEMISSION, + operation_method="dem_etudiant", + event_type="DEMISSION", + ) + + +@bp.route("/do_def_etudiant") +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def do_def_etudiant(etudid, formsemestre_id, event_date=None): + "Déclare la défaillance d'un etudiant dans le semestre" + return _do_dem_or_def_etud( + etudid, + formsemestre_id, + event_date=event_date, + etat_new=sco_codes_parcours.DEF, + operation_method="defailleEtudiant", + event_type="DEFAILLANCE", + ) + + +def _do_dem_or_def_etud( + etudid, + formsemestre_id, + event_date=None, + etat_new=scu.DEMISSION, # DEMISSION or DEF + operation_method="demEtudiant", + event_type="DEMISSION", + redirect=True, +): + "Démission ou défaillance d'un étudiant" + sco_formsemestre_inscriptions.do_formsemestre_demission( + etudid, + formsemestre_id, + event_date=event_date, + etat_new=etat_new, # DEMISSION or DEF + operation_method=operation_method, + event_type=event_type, + ) + if redirect: + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + + +@bp.route("/do_cancel_dem", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def do_cancel_dem(etudid, formsemestre_id, dialog_confirmed=False, args=None): + "Annule une démission" + return _do_cancel_dem_or_def( + etudid, + formsemestre_id, + dialog_confirmed=dialog_confirmed, + args=args, + operation_name="démission", + etat_current=scu.DEMISSION, + etat_new=scu.INSCRIT, + operation_method="cancelDem", + event_type="DEMISSION", + ) + + +@bp.route("/do_cancel_def", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def do_cancel_def(etudid, formsemestre_id, dialog_confirmed=False, args=None): + "Annule la défaillance de l'étudiant" + return _do_cancel_dem_or_def( + etudid, + formsemestre_id, + dialog_confirmed=dialog_confirmed, + args=args, + operation_name="défaillance", + etat_current=sco_codes_parcours.DEF, + etat_new=scu.INSCRIT, + operation_method="cancel_def", + event_type="DEFAILLANCE", + ) + + +def _do_cancel_dem_or_def( + etudid, + formsemestre_id, + dialog_confirmed=False, + args=None, + operation_name="", # "démission" ou "défaillance" + etat_current=scu.DEMISSION, + etat_new=scu.INSCRIT, + operation_method="cancel_dem", + event_type="DEMISSION", +): + "Annule une démission ou une défaillance" + etud: Identite = Identite.query.filter_by( + id=etudid, dept_id=g.scodoc_dept_id + ).first_or_404() + formsemestre: FormSemestre = FormSemestre.query.filter_by( + id=formsemestre_id, dept_id=g.scodoc_dept_id + ).first_or_404() + # check lock + if not formsemestre.etat: + raise ScoValueError("Modification impossible: semestre verrouille") + # verif + if formsemestre_id not in (inscr.formsemestre_id for inscr in etud.inscriptions()): + raise ScoValueError("étudiant non inscrit dans ce semestre !") + if etud.inscription_etat(formsemestre_id) != etat_current: + raise ScoValueError(f"etudiant non {operation_name} !") + + if not dialog_confirmed: + return scu.confirm_dialog( + f"

              Confirmer l'annulation de la {operation_name} ?

              ", + dest_url="", + cancel_url=url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + ), + parameters={"etudid": etudid, "formsemestre_id": formsemestre_id}, + ) + # + inscr = next( + inscr + for inscr in etud.inscriptions() + if inscr.formsemestre_id == formsemestre_id + ) + inscr.etat = etat_new + db.session.add(inscr) + Scolog.logdb(method=operation_method, etudid=etudid) + # Efface les évènements + for event in ScolarEvent.query.filter_by( + etudid=etudid, formsemestre_id=formsemestre_id, event_type=event_type + ): + db.session.delete(event) + + db.session.commit() + flash(f"{operation_name} annulée.") + return flask.redirect( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + + +@bp.route("/etudident_create_form", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def etudident_create_form(): + "formulaire creation individuelle etudiant" + return _etudident_create_or_edit_form(edit=False) + + +@bp.route("/etudident_edit_form", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def etudident_edit_form(): + "formulaire edition individuelle etudiant" + return _etudident_create_or_edit_form(edit=True) + + +def _etudident_create_or_edit_form(edit): + "Le formulaire HTML" + H = [html_sco_header.sco_header()] + F = html_sco_header.sco_footer() + vals = scu.get_request_args() + etudid = vals.get("etudid", None) + cnx = ndb.GetDBConnexion() + descr = [] + if not edit: + # creation nouvel etudiant + initvalues = {} + submitlabel = "Ajouter cet étudiant" + H.append( + """

              Création d'un étudiant

              +

              En général, il est recommandé d'importer les + étudiants depuis Apogée ou via un fichier Excel (menu Inscriptions + dans le semestre). +

              +

              + N'utilisez ce formulaire au cas par cas que pour les cas particuliers + ou si votre établissement n'utilise pas d'autre logiciel de gestion des + inscriptions. +

              +

              L'étudiant créé ne sera pas inscrit. + Pensez à l'inscrire dans un semestre !

              + """ + ) + else: + # edition donnees d'un etudiant existant + # setup form init values + if not etudid: + raise ValueError("missing etudid parameter") + descr.append(("etudid", {"default": etudid, "input_type": "hidden"})) + H.append( + '

              Modification d\'un étudiant (fiche)

              ' + % url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid) + ) + initvalues = sco_etud.etudident_list(cnx, {"etudid": etudid}) + assert len(initvalues) == 1 + initvalues = initvalues[0] + submitlabel = "Modifier les données" + + vals = scu.get_request_args() + nom = vals.get("nom", None) + if nom is None: + nom = initvalues.get("nom", None) + if nom is None: + infos = [] + else: + prenom = vals.get("prenom", "") + if vals.get("tf_submitted", False) and not prenom: + prenom = initvalues.get("prenom", "") + infos = sco_portal_apogee.get_infos_apogee(nom, prenom) + + if infos: + formatted_infos = [ + """ + +
                """ + ] + nanswers = len(infos) + nmax = 10 # nb max de reponse montrées + infos = infos[:nmax] + for i in infos: + formatted_infos.append("
                • ") + for k in i.keys(): + if k != "nip": + item = "
                • %s : %s
                • " % (k, i[k]) + else: + item = ( + '
                • %s : %s
                • ' + % (k, i[k], i[k]) + ) + formatted_infos.append(item) + + formatted_infos.append("
              1. ") + formatted_infos.append("
              ") + m = "%d étudiants trouvés" % nanswers + if len(infos) != nanswers: + m += " (%d montrés)" % len(infos) + A = """
              +
              Informations Apogée
              +

              %s

              + %s +
              """ % ( + m, + "\n".join(formatted_infos), + ) + else: + A = """

              Pas d'informations d'Apogée

              """ + + require_ine = sco_preferences.get_preference("always_require_ine") + + descr += [ + ("adm_id", {"input_type": "hidden"}), + ("nom", {"size": 25, "title": "Nom", "allow_null": False}), + ("nom_usuel", {"size": 25, "title": "Nom usuel", "allow_null": True}), + ( + "prenom", + { + "size": 25, + "title": "Prénom", + "allow_null": scu.CONFIG.ALLOW_NULL_PRENOM, + }, + ), + ( + "civilite", + { + "input_type": "menu", + "labels": ["Homme", "Femme", "Autre/neutre"], + "allowed_values": ["M", "F", "X"], + "title": "Civilité", + }, + ), + ( + "date_naissance", + { + "title": "Date de naissance", + "input_type": "date", + "explanation": "j/m/a", + "validator": lambda val, _: DMY_REGEXP.match(val) + and (ndb.DateDMYtoISO(val) < datetime.date.today().isoformat()), + }, + ), + ("lieu_naissance", {"title": "Lieu de naissance", "size": 32}), + ("dept_naissance", {"title": "Département de naissance", "size": 5}), + ("nationalite", {"size": 25, "title": "Nationalité"}), + ( + "statut", + { + "size": 25, + "title": "Statut", + "explanation": '("salarie", ...) inutilisé par ScoDoc', + }, + ), + ( + "annee", + { + "size": 5, + "title": "Année admission IUT", + "type": "int", + "allow_null": False, + "explanation": "année 1ere inscription (obligatoire)", + }, + ), + # + ("sep", {"input_type": "separator", "title": "Scolarité antérieure:"}), + ("bac", {"size": 5, "explanation": "série du bac (S, STI, STT, ...)"}), + ( + "specialite", + { + "size": 25, + "title": "Spécialité", + "explanation": "spécialité bac: SVT M, GENIE ELECTRONIQUE, ...", + }, + ), + ( + "annee_bac", + { + "size": 5, + "title": "Année bac", + "type": "int", + "min_value": 1945, + "max_value": datetime.date.today().year + 1, + "explanation": "année obtention du bac", + }, + ), + ( + "math", + { + "size": 3, + "type": "float", + "title": "Note de mathématiques", + "explanation": "note sur 20 en terminale", + }, + ), + ( + "physique", + { + "size": 3, + "type": "float", + "title": "Note de physique", + "explanation": "note sur 20 en terminale", + }, + ), + ( + "anglais", + { + "size": 3, + "type": "float", + "title": "Note d'anglais", + "explanation": "note sur 20 en terminale", + }, + ), + ( + "francais", + { + "size": 3, + "type": "float", + "title": "Note de français", + "explanation": "note sur 20 obtenue au bac", + }, + ), + ( + "type_admission", + { + "input_type": "menu", + "title": "Voie d'admission", + "allowed_values": scu.TYPES_ADMISSION, + }, + ), + ( + "boursier_prec", + { + "input_type": "boolcheckbox", + "labels": ["non", "oui"], + "title": "Boursier ?", + "explanation": "dans le cycle précédent (lycée)", + }, + ), + ( + "rang", + { + "size": 1, + "type": "int", + "title": "Position établissement", + "explanation": "rang de notre établissement dans les voeux du candidat (si connu)", + }, + ), + ( + "qualite", + { + "size": 3, + "type": "float", + "title": "Qualité", + "explanation": "Note de qualité attribuée au dossier (par le jury d'adm.)", + }, + ), + ( + "decision", + { + "input_type": "menu", + "title": "Décision", + "allowed_values": [ + "ADMIS", + "ATTENTE 1", + "ATTENTE 2", + "ATTENTE 3", + "REFUS", + "?", + ], + }, + ), + ( + "score", + { + "size": 3, + "type": "float", + "title": "Score", + "explanation": "score calculé lors de l'admission", + }, + ), + ( + "classement", + { + "size": 3, + "type": "int", + "title": "Classement", + "explanation": "Classement par le jury d'admission (de 1 à N)", + }, + ), + ("apb_groupe", {"size": 15, "title": "Groupe APB ou PS"}), + ( + "apb_classement_gr", + { + "size": 3, + "type": "int", + "title": "Classement", + "explanation": "Classement par le jury dans le groupe ABP ou PS (de 1 à Ng)", + }, + ), + ("rapporteur", {"size": 50, "title": "Enseignant rapporteur"}), + ( + "commentaire", + { + "input_type": "textarea", + "rows": 4, + "cols": 50, + "title": "Note du rapporteur", + }, + ), + ("nomlycee", {"size": 20, "title": "Lycée d'origine"}), + ("villelycee", {"size": 15, "title": "Commune du lycée"}), + ("codepostallycee", {"size": 15, "title": "Code Postal lycée"}), + ( + "codelycee", + { + "size": 15, + "title": "Code Lycée", + "explanation": "Code national établissement du lycée ou établissement d'origine", + }, + ), + ("sep", {"input_type": "separator", "title": "Codes Apogée: (optionnels)"}), + ( + "code_nip", + { + "size": 25, + "title": "Numéro NIP", + "allow_null": True, + "explanation": "numéro identité étudiant (Apogée)", + }, + ), + ( + "code_ine", + { + "size": 25, + "title": "Numéro INE", + "allow_null": not require_ine, + "explanation": "numéro INE", + }, + ), + ( + "dont_check_homonyms", + { + "title": "Autoriser les homonymes", + "input_type": "boolcheckbox", + "explanation": "ne vérifie pas les noms et prénoms proches", + }, + ), + ] + initvalues["dont_check_homonyms"] = False + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + descr, + submitlabel=submitlabel, + cancelbutton="Re-interroger Apogee", + initvalues=initvalues, + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + "

              " + A + F + elif tf[0] == -1: + return "\n".join(H) + tf[1] + "

              " + A + F + # return '\n'.join(H) + '

              annulation

              ' + F + else: + # form submission + if edit: + etudid = tf[2]["etudid"] + else: + etudid = None + ok, NbHomonyms = sco_etud.check_nom_prenom( + cnx, nom=tf[2]["nom"], prenom=tf[2]["prenom"], etudid=etudid + ) + if not ok: + return ( + "\n".join(H) + + tf_error_message("Nom ou prénom invalide") + + tf[1] + + "

              " + + A + + F + ) + # log('NbHomonyms=%s' % NbHomonyms) + if not tf[2]["dont_check_homonyms"] and NbHomonyms > 0: + return ( + "\n".join(H) + + tf_error_message( + """Attention: il y a déjà un étudiant portant des noms et prénoms proches. Vous pouvez forcer la présence d'un homonyme en cochant "autoriser les homonymes" en bas du formulaire.""" + ) + + tf[1] + + "

              " + + A + + F + ) + + if not edit: + etud = sco_etud.create_etud(cnx, args=tf[2]) + etudid = etud["etudid"] + else: + # modif d'un etudiant + sco_etud.etudident_edit(cnx, tf[2]) + etud = sco_etud.etudident_list(cnx, {"etudid": etudid})[0] + sco_etud.fill_etuds_info([etud]) + # Inval semesters with this student: + to_inval = [s["formsemestre_id"] for s in etud["sems"]] + for formsemestre_id in to_inval: + sco_cache.invalidate_formsemestre( + formsemestre_id=formsemestre_id + ) # > etudident_create_or_edit + # + return flask.redirect("ficheEtud?etudid=" + str(etudid)) + + +@bp.route("/etudident_delete", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def etudident_delete(etudid, dialog_confirmed=False): + "Delete a student" + cnx = ndb.GetDBConnexion() + etuds = sco_etud.etudident_list(cnx, {"etudid": etudid}) + if not etuds: + raise ScoValueError("Étudiant inexistant !") + else: + etud = etuds[0] + sco_etud.fill_etuds_info([etud]) + if not dialog_confirmed: + return scu.confirm_dialog( + """

              Confirmer la suppression de l'étudiant {e[nomprenom]} ?

              +

              +

              Prenez le temps de vérifier + que vous devez vraiment supprimer cet étudiant ! +

              +

              Cette opération irréversible + efface toute trace de l'étudiant: inscriptions, notes, absences... + dans tous les semestres qu'il a fréquenté. +

              +

              Dans la plupart des cas, vous avez seulement besoin de le

                désinscrire
              + d'un semestre ? (dans ce cas passez par sa fiche, menu associé au semestre)

              + +

              Vérifier la fiche de {e[nomprenom]} +

              """.format( + e=etud, + fiche_url=url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + ), + ), + dest_url="", + cancel_url=url_for( + "scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid + ), + OK="Supprimer définitivement cet étudiant", + parameters={"etudid": etudid}, + ) + log("etudident_delete: etudid=%(etudid)s nomprenom=%(nomprenom)s" % etud) + # delete in all tables ! + tables = [ + "notes_appreciations", + "scolar_autorisation_inscription", + "scolar_formsemestre_validation", + "scolar_events", + "notes_notes_log", + "notes_notes", + "notes_moduleimpl_inscription", + "notes_formsemestre_inscription", + "group_membership", + "etud_annotations", + "scolog", + "admissions", + "adresse", + "absences", + "absences_notifications", + "billet_absence", + ] + cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) + for table in tables: + cursor.execute("delete from %s where etudid=%%(etudid)s" % table, etud) + cursor.execute("delete from identite where id=%(etudid)s", etud) + cnx.commit() + # Inval semestres où il était inscrit: + to_inval = [s["formsemestre_id"] for s in etud["sems"]] + for formsemestre_id in to_inval: + sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id) # > + return flask.redirect(scu.ScoURL() + r"?head_message=Etudiant%20supprimé") + + +@bp.route("/check_group_apogee") +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def check_group_apogee(group_id, etat=None, fix=False, fixmail=False): + """Verification des codes Apogee et mail de tout un groupe. + Si fix == True, change les codes avec Apogée. + + XXX A re-écrire pour API 2: prendre liste dans l'étape et vérifier à partir de cela. + """ + etat = etat or None + members, group, _, sem, _ = sco_groups.get_group_infos(group_id, etat=etat) + formsemestre_id = group["formsemestre_id"] + + cnx = ndb.GetDBConnexion() + H = [ + html_sco_header.html_sem_header( + "Étudiants du %s" % (group["group_name"] or "semestre") + ), + '
              AttributTypeDescription
              %s%s%s%s
              ', + "", + ] + nerrs = 0 # nombre d'anomalies détectées + nfix = 0 # nb codes changes + nmailmissing = 0 # nb etuds sans mail + for t in members: + nom, nom_usuel, prenom, etudid, email, code_nip = ( + t["nom"], + t["nom_usuel"], + t["prenom"], + t["etudid"], + t["email"], + t["code_nip"], + ) + infos = sco_portal_apogee.get_infos_apogee(nom, prenom) + if not infos: + info_apogee = ( + 'Pas d\'information (Modifier identité)' + % etudid + ) + nerrs += 1 + else: + if len(infos) == 1: + nip_apogee = infos[0]["nip"] + if code_nip != nip_apogee: + if fix: + # Update database + sco_etud.identite_edit( + cnx, + args={"etudid": etudid, "code_nip": nip_apogee}, + ) + info_apogee = ( + 'copié %s' % nip_apogee + ) + nfix += 1 + else: + info_apogee = '%s' % nip_apogee + nerrs += 1 + else: + info_apogee = "ok" + else: + info_apogee = ( + '%d correspondances (Choisir)' + % (len(infos), etudid) + ) + nerrs += 1 + # check mail + if email: + mailstat = "ok" + else: + if fixmail and len(infos) == 1 and "mail" in infos[0]: + mail_apogee = infos[0]["mail"] + adrs = sco_etud.adresse_list(cnx, {"etudid": etudid}) + if adrs: + adr = adrs[0] # modif adr existante + args = {"adresse_id": adr["adresse_id"], "email": mail_apogee} + sco_etud.adresse_edit(cnx, args=args, disable_notify=True) + else: + # creation adresse + args = {"etudid": etudid, "email": mail_apogee} + sco_etud.adresse_create(cnx, args=args) + mailstat = 'copié' + else: + mailstat = "inconnu" + nmailmissing += 1 + H.append( + '' + % ( + url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid), + nom, + nom_usuel, + prenom, + mailstat, + code_nip, + info_apogee, + ) + ) + H.append("
              NomNom usuelPrénomMailNIP (ScoDoc)Apogée
              %s%s%s%s%s%s
              ") + H.append("

                ") + if nfix: + H.append("
              • %d codes modifiés
              • " % nfix) + H.append("
              • Codes NIP: %d anomalies détectées
              • " % nerrs) + H.append("
              • Adresse mail: %d étudiants sans adresse
              • " % nmailmissing) + H.append("
              ") + H.append( + """ +
              + + + + + +
              +

              Retour au semestre + """ + % ( + request.base_url, + formsemestre_id, + scu.strnone(group_id), + scu.strnone(etat), + formsemestre_id, + ) + ) + H.append( + """ +

              + + + + + +
              +

              Retour au semestre + """ + % ( + request.base_url, + formsemestre_id, + scu.strnone(group_id), + scu.strnone(etat), + formsemestre_id, + ) + ) + + return "\n".join(H) + html_sco_header.sco_footer() + + +@bp.route("/form_students_import_excel", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def form_students_import_excel(formsemestre_id=None): + "formulaire import xls" + formsemestre_id = int(formsemestre_id) if formsemestre_id else None + if formsemestre_id: + sem = sco_formsemestre.get_formsemestre(formsemestre_id) + dest_url = ( + # scu.ScoURL() + "/formsemestre_status?formsemestre_id=%s" % formsemestre_id # TODO: Remplacer par for_url ? + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) + ) + else: + sem = None + dest_url = scu.ScoURL() + if sem and not sem["etat"]: + raise ScoValueError("Modification impossible: semestre verrouille") + H = [ + html_sco_header.sco_header(page_title="Import etudiants"), + """

              Téléchargement d\'une nouvelle liste d\'etudiants

              +
              +

              A utiliser pour importer de nouveaux étudiants (typiquement au + premier semestre).

              +

              Si les étudiants à inscrire sont déjà dans un autre + semestre, utiliser le menu "Inscriptions (passage des étudiants) + depuis d'autres semestres à partir du semestre destination. +

              +

              Si vous avez un portail Apogée, il est en général préférable d'importer les + étudiants depuis Apogée, via le menu "Synchroniser avec étape Apogée". +

              +
              +

              + L'opération se déroule en deux étapes. Dans un premier temps, + vous téléchargez une feuille Excel type. Vous devez remplir + cette feuille, une ligne décrivant chaque étudiant. Ensuite, + vous indiquez le nom de votre fichier dans la case "Fichier Excel" + ci-dessous, et cliquez sur "Télécharger" pour envoyer au serveur + votre liste. +

              + """, + ] # ' + if sem: + H.append( + """

              Les étudiants importés seront inscrits dans + le semestre %s

              """ + % sem["titremois"] + ) + else: + H.append( + """ +

              Pour inscrire directement les étudiants dans un semestre de + formation, il suffit d'indiquer le code de ce semestre + (qui doit avoir été créé au préalable). Cliquez ici pour afficher les codes +

              + """ + % (scu.ScoURL()) + ) + + H.append("""
              1. """) + if formsemestre_id: + H.append( + """ + + """ + ) + else: + H.append("""""") + H.append( + """Obtenir la feuille excel à remplir
              2. +
              3. """ + ) + + F = html_sco_header.sco_footer() + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ( + "csvfile", + {"title": "Fichier Excel:", "input_type": "file", "size": 40}, + ), + ( + "check_homonyms", + { + "title": "Vérifier les homonymes", + "input_type": "boolcheckbox", + "explanation": "arrète l'importation si plus de 10% d'homonymes", + }, + ), + ( + "require_ine", + { + "title": "Importer INE", + "input_type": "boolcheckbox", + "explanation": "n'importe QUE les étudiants avec nouveau code INE", + }, + ), + ("formsemestre_id", {"input_type": "hidden"}), + ), + initvalues={"check_homonyms": True, "require_ine": False}, + submitlabel="Télécharger", + ) + S = [ + """

                Le fichier Excel décrivant les étudiants doit comporter les colonnes suivantes. +

                Les colonnes peuvent être placées dans n'importe quel ordre, mais +le titre exact (tel que ci-dessous) doit être sur la première ligne. +

                +

                +Les champs avec un astérisque (*) doivent être présents (nulls non autorisés). +

                + + +

                + +""" + ] + for t in sco_import_etuds.sco_import_format( + with_codesemestre=(formsemestre_id == None) + ): + if int(t[3]): + ast = "" + else: + ast = "*" + S.append( + "" + % (t[0], t[1], t[4], ast) + ) + if tf[0] == 0: + return "\n".join(H) + tf[1] + "" + "\n".join(S) + F + elif tf[0] == -1: + return flask.redirect(dest_url) + else: + return sco_import_etuds.students_import_excel( + tf[2]["csvfile"], + formsemestre_id=int(formsemestre_id) if formsemestre_id else None, + check_homonyms=tf[2]["check_homonyms"], + require_ine=tf[2]["require_ine"], + ) + + +@bp.route("/import_generate_excel_sample") +@scodoc +@permission_required(Permission.ScoEtudInscrit) +@scodoc7func +def import_generate_excel_sample(with_codesemestre="1"): + "une feuille excel pour importation etudiants" + if with_codesemestre: + with_codesemestre = int(with_codesemestre) + else: + with_codesemestre = 0 + format = sco_import_etuds.sco_import_format() + data = sco_import_etuds.sco_import_generate_excel_sample( + format, with_codesemestre, exclude_cols=["photo_filename"] + ) + return scu.send_file( + data, "ImportEtudiants", scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE + ) + # return sco_excel.send_excel_file(data, "ImportEtudiants" + scu.XLSX_SUFFIX) + + +# --- Données admission +@bp.route("/import_generate_admission_sample") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def import_generate_admission_sample(formsemestre_id): + "une feuille excel pour importation données admissions" + group = sco_groups.get_group(sco_groups.get_default_group(formsemestre_id)) + fmt = sco_import_etuds.sco_import_format() + data = sco_import_etuds.sco_import_generate_excel_sample( + fmt, + only_tables=["identite", "admissions", "adresse"], + exclude_cols=["nationalite", "foto", "photo_filename"], + group_ids=[group["group_id"]], + ) + return scu.send_file( + data, "AdmissionEtudiants", scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE + ) + # return sco_excel.send_excel_file(data, "AdmissionEtudiants" + scu.XLSX_SUFFIX) + + +# --- Données admission depuis fichier excel (version nov 2016) +@bp.route("/form_students_import_infos_admissions", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def form_students_import_infos_admissions(formsemestre_id=None): + "formulaire import xls" + authuser = current_user + F = html_sco_header.sco_footer() + if not authuser.has_permission(Permission.ScoEtudInscrit): + # autorise juste l'export + H = [ + html_sco_header.sco_header( + page_title="Export données admissions (Parcoursup ou autre)", + ), + """

                Téléchargement des informations sur l'admission des étudiants

                +

                + Exporter les informations de ScoDoc (classeur Excel) (ce fichier peut être ré-importé après d'éventuelles modifications) +

                +

                Vous n'avez pas le droit d'importer les données

                + """ + % {"formsemestre_id": formsemestre_id}, + ] + return "\n".join(H) + F + + # On a le droit d'importer: + H = [ + html_sco_header.sco_header(page_title="Import données admissions Parcoursup"), + """

                Téléchargement des informations sur l'admission des étudiants depuis feuilles import Parcoursup

                +
                +

                A utiliser pour renseigner les informations sur l'origine des étudiants (lycées, bac, etc). Ces informations sont facultatives mais souvent utiles pour mieux connaitre les étudiants et aussi pour effectuer des statistiques (résultats suivant le type de bac...). Les données sont affichées sur les fiches individuelles des étudiants.

                +
                +

                + Importer ici la feuille excel utilisée pour envoyer le classement Parcoursup. + Seuls les étudiants actuellement inscrits dans ce semestre ScoDoc seront affectés, + les autres lignes de la feuille seront ignorées. Et seules les colonnes intéressant ScoDoc + seront importées: il est inutile d'éliminer les autres. +
                + Seules les données "admission" seront modifiées (et pas l'identité de l'étudiant). +
                + Les colonnes "nom" et "prenom" sont requises, ou bien une colonne "etudid". +

                +

                + Avant d'importer vos données, il est recommandé d'enregistrer les informations actuelles: + exporter les données actuelles de ScoDoc (ce fichier peut être ré-importé après d'éventuelles modifications) +

                + """ + % {"formsemestre_id": formsemestre_id}, + ] # ' + + type_admission_list = ( + "Autre", + "Parcoursup", + "Parcoursup PC", + "APB", + "APB PC", + "CEF", + "Direct", + ) + + tf = TrivialFormulator( + request.base_url, + scu.get_request_args(), + ( + ( + "csvfile", + {"title": "Fichier Excel:", "input_type": "file", "size": 40}, + ), + ( + "type_admission", + { + "title": "Type d'admission", + "explanation": "sera attribué aux étudiants modifiés par cet import n'ayant pas déjà un type", + "input_type": "menu", + "allowed_values": type_admission_list, + }, + ), + ("formsemestre_id", {"input_type": "hidden"}), + ), + submitlabel="Télécharger", + ) + + help_text = ( + """

                Les colonnes importables par cette fonction sont indiquées dans la table ci-dessous. + Seule la première feuille du classeur sera utilisée. +

                + """ + + sco_import_etuds.adm_table_description_format().html() + + """
                """ + ) + + if tf[0] == 0: + return "\n".join(H) + tf[1] + help_text + F + elif tf[0] == -1: + return flask.redirect( + scu.ScoURL() + + "/formsemestre_status?formsemestre_id=" + + str(formsemestre_id) + ) + else: + return sco_import_etuds.students_import_admission( + tf[2]["csvfile"], + type_admission=tf[2]["type_admission"], + formsemestre_id=formsemestre_id, + ) + + +@bp.route("/formsemestre_import_etud_admission") +@scodoc +@permission_required(Permission.ScoEtudChangeAdr) +@scodoc7func +def formsemestre_import_etud_admission(formsemestre_id, import_email=True): + """Reimporte donnees admissions par synchro Portail Apogée""" + ( + no_nip, + unknowns, + changed_mails, + ) = sco_synchro_etuds.formsemestre_import_etud_admission( + formsemestre_id, import_identite=True, import_email=import_email + ) + H = [ + html_sco_header.html_sem_header("Reimport données admission"), + "

                Opération effectuée

                ", + ] + if no_nip: + H.append("

                Attention: étudiants sans NIP: " + str(no_nip) + "

                ") + if unknowns: + H.append( + "

                Attention: étudiants inconnus du portail: codes NIP=" + + str(unknowns) + + "

                " + ) + if changed_mails: + H.append("

                Adresses mails modifiées:

                ") + for (info, new_mail) in changed_mails: + H.append( + "%s: %s devient %s
                " + % (info["nom"], info["email"], new_mail) + ) + return "\n".join(H) + html_sco_header.sco_footer() + + +sco_publish( + "/photos_import_files_form", + sco_trombino.photos_import_files_form, + Permission.ScoEtudChangeAdr, + methods=["GET", "POST"], +) +sco_publish( + "/photos_generate_excel_sample", + sco_trombino.photos_generate_excel_sample, + Permission.ScoEtudChangeAdr, +) + +# --- Statistiques +@bp.route("/stat_bac") +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def stat_bac(formsemestre_id): + "Renvoie statistisques sur nb d'etudiants par bac" + cnx = ndb.GetDBConnexion() + ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list( + args={"formsemestre_id": formsemestre_id} + ) + Bacs = {} # type bac : nb etud + for i in ins: + etud = sco_etud.etudident_list(cnx, {"etudid": i["etudid"]})[0] + typebac = "%(bac)s %(specialite)s" % etud + Bacs[typebac] = Bacs.get(typebac, 0) + 1 + return Bacs + + +# --- Dump (assistance) +@bp.route("/sco_dump_and_send_db", methods=["GET", "POST"]) +@scodoc +@permission_required(Permission.ScoView) +@scodoc7func +def sco_dump_and_send_db(message="", request_url="", traceback_str_base64=""): + "Send anonymized data to supervision" + + status_code = sco_dump_db.sco_dump_and_send_db( + message, request_url, traceback_str_base64=traceback_str_base64 + ) + H = [html_sco_header.sco_header(page_title="Assistance technique")] + if status_code == requests.codes.INSUFFICIENT_STORAGE: # pylint: disable=no-member + H.append( + """

                + Erreur: espace serveur trop plein. + Merci de contacter {0}

                """.format( + scu.SCO_DEV_MAIL + ) + ) + elif status_code == requests.codes.OK: # pylint: disable=no-member + H.append("""

                Opération effectuée.

                """) + else: + H.append( + f"""

                + Erreur: code {status_code} + Merci de contacter {scu.SCO_DEV_MAIL}

                """ + ) + flash("Données envoyées au serveur d'assistance") + return "\n".join(H) + html_sco_header.sco_footer()
                AttributTypeDescription
                %s%s%s%s