############################################################################## # 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 collections.abc import Iterable 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 ApcValidationAnnee, 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, CursusBUT, 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""" def __init__(self, etud: Identite, 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 (ici diplôme BUT) est validé. Considère le parcours du semestre en cours (res). """ parcour_id = self.nt.etuds_parcour_id.get(self.etud.id) return but_parcours_validated(self.etud, parcour_id) def but_annee_validated( etudid: int, referentiel_competence_id: int, annee: int = 3 ) -> bool: """Vrai si une validation de l'année BUT est enregistrée""" return any( sco_codes.code_annee_validant(v.code) for v in ApcValidationAnnee.query.filter_by( etudid=etudid, ordre=annee, referentiel_competence_id=referentiel_competence_id, ) ) def but_parcours_validated(etud: Identite, parcour_id: int | None) -> bool: """Détermine si le parcours BUT est validé. = 180 ECTS acquis dans les UEs du parcours. """ if parcour_id is None: return False # étudiant non inscrit à un parcours # Les ECTS validations = but_validations_ues_parcours(etud, parcour_id) ects_acquis = validations_count_ects(validations) return ects_acquis >= CursusBUT.ECTS_DIPLOME 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 "Liste des inscriptions aux sem. de la formation, triées par indice et chronologie" self.parcour: ApcParcours = get_etud_parcours( etud, formation.referentiel_competence_id ) "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 get_ects_acquis(self) -> int: "Nombre d'ECTS validés par etud dans le parcours BUT de ce référentiel" return but_ects_valides( self.etud, self.formation.referentiel_competence.id, parcour_id=self.parcour.id if self.parcour is not None else None, ) 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 get_etud_parcours( etud: Identite, referentiel_competence_id: int | None ) -> ApcParcours | None: """Le parcours de l'étudiant dans ce réf. de compétence, ou None. = celui du DERNIER semestre suivi dans le référentiel de compétence (peut être None si l'incription n'a pas de parcours) """ inscriptions = sorted( [ ins for ins in etud.formsemestre_inscriptions if ins.formsemestre.formation.referentiel_competence and ( ins.formsemestre.formation.referentiel_competence.id == referentiel_competence_id ) ], key=lambda s: (s.formsemestre.date_debut, s.formsemestre.semestre_id), ) return inscriptions[-1].parcour if inscriptions else None def but_ects_valides( etud: Identite, referentiel_competence_id: int | None = None, annees_but: None | Iterable[str] = None, parcour_id: int | None = None, ) -> int: """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. On peut spécifier soit le referentiel_competence_id, soit le parcour. Si parcour est spécifié, ne prend que les UEs de ce parcours et du tronc commun. Si annees_but est spécifié, un iterable "BUT1, "BUT2" par exemple, ne prend que ces années. """ validations = ( but_validations_ues_parcours(etud, parcour_id, annees_but) if parcour_id is not None else but_validations_ues(etud, referentiel_competence_id, annees_but) ) return validations_count_ects(validations) def validations_count_ects(validations: list[ScolarFormSemestreValidation]) -> int: """Somme les ECTS validés par ces UEs, en éliminant les éventuels doublons (niveaux de compétences validés plusieurs fois)""" ects_dict = {} for v in validations: key = ( v.ue.semestre_idx, v.ue.niveau_competence.id if v.ue.niveau_competence else None, ) if v.code in CODES_UE_VALIDES: ects_dict[key] = v.ue.ects or 0.0 return int(sum(ects_dict.values())) if ects_dict else 0 def but_validations_ues( etud: Identite, referentiel_competence_id: int, annees_but: None | Iterable[str] = None, ) -> list[ScolarFormSemestreValidation]: """Query les validations d'UEs pour cet étudiant dans des UEs appartenant à ce référentiel de compétence et en option pour les années BUT indiquées. annees_but : None (tout) ou liste [ "BUT1", ... ] """ validations = ( ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) .filter(ScolarFormSemestreValidation.ue_id != None) .join(UniteEns) .join(ApcNiveau) ) # restreint à certaines années (utile pour les ECTS du DUT120) if annees_but: validations = validations.filter(ApcNiveau.annee.in_(annees_but)) # restreint au référentiel de compétence validations = validations.join(ApcCompetence).filter_by( referentiel_id=referentiel_competence_id ) return sorted_validations(validations) def sorted_validations(validations) -> list[ScolarFormSemestreValidation]: """Tri (nb: fait en python pour gérer les validations externes qui n'ont pas de formsemestre)""" return sorted( validations, key=lambda v: ( (v.formsemestre.semestre_id, v.ue.numero, v.ue.acronyme) if v.formsemestre else (v.ue.semestre_idx or -2, v.ue.numero, v.ue.acronyme) ), ) def but_validations_ues_parcours( etud: Identite, parcour_id: int, annees_but: None | Iterable[str] = None ) -> list[ScolarFormSemestreValidation]: """Query les validations d'UEs pour cet étudiant dans des UEs appartenant à ce parcours ou à son tronc commun. """ # Rappel: # Les UEs associées à un parcours: # UniteEns.query.join(UEParcours).filter(UEParcours.parcours_id == parcour.id) ) # Les UEs associées au tronc commun (à aucun parcours) # UniteEns.query.filter(~UniteEns.id.in_(UEParcours.query.with_entities(UEParcours.ue_id))) # Les validations d'UE de ce parcours ou du tronc commun pour cet étudiant: validations = ( ScolarFormSemestreValidation.query.filter_by(etudid=etud.id) .filter(ScolarFormSemestreValidation.ue_id != None) .join(UniteEns) .filter( db.or_( UniteEns.id.in_( UEParcours.query.with_entities(UEParcours.ue_id).filter( UEParcours.parcours_id == parcour_id ) ), ~UniteEns.id.in_(UEParcours.query.with_entities(UEParcours.ue_id)), ) ) ) # restreint à certaines années (utile pour les ECTS du DUT120) if annees_but: validations = validations.join(ApcNiveau).filter( ApcNiveau.annee.in_(annees_but) ) return sorted_validations(validations) 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"""
""" 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"""Vérifiez les parcours cochés pour ce semestre, et les associations entre UE et niveaux dans la formation.