############################################################################## # ScoDoc # Copyright (c) 1999 - 2024 Emmanuel Viennet. All rights reserved. # See LICENSE ############################################################################## """Cursus en BUT Classe raccordant avec ScoDoc 7: ScoDoc 7 utilisait sco_cursus_dut.SituationEtudCursus Ce module définit une classe SituationEtudCursusBUT avec la même interface. """ import collections from operator import attrgetter from flask import g, url_for from app import db, log from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_compat import NotesTableCompat from app.models.but_refcomp import ( ApcCompetence, ApcNiveau, ApcParcours, ApcReferentielCompetences, ) from app.models.ues import UEParcours from app.models.but_validations import ApcValidationRCUE from app.models.etudiants import Identite from app.models.formations import Formation from app.models.formsemestre import FormSemestre from app.models.ues import UniteEns from app.models.validations import ScolarFormSemestreValidation from app.scodoc import codes_cursus as sco_codes from app.scodoc.codes_cursus import code_ue_validant, CODES_UE_VALIDES, UE_STANDARD from app.scodoc.sco_exceptions import ScoNoReferentielCompetences, ScoValueError from app.scodoc import sco_cursus_dut class SituationEtudCursusBUT(sco_cursus_dut.SituationEtudCursusClassic): """Pour compat ScoDoc 7: à revoir pour le BUT""" def __init__(self, etud: dict, formsemestre_id: int, res: ResultatsSemestreBUT): super().__init__(etud, formsemestre_id, res) # Ajustements pour le BUT self.can_compensate_with_prev = False # jamais de compensation à la mode DUT def check_compensation_dut(self, semc: dict, ntc: NotesTableCompat): "Jamais de compensation façon DUT" return False def parcours_validated(self): "True si le parcours est validé" return False # XXX TODO class EtudCursusBUT: """L'état de l'étudiant dans son cursus BUT Liste des niveaux validés/à valider (utilisé pour le résumé sur la fiche étudiant) """ def __init__(self, etud: Identite, formation: Formation): """formation indique la spécialité préparée""" # Vérifie que l'étudiant est bien inscrit à un sem. de cette formation if formation.id not in ( ins.formsemestre.formation.id for ins in etud.formsemestre_inscriptions ): raise ScoValueError( f"{etud.nomprenom} non inscrit dans {formation.titre} v{formation.version}" ) if not formation.referentiel_competence: raise ScoNoReferentielCompetences(formation=formation) # self.etud = etud self.formation = formation self.inscriptions = sorted( [ ins for ins in etud.formsemestre_inscriptions if ins.formsemestre.formation.referentiel_competence and ( ins.formsemestre.formation.referentiel_competence.id == formation.referentiel_competence.id ) ], key=lambda s: (s.formsemestre.semestre_id, s.formsemestre.date_debut), ) "Liste des inscriptions aux sem. de la formation, triées par indice et chronologie" self.parcour: ApcParcours = self.inscriptions[-1].parcour "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)" self.niveaux_by_annee: dict[int, list[ApcNiveau]] = {} "{ annee:int : liste des niveaux à valider }" self.niveaux: dict[int, ApcNiveau] = {} "cache les niveaux" for annee in (1, 2, 3): niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours( annee, [self.parcour] if self.parcour else None )[1] # groupe les niveaux de tronc commun et ceux spécifiques au parcour self.niveaux_by_annee[annee] = niveaux_d["TC"] + ( niveaux_d[self.parcour.id] if self.parcour else [] ) self.niveaux.update( {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]} ) self.validation_par_competence_et_annee = {} """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" validation_rcue: ApcValidationRCUE for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): niveau = validation_rcue.niveau() if niveau is None: raise ScoValueError( """UE d'un RCUE non associée à un niveau de compétence. Vérifiez la formation et les associations de ses UEs. """ ) if not niveau.competence.id in self.validation_par_competence_et_annee: self.validation_par_competence_et_annee[niveau.competence.id] = {} previous_validation = self.validation_par_competence_et_annee.get( niveau.competence.id ).get(validation_rcue.annee()) # prend la "meilleure" validation if (not previous_validation) or ( sco_codes.BUT_CODES_ORDER[validation_rcue.code] > sco_codes.BUT_CODES_ORDER[previous_validation.code] ): self.validation_par_competence_et_annee[niveau.competence.id][ niveau.annee ] = validation_rcue self.competences = { competence.id: competence for competence in ( self.parcour.query_competences() if self.parcour else self.formation.referentiel_competence.get_competences_tronc_commun() ) } "cache { competence_id : competence }" def to_dict(self): """ { competence_id : { annee : meilleure_validation } } """ # XXX lent, provisoirement utilisé par TableJury.add_but_competences() return { competence.id: { annee: self.validation_par_competence_et_annee.get( competence.id, {} ).get(annee) for annee in ("BUT1", "BUT2", "BUT3") } for competence in self.competences.values() } # XXX TODO OPTIMISATION ACCESS TABLE JURY def to_dict_codes(self) -> dict[int, dict[str, int]]: """ { competence_id : { annee : { validation } } } où validation est un petit dict avec niveau_id, etc. """ d = {} for competence in self.competences.values(): d[competence.id] = {} for annee in ("BUT1", "BUT2", "BUT3"): validation_rcue: ApcValidationRCUE = ( self.validation_par_competence_et_annee.get(competence.id, {}).get( annee ) ) d[competence.id][annee] = ( validation_rcue.to_dict_codes() if validation_rcue else None ) return d def competence_annee_has_niveau(self, competence_id: int, annee: str) -> bool: "vrai si la compétence à un niveau dans cette annee ('BUT1') pour le parcour de cet etud" # slow, utile pour affichage fiche return annee in [n.annee for n in self.competences[competence_id].niveaux] def load_validation_by_niveau(self) -> dict[int, list[ApcValidationRCUE]]: """Cherche les validations de jury enregistrées pour chaque niveau Résultat: { niveau_id : [ ApcValidationRCUE ] } meilleure validation pour ce niveau """ validations_by_niveau = collections.defaultdict(lambda: []) for validation_rcue in ApcValidationRCUE.query.filter_by(etud=self.etud): validations_by_niveau[validation_rcue.niveau().id].append(validation_rcue) validation_by_niveau = { niveau_id: sorted( validations, key=lambda v: sco_codes.BUT_CODES_ORDER[v.code] )[0] for niveau_id, validations in validations_by_niveau.items() if validations } return validation_by_niveau class FormSemestreCursusBUT: """L'état des étudiants d'un formsemestre dans leur cursus BUT Permet d'obtenir pour chacun liste des niveaux validés/à valider """ def __init__(self, res: ResultatsSemestreBUT): """res indique le formsemestre de référence, qui donne la liste des étudiants et le référentiel de compétence. """ self.res = res self.formsemestre = res.formsemestre if not res.formsemestre.formation.referentiel_competence: raise ScoNoReferentielCompetences(formation=res.formsemestre.formation) # Données cachées pour accélerer les accès: self.referentiel_competences_id: int = ( self.res.formsemestre.formation.referentiel_competence_id ) self.ue_ids: set[int] = set() "set of ue_ids known to belong to our cursus" self.parcours_by_id: dict[int, ApcParcours] = {} "cache des parcours" self.niveaux_by_parcour_by_annee: dict[int, dict[int, list[ApcNiveau]]] = {} "cache { parcour_id : { annee : [ parcour] } }" self.niveaux_by_id: dict[int, ApcNiveau] = {} "cache niveaux" def get_niveaux_parcours_etud(self, etud: Identite) -> dict[int, list[ApcNiveau]]: """Les niveaux compétences que doit valider cet étudiant. Le parcour considéré est celui de l'inscription dans le semestre courant. Si on est en début de cursus, on peut être en tronc commun sans avoir choisi de parcours. Dans ce cas, on n'aura que les compétences de tronc commun. Il faudra donc, avant de diplômer, s'assurer que les compétences du parcours du dernier semestre (S6) sont validées (avec parcour non NULL). """ parcour_id = self.res.etuds_parcour_id.get(etud.id) if parcour_id is None: parcour = None else: if parcour_id not in self.parcours_by_id: self.parcours_by_id[parcour_id] = db.session.get( ApcParcours, parcour_id ) parcour = self.parcours_by_id[parcour_id] return self.get_niveaux_parcours_by_annee(parcour) def get_niveaux_parcours_by_annee( self, parcour: ApcParcours ) -> dict[int, list[ApcNiveau]]: """La liste des niveaux de compétences du parcours, par année BUT. { 1 : [ niveau, ... ] } Si parcour est None, donne uniquement les niveaux tronc commun (cas utile par exemple en 1ere année, mais surtout pas pour donner un diplôme!) """ parcour_id = None if parcour is None else parcour.id if parcour_id in self.niveaux_by_parcour_by_annee: return self.niveaux_by_parcour_by_annee[parcour_id] ref_comp: ApcReferentielCompetences = ( self.res.formsemestre.formation.referentiel_competence ) niveaux_by_annee = {} for annee in (1, 2, 3): niveaux_d = ref_comp.get_niveaux_by_parcours( annee, [parcour] if parcour else None )[1] # groupe les niveaux de tronc commun et ceux spécifiques au parcour niveaux_by_annee[annee] = niveaux_d["TC"] + ( niveaux_d[parcour.id] if parcour else [] ) self.niveaux_by_parcour_by_annee[parcour_id] = niveaux_by_annee self.niveaux_by_id.update( {niveau.id: niveau for niveau in niveaux_by_annee[annee]} ) return niveaux_by_annee def get_etud_validation_par_competence_et_annee(self, etud: Identite): """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" validation_par_competence_et_annee = {} for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): # On s'assurer qu'elle concerne notre cursus ! ue = validation_rcue.ue2 if ue.id not in self.ue_ids: if ( ue.formation.referentiel_competences_id == self.referentiel_competences_id ): self.ue_ids = ue.id else: continue # skip this validation niveau = validation_rcue.niveau() if not niveau.competence.id in validation_par_competence_et_annee: validation_par_competence_et_annee[niveau.competence.id] = {} previous_validation = validation_par_competence_et_annee.get( niveau.competence.id ).get(validation_rcue.annee()) # prend la "meilleure" validation if (not previous_validation) or ( sco_codes.BUT_CODES_ORDER[validation_rcue.code] > sco_codes.BUT_CODES_ORDER[previous_validation["code"]] ): self.validation_par_competence_et_annee[niveau.competence.id][ niveau.annee ] = validation_rcue return validation_par_competence_et_annee def list_etud_inscriptions(self, etud: Identite): "Le parcours à valider: celui du DERNIER semestre suivi (peut être None)" self.niveaux_by_annee = {} "{ annee : liste des niveaux à valider }" self.niveaux: dict[int, ApcNiveau] = {} "cache les niveaux" for annee in (1, 2, 3): niveaux_d = formation.referentiel_competence.get_niveaux_by_parcours( annee, [self.parcour] if self.parcour else None # XXX WIP )[1] # groupe les niveaux de tronc commun et ceux spécifiques au parcour self.niveaux_by_annee[annee] = niveaux_d["TC"] + ( niveaux_d[self.parcour.id] if self.parcour else [] ) self.niveaux.update( {niveau.id: niveau for niveau in self.niveaux_by_annee[annee]} ) self.validation_par_competence_et_annee = {} """{ competence_id : { 'BUT1' : validation_rcue (la "meilleure"), ... } }""" for validation_rcue in ApcValidationRCUE.query.filter_by(etud=etud): niveau = validation_rcue.niveau() if not niveau.competence.id in self.validation_par_competence_et_annee: self.validation_par_competence_et_annee[niveau.competence.id] = {} previous_validation = self.validation_par_competence_et_annee.get( niveau.competence.id ).get(validation_rcue.annee()) # prend la "meilleure" validation if (not previous_validation) or ( sco_codes.BUT_CODES_ORDER[validation_rcue.code] > sco_codes.BUT_CODES_ORDER[previous_validation["code"]] ): self.validation_par_competence_et_annee[niveau.competence.id][ niveau.annee ] = validation_rcue self.competences = { competence.id: competence for competence in ( self.parcour.query_competences() if self.parcour else self.formation.referentiel_competence.get_competences_tronc_commun() ) } "cache { competence_id : competence }" def but_ects_valides(etud: Identite, referentiel_competence_id: int) -> float: """Nombre d'ECTS validés par etud dans le BUT de référentiel indiqué. Ne prend que les UE associées à des niveaux de compétences, et ne les compte qu'une fois même en cas de redoublement avec re-validation. """ validations = ( ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) .filter(ScolarFormSemestreValidation.ue_id != None) .join(UniteEns) .join(ApcNiveau) .join(ApcCompetence) .filter_by(referentiel_id=referentiel_competence_id) ) ects_dict = {} for v in validations: key = (v.ue.semestre_idx, v.ue.niveau_competence.id) if v.code in CODES_UE_VALIDES: ects_dict[key] = v.ue.ects return sum(ects_dict.values()) if ects_dict else 0.0 def etud_ues_de_but1_non_validees( etud: Identite, formation: Formation, parcour: ApcParcours ) -> list[UniteEns]: """Liste des UEs de S1 et S2 non validées, dans son parcours""" # Les UEs avec décisions, dans les S1 ou S2 d'une formation de même code: validations = ( ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) .filter(ScolarFormSemestreValidation.ue_id != None) .join(UniteEns) .filter(db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2)) .join(Formation) .filter_by(formation_code=formation.formation_code) ) codes_validations_by_ue_code = collections.defaultdict(list) for v in validations: codes_validations_by_ue_code[v.ue.ue_code].append(v.code) # Les UEs du parcours en S1 et S2: ues = formation.query_ues_parcour(parcour).filter( db.or_(UniteEns.semestre_idx == 1, UniteEns.semestre_idx == 2) ) # Liste triée des ues non validées return sorted( [ ue for ue in ues if not any( ( code_ue_validant(code) for code in codes_validations_by_ue_code[ue.ue_code] ) ) ], key=attrgetter("numero", "acronyme"), ) def formsemestre_warning_apc_setup( formsemestre: FormSemestre, res: ResultatsSemestreBUT ) -> str: """Vérifie que la formation est OK pour un BUT: - ref. compétence associé - tous les niveaux des parcours du semestre associés à des UEs du formsemestre - pas d'UE non associée à un niveau Renvoie fragment de HTML. """ if not formsemestre.formation.is_apc(): return "" url_formation = url_for( "notes.ue_table", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id, semestre_idx=formsemestre.semestre_id, ) if formsemestre.formation.referentiel_competence is None: return f"""<div class="formsemestre_status_warning"> La <a class="stdlink" href="{url_formation}">formation n'est pas associée à un référentiel de compétence.</a> </div> """ H = [] # Le semestre n'a pas de parcours, mais les UE ont des parcours ? if not formsemestre.parcours: nb_ues_sans_parcours = len( formsemestre.formation.query_ues_parcour(None) .filter(UniteEns.semestre_idx == formsemestre.semestre_id) .all() ) nb_ues_tot = ( UniteEns.query.filter_by(formation=formsemestre.formation, type=UE_STANDARD) .filter(UniteEns.semestre_idx == formsemestre.semestre_id) .count() ) if nb_ues_sans_parcours != nb_ues_tot: H.append( """Le semestre n'est associé à aucun parcours, mais les UEs de la formation ont des parcours """ ) # Vérifie les niveaux de chaque parcours for parcour in formsemestre.parcours or [None]: annee = (formsemestre.semestre_id + 1) // 2 niveaux_ids = { niveau.id for niveau in ApcNiveau.niveaux_annee_de_parcours( parcour, annee, formsemestre.formation.referentiel_competence ) } ues_parcour = formsemestre.formation.query_ues_parcour(parcour).filter( UniteEns.semestre_idx == formsemestre.semestre_id ) ues_niveaux_ids = { ue.niveau_competence.id for ue in ues_parcour if ue.niveau_competence } if niveaux_ids != ues_niveaux_ids: H.append( f"""Parcours {parcour.code if parcour else "Tronc commun"} : {len(ues_niveaux_ids)} UE avec niveaux mais {len(niveaux_ids)} niveaux à valider ! """ ) if not H: return "" return f"""<div class="formsemestre_status_warning"> Problème dans la <a class="stdlink" href="{url_formation}">configuration de la formation</a>: <ul> <li>{ '</li><li>'.join(H) }</li> </ul> <p class="help">Vérifiez les parcours cochés pour ce semestre, et les associations entre UE et niveaux <a class="stdlink" href="{ url_for("notes.parcour_formation", scodoc_dept=g.scodoc_dept, formation_id=formsemestre.formation.id) }">dans la formation.</a> </p> </div> """ def formation_semestre_niveaux_warning(formation: Formation, semestre_idx: int) -> str: """Vérifie que tous les niveaux de compétences de cette année de formation ont bien des UEs. Afin de ne pas générer trop de messages, on ne considère que les parcours du référentiel de compétences pour lesquels au moins une UE a été associée. Renvoie fragment de html """ annee = (semestre_idx - 1) // 2 + 1 # année BUT ref_comp: ApcReferentielCompetences = formation.referentiel_competence if not ref_comp: return "" # détecté ailleurs... niveaux_sans_ue_by_parcour = {} # { parcour.code : [ ue ... ] } parcours_ids = { uep.parcours_id for uep in UEParcours.query.join(UniteEns).filter_by( formation_id=formation.id, type=UE_STANDARD ) } for parcour in ref_comp.parcours: if parcour.id not in parcours_ids: continue # saute parcours associés à aucune UE (tous semestres) niveaux_sans_ue = [] niveaux = ApcNiveau.niveaux_annee_de_parcours(parcour, annee, ref_comp) # print(f"\n# Parcours {parcour.code} : {len(niveaux)} niveaux") for niveau in niveaux: ues = [ue for ue in formation.ues if ue.niveau_competence_id == niveau.id] if not ues: niveaux_sans_ue.append(niveau) # print( niveau.competence.titre + " " + str(niveau.ordre) + "\t" + str(ue) ) if niveaux_sans_ue: niveaux_sans_ue_by_parcour[parcour.code] = niveaux_sans_ue # H = [] for parcour_code, niveaux in niveaux_sans_ue_by_parcour.items(): H.append( f"""<li>Parcours {parcour_code} : { len(niveaux)} niveaux sans UEs : <span class="niveau-nom"><span> { '</span>, <span>'.join( f'{niveau.competence.titre} {niveau.ordre}' for niveau in niveaux ) } </span> </li> """ ) # Combien de compétences de tronc commun ? _, niveaux_by_parcours = ref_comp.get_niveaux_by_parcours(annee) nb_niveaux_tc = len(niveaux_by_parcours["TC"]) nb_ues_tc = len( formation.query_ues_parcour(None) .filter(UniteEns.semestre_idx == semestre_idx) .all() ) if nb_niveaux_tc != nb_ues_tc: H.append( f"""<li>{nb_niveaux_tc} niveaux de compétences de tronc commun, mais {nb_ues_tc} UEs de tronc commun ! (c'est normal si vous avez des UEs différenciées par parcours)</li>""" ) if H: return f"""<div class="formation_semestre_niveaux_warning"> <div>Problèmes détectés à corriger :</div> <ul> {"".join(H)} </ul> </div> """ return "" # no problem detected def ue_associee_au_niveau_du_parcours( ues_possibles: list[UniteEns], niveau: ApcNiveau, sem_name: str = "S" ) -> UniteEns: "L'UE associée à ce niveau, ou None" ues = [ue for ue in ues_possibles if ue.niveau_competence_id == niveau.id] if len(ues) > 1: # plusieurs UEs associées à ce niveau: élimine celles sans parcours ues_pair_avec_parcours = [ue for ue in ues if ue.parcours] if ues_pair_avec_parcours: ues = ues_pair_avec_parcours if len(ues) > 1: log(f"_niveau_ues: {len(ues)} associées au niveau {niveau} / {sem_name}") return ues[0] if ues else None def parcour_formation_competences( parcour: ApcParcours, formation: Formation ) -> tuple[list[dict], float]: """ [ { 'competence' : ApcCompetence, 'niveaux' : { 1 : { ... }, 2 : { ... }, 3 : { 'niveau' : ApcNiveau, 'ue_impair' : UniteEns, # actuellement associée 'ues_impair' : list[UniteEns], # choix possibles 'ue_pair' : UniteEns, 'ues_pair' : list[UniteEns], } } } ], ects_parcours (somme des ects des UEs associées) """ refcomp: ApcReferentielCompetences = formation.referentiel_competence def _niveau_ues(competence: ApcCompetence, annee: int) -> dict: """niveau et ues pour cette compétence de cette année du parcours. Si parcour est None, les niveaux du tronc commun """ if parcour is not None: # L'étudiant est inscrit à un parcours: cherche les niveaux niveaux = ApcNiveau.niveaux_annee_de_parcours( parcour, annee, competence=competence ) else: # sans parcours, on cherche les niveaux du Tronc Commun de cette année niveaux = [ niveau for niveau in refcomp.get_niveaux_by_parcours(annee)[1]["TC"] if niveau.competence_id == competence.id ] if len(niveaux) > 0: if len(niveaux) > 1: log( f"""_niveau_ues: plus d'un niveau pour {competence} annee {annee} {("parcours " + parcour.code) if parcour else ""}""" ) niveau = niveaux[0] elif len(niveaux) == 0: return { "niveau": None, "ue_pair": None, "ue_impair": None, "ues_pair": [], "ues_impair": [], } # Toutes les UEs de la formation dans ce parcours ou tronc commun ues = [ ue for ue in formation.ues if ( (not ue.parcours) or (parcour is not None and (parcour.id in (p.id for p in ue.parcours))) ) and ue.type == UE_STANDARD ] ues_pair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee)] ues_impair_possibles = [ue for ue in ues if ue.semestre_idx == (2 * annee - 1)] # UE associée au niveau dans ce parcours ue_pair = ue_associee_au_niveau_du_parcours( ues_pair_possibles, niveau, f"S{2*annee}" ) ue_impair = ue_associee_au_niveau_du_parcours( ues_impair_possibles, niveau, f"S{2*annee-1}" ) return { "niveau": niveau, "ue_pair": ue_pair, "ues_pair": [ ue for ue in ues_pair_possibles if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id ], "ue_impair": ue_impair, "ues_impair": [ ue for ue in ues_impair_possibles if (not ue.niveau_competence) or ue.niveau_competence.id == niveau.id ], } competences = [ { "competence": competence, "niveaux": {annee: _niveau_ues(competence, annee) for annee in (1, 2, 3)}, } for competence in ( parcour.query_competences() if parcour else refcomp.competences.order_by(ApcCompetence.numero) ) ] ects_parcours = sum( sum( (ni["ue_impair"].ects or 0) if ni["ue_impair"] else 0 for ni in cp["niveaux"].values() ) for cp in competences ) + sum( sum( (ni["ue_pair"].ects or 0) if ni["ue_pair"] else 0 for ni in cp["niveaux"].values() ) for cp in competences ) return competences, ects_parcours