diff --git a/app/but/jury_but.py b/app/but/jury_but.py
index b105bc7c..9997a736 100644
--- a/app/but/jury_but.py
+++ b/app/but/jury_but.py
@@ -1,1092 +1,1107 @@
-##############################################################################
-# ScoDoc
-# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
-# See LICENSE
-##############################################################################
-
-"""Jury BUT: logique de gestion
-
-Utilisation:
- 1) chargement page jury, pour un étudiant et un formsemestre BUT quelconque
- - DecisionsProposeesAnnee(formsemestre)
- cherche l'autre formsemestre de la même année scolaire (peut ne pas exister)
- cherche les RCUEs de l'année (BUT1, 2, 3)
- pour un redoublant, le RCUE peut considérer un formsemestre d'une année antérieure.
-
- on instancie des DecisionsProposees pour les
- différents éléments (UEs, RCUEs, Année, Diplôme)
- Cela donne
- - les codes possibles (dans .codes)
- - le code actuel si une décision existe déjà (dans code_valide)
- - pour les UEs, le rcue s'il y en a un)
-
- 2) Validation pour l'utilisateur (form)) => enregistrement code
- - on vérifie que le code soumis est bien dans les codes possibles
- - on enregistre la décision (dans ScolarFormSemestreValidation pour les UE,
- ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années)
- - Si RCUE validé, on déclenche d'éventuelles validations:
- ("La validation des deux UE du niveau d’une compétence emporte la validation
- de l’ensemble des UE du niveau inférieur de cette même compétence.")
-
-Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une
-autorisation d'inscription dans le semestre pair suivant: `ScolarAutorisationInscription`.
-Les jurys de semestres pairs non (S2, S4, S6): il y a une décision sur l'année (ETP)
- - autorisation en S_2n+1 (S3 ou S5) si: ADM, ADJ, PASD, PAS1CN
- - autorisation en S2n-1 (S1, S3 ou S5) si: RED
- - rien si pour les autres codes d'année.
-
-Le formulaire permet de choisir des codes d'UE, RCUE et Année (ETP).
-Mais normalement, les codes d'UE sont à choisir: les RCUE et l'année s'en déduisent.
-Si l'utilisateur coche "décision manuelle", il peut alors choisir les codes RCUE et années.
-
-La soumission du formulaire:
- - etud, formation
- - UEs: [(formsemestre, ue, code), ...]
- - RCUE: [(formsemestre, ue, code), ...] le formsemestre est celui d'indice pair du niveau
- (S2, S4 ou S6), il sera regoupé avec celui impair de la même année ou de la suivante.
- - Année: [(formsemestre, code)]
-
-DecisionsProposeesAnnee:
- si 1/2 des rcue et aucun < 8 + pour S5 condition sur les UE de BUT1 et BUT2
- => charger les DecisionsProposeesRCUE
-
-DecisionsProposeesRCUE: les RCUEs pour cette année
- validable, compensable, ajourné. Utilise classe RegroupementCoherentUE
-
-DecisionsProposeesUE: décisions de jury sur une UE du BUT
- initialisation sans compensation (ue isolée), mais
- DecisionsProposeesRCUE appelera .set_compensable()
- si on a la possibilité de la compenser dans le RCUE.
-"""
-import html
-from operator import attrgetter
-import re
-from typing import Union
-
-import numpy as np
-from flask import g, url_for
-
-from app import db
-from app import log
-from app.comp.res_but import ResultatsSemestreBUT
-from app.comp import res_sem
-
-from app.models.but_refcomp import (
- ApcAnneeParcours,
- ApcCompetence,
- ApcNiveau,
- ApcParcours,
- ApcParcoursNiveauCompetence,
-)
-from app.models import Scolog, ScolarAutorisationInscription
-from app.models.but_validations import (
- ApcValidationAnnee,
- ApcValidationRCUE,
- RegroupementCoherentUE,
-)
-from app.models.etudiants import Identite
-from app.models.formations import Formation
-from app.models.formsemestre import FormSemestre, FormSemestreInscription
-from app.models.ues import UniteEns
-from app.models.validations import ScolarFormSemestreValidation
-from app.scodoc import sco_cache
-from app.scodoc import sco_codes_parcours as sco_codes
-from app.scodoc.sco_codes_parcours import RED, UE_STANDARD
-from app.scodoc import sco_utils as scu
-from app.scodoc.sco_exceptions import ScoException, ScoValueError
-
-
-class NoRCUEError(ScoValueError):
- """Erreur en cas de RCUE manquant"""
-
- def __init__(self, deca: "DecisionsProposeesAnnee", ue: UniteEns):
- if all(u.niveau_competence for u in deca.ues_pair):
- warning_pair = ""
- else:
- warning_pair = """
certaines UE du semestre pair ne sont pas associées à un niveau de compétence
"""
- if all(u.niveau_competence for u in deca.ues_impair):
- warning_impair = ""
- else:
- warning_impair = """
certaines UE du semestre impair ne sont pas associées à un niveau de compétence
"""
- msg = (
- f"""
Pas de RCUE pour l'UE {ue.acronyme}
- {warning_impair}
- {warning_pair}
-
UE {ue.acronyme}: niveau {html.escape(str(ue.niveau_competence))}
-
UEs impaires: {html.escape(', '.join(str(u.niveau_competence or "pas de niveau")
- for u in deca.ues_impair))}
-
- """
- + deca.infos()
- )
- super().__init__(msg)
-
-
-class DecisionsProposees:
- """Une décision de jury proposé, constituée d'une liste de codes et d'une explication.
- Super-classe, spécialisée pour les UE, les RCUE, les années et le diplôme.
-
- validation : None ou une instance de d'une classe avec un champ code
- ApcValidationRCUE, ApcValidationAnnee ou ScolarFormSemestreValidation
- """
-
- # Codes toujours proposés sauf si include_communs est faux:
- codes_communs = [
- sco_codes.RAT,
- sco_codes.DEF,
- sco_codes.ABAN,
- sco_codes.DEM,
- sco_codes.UEBSL,
- ]
-
- def __init__(
- self,
- etud: Identite = None,
- code: Union[str, list[str]] = None,
- explanation="",
- code_valide=None,
- include_communs=True,
- ):
- self.etud = etud
- self.codes = []
- "Les codes attribuables par ce jury"
- if include_communs:
- self.codes = self.codes_communs.copy()
- if isinstance(code, list):
- self.codes = code + self.codes
- elif code is not None:
- self.codes = [code] + self.codes
- self.validation = None
- "Validation enregistrée"
- self.code_valide: str = code_valide
- "Code décision actuel enregistré"
- # S'assure que le code enregistré est toujours présent dans le menu
- if self.code_valide and self.code_valide not in self.codes:
- self.codes.append(self.code_valide)
- self.explanation: str = explanation
- "Explication à afficher à côté de la décision"
- self.recorded = False
- "true si la décision vient d'être enregistrée"
-
- def __repr__(self) -> str:
- return f"""<{self.__class__.__name__} valid={self.code_valide
- } codes={self.codes} explanation={self.explanation}"""
-
-
-class DecisionsProposeesAnnee(DecisionsProposees):
- """Décisions de jury sur une année (ETP) du BUT
-
- Le texte:
- La poursuite d'études dans un semestre pair d’une même année est de droit
- pour tout étudiant. La poursuite d’études dans un semestre impair est
- possible si et seulement si l’étudiant a obtenu :
- - la moyenne à plus de la moitié des regroupements cohérents d’UE;
- - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE.
- La poursuite d'études dans le semestre 5 nécessite de plus la validation
- de toutes les UE des semestres 1 et 2 dans les conditions de validation
- des points 4.3 (moy_ue >= 10) et 4.4 (compensation rcue), ou par décision
- de jury.
- """
-
- # Codes toujours proposés sauf si include_communs est faux:
- codes_communs = [
- sco_codes.RAT,
- sco_codes.ABAN,
- sco_codes.ABL,
- sco_codes.ATJ,
- sco_codes.DEF,
- sco_codes.DEM,
- sco_codes.EXCLU,
- ]
-
- def __init__(
- self,
- etud: Identite,
- formsemestre: FormSemestre,
- ):
- super().__init__(etud=etud)
- self.formsemestre_id = formsemestre.id
- formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre)
- assert (
- (formsemestre_pair is None)
- or (formsemestre_impair is None)
- or (
- ((formsemestre_pair.semestre_id - formsemestre_impair.semestre_id) == 1)
- and (
- formsemestre_pair.formation.referentiel_competence_id
- == formsemestre_impair.formation.referentiel_competence_id
- )
- )
- )
-
- self.formsemestre_impair = formsemestre_impair
- "le 1er semestre de l'année scolaire considérée (S1, S3, S5)"
- self.formsemestre_pair = formsemestre_pair
- "le second formsemestre de la même année scolaire (S2, S4, S6)"
- formsemestre_last = formsemestre_pair or formsemestre_impair
- "le formsemestre le plus avancé dans cette année"
-
- self.annee_but = (formsemestre_last.semestre_id + 1) // 2
- "le rang de l'année dans le BUT: 1, 2, 3"
- assert self.annee_but in (1, 2, 3)
- self.rcues_annee = []
- "RCUEs de l'année"
- self.inscription_etat = etud.inscription_etat(formsemestre_last.id)
-
- if self.formsemestre_impair is not None:
- self.validation = ApcValidationAnnee.query.filter_by(
- etudid=self.etud.id,
- formsemestre_id=formsemestre_impair.id,
- ordre=self.annee_but,
- ).first()
- else:
- self.validation = None
- if self.validation is not None:
- self.code_valide = self.validation.code
- self.parcour = None
- "Le parcours considéré (celui du semestre pair, ou à défaut impair)"
- if self.formsemestre_pair is not None:
- self.res_pair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
- self.formsemestre_pair
- )
- else:
- self.res_pair = None
- if self.formsemestre_impair is not None:
- self.res_impair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
- self.formsemestre_impair
- )
- else:
- self.res_impair = None
-
- self.ues_impair, self.ues_pair = self.compute_ues_annee() # pylint: disable=all
- self.decisions_ues = {
- ue.id: DecisionsProposeesUE(
- etud, formsemestre_impair, ue, self.inscription_etat
- )
- for ue in self.ues_impair
- }
- "{ue_id : DecisionsProposeesUE} pour toutes les UE de l'année"
- self.decisions_ues.update(
- {
- ue.id: DecisionsProposeesUE(
- etud, formsemestre_pair, ue, self.inscription_etat
- )
- for ue in self.ues_pair
- }
- )
- self.rcues_annee = self.compute_rcues_annee()
-
- formation = (
- self.formsemestre_impair.formation
- if self.formsemestre_impair
- else self.formsemestre_pair.formation
- )
- self.niveaux_competences = ApcNiveau.niveaux_annee_de_parcours(
- self.parcour, self.annee_but, formation.referentiel_competence
- ).all() # non triés
- "liste des niveaux de compétences associés à cette année"
- self.decisions_rcue_by_niveau = self.compute_decisions_niveaux()
- "les décisions rcue associées aux niveau_id"
- self.dec_rcue_by_ue = self._dec_rcue_by_ue()
- "{ ue_id : DecisionsProposeesRCUE } pour toutes les UE associées à un niveau"
- self.nb_competences = len(self.niveaux_competences)
- "le nombre de niveaux de compétences à valider cette année"
- rcues_avec_niveau = [d.rcue for d in self.decisions_rcue_by_niveau.values()]
- self.nb_validables = len(
- [rcue for rcue in rcues_avec_niveau if rcue.est_validable()]
- )
- "le nombre de comp. validables (éventuellement par compensation)"
- self.nb_rcue_valides = len(
- [rcue for rcue in rcues_avec_niveau if rcue.code_valide()]
- )
- "le nombre de niveaux validés (déc. jury prise)"
- self.nb_rcues_under_8 = len(
- [rcue for rcue in rcues_avec_niveau if not rcue.est_suffisant()]
- )
- "le nb de comp. sous la barre de 8/20"
- # année ADM si toutes RCUE validées (sinon PASD) et non DEM ou DEF
- self.admis = (self.nb_validables == self.nb_competences) and (
- self.inscription_etat == scu.INSCRIT
- )
- "vrai si l'année est réussie, tous niveaux validables ou validés par le jury"
- self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2)
- # Peut passer si plus de la moitié validables et tous > 8
- self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0)
- # XXX TODO ajouter condition pour passage en S5
-
- # Enfin calcule les codes des UE:
- for dec_ue in self.decisions_ues.values():
- dec_ue.compute_codes()
-
- # Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR
- expl_rcues = (
- f"{self.nb_validables} niveau validable(s) sur {self.nb_competences}"
- )
- if self.admis:
- self.codes = [sco_codes.ADM] + self.codes
- self.explanation = expl_rcues
- elif self.inscription_etat != scu.INSCRIT:
- self.codes = [
- sco_codes.DEM
- if self.inscription_etat == scu.DEMISSION
- else sco_codes.DEF,
- # propose aussi d'autres codes, au cas où...
- sco_codes.DEM
- if self.inscription_etat != scu.DEMISSION
- else sco_codes.DEF,
- sco_codes.ABAN,
- sco_codes.ABL,
- sco_codes.EXCLU,
- ]
- elif self.passage_de_droit:
- self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes
- self.explanation = expl_rcues
- elif self.valide_moitie_rcue: # mais au moins 1 rcue insuffisante
- self.codes = [
- sco_codes.RED,
- sco_codes.NAR,
- sco_codes.PAS1NCI,
- sco_codes.ADJ,
- ] + self.codes
- self.explanation = expl_rcues + f" et {self.nb_rcues_under_8} < 8"
- else:
- self.codes = [
- sco_codes.RED,
- sco_codes.NAR,
- sco_codes.PAS1NCI,
- sco_codes.ADJ,
- sco_codes.PASD, # voir #488 (discutable, conventions locales)
- ] + self.codes
- self.explanation = (
- expl_rcues
- + f""" et {self.nb_rcues_under_8}
- niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
- )
- #
-
- def infos(self) -> str:
- "informations, for debugging purpose"
- return f"""DecisionsProposeesAnnee
-
- """
-
- def annee_scolaire(self) -> int:
- "L'année de début de l'année scolaire"
- formsemestre = self.formsemestre_impair or self.formsemestre_pair
- return formsemestre.annee_scolaire()
-
- def annee_scolaire_str(self) -> str:
- "L'année scolaire, eg '2021 - 2022'"
- formsemestre = self.formsemestre_impair or self.formsemestre_pair
- return formsemestre.annee_scolaire_str().replace(" ", "")
-
- def comp_formsemestres(
- self, formsemestre: FormSemestre
- ) -> tuple[FormSemestre, FormSemestre]:
- """les deux formsemestres de l'année scolaire à laquelle appartient formsemestre."""
- if not formsemestre.formation.is_apc(): # garde fou
- return None, None
- if formsemestre.semestre_id % 2 == 0:
- other_semestre_id = formsemestre.semestre_id - 1
- else:
- other_semestre_id = formsemestre.semestre_id + 1
- annee_scolaire = formsemestre.annee_scolaire()
- other_formsemestre = None
- for inscr in self.etud.formsemestre_inscriptions:
- if (
- # Même spécialité BUT (tolère ainsi des variantes de formation)
- (
- inscr.formsemestre.formation.referentiel_competence
- == formsemestre.formation.referentiel_competence
- )
- # L'autre semestre
- and (inscr.formsemestre.semestre_id == other_semestre_id)
- # de la même année scolaire:
- and (inscr.formsemestre.annee_scolaire() == annee_scolaire)
- ):
- other_formsemestre = inscr.formsemestre
- if formsemestre.semestre_id % 2 == 0:
- return other_formsemestre, formsemestre
- return formsemestre, other_formsemestre
-
- def compute_ues_annee(self) -> list[list[UniteEns], list[UniteEns]]:
- """UEs à valider cette année pour cet étudiant, selon son parcours.
- Ramène [ listes des UE du semestre impair, liste des UE du semestre pair ].
- """
- etudid = self.etud.id
- ues_sems = []
- for (formsemestre, res) in (
- (self.formsemestre_impair, self.res_impair),
- (self.formsemestre_pair, self.res_pair),
- ):
- if (formsemestre is None) or (not formsemestre.formation.is_apc()):
- ues = []
- else:
- formation: Formation = formsemestre.formation
- # Parcour dans lequel l'étudiant est inscrit, et liste des UEs
- if res.etuds_parcour_id[etudid] is None:
- # pas de parcour: prend toutes les UEs (non bonus)
- ues = [ue for ue in res.etud_ues(etudid) if ue.type == UE_STANDARD]
- ues.sort(key=lambda u: u.numero)
- else:
- parcour = ApcParcours.query.get(res.etuds_parcour_id[etudid])
- if parcour is not None:
- self.parcour = parcour
- ues = (
- formation.query_ues_parcour(parcour)
- .filter_by(semestre_idx=formsemestre.semestre_id)
- .order_by(UniteEns.numero)
- .all()
- )
- ues_sems.append(ues)
- return ues_sems
-
- def check_ues_ready_jury(self) -> list[str]:
- """Vérifie que les toutes les UEs (hors bonus) de l'année sont
- bien associées à des niveaux de compétences.
- Renvoie liste vide si ok, sinon liste de message explicatifs
- """
- messages = []
- for ue in self.ues_impair + self.ues_pair:
- if ue.niveau_competence is None:
- messages.append(
- f"UE {ue.acronyme} non associée à un niveau de compétence"
- )
- if ue.semestre_idx is None:
- messages.append(
- f"UE {ue.acronyme} n'a pas d'indice de semestre dans la formation"
- )
- return messages
-
- def compute_rcues_annee(self) -> list[RegroupementCoherentUE]:
- """Liste des regroupements d'UE à considérer cette année.
- Pour le moment on ne considère pas de RCUE à cheval sur plusieurs années (redoublants).
- Si on n'a pas les deux semestres, aucun RCUE.
- Raises ScoValueError s'il y a des UE sans RCUE.
- """
- if self.formsemestre_pair is None or self.formsemestre_impair is None:
- return []
- rcues_annee = []
- ues_impair_sans_rcue = {ue.id for ue in self.ues_impair}
- for ue_pair in self.ues_pair:
- rcue = None
- for ue_impair in self.ues_impair:
- if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id:
- rcue = RegroupementCoherentUE(
- self.etud,
- self.formsemestre_impair,
- ue_impair,
- self.formsemestre_pair,
- ue_pair,
- self.inscription_etat,
- )
- ues_impair_sans_rcue.discard(ue_impair.id)
- break
- if rcue is None:
- raise NoRCUEError(deca=self, ue=ue_pair)
- rcues_annee.append(rcue)
- if len(ues_impair_sans_rcue) > 0:
- ue = UniteEns.query.get(ues_impair_sans_rcue.pop())
- raise NoRCUEError(deca=self, ue=ue)
- return rcues_annee
-
- def compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]:
- """Pour chaque niveau de compétence de cette année, construit
- le DecisionsProposeesRCUE,
- ou None s'il n'y en a pas
- (ne devrait pas arriver car compute_rcues_annee vérifie déjà cela).
- Return: { niveau_id : DecisionsProposeesRCUE }
- """
- # Retrouve le RCUE associé à chaque niveau
- rc_niveaux = []
- for niveau in self.niveaux_competences:
- rcue = None
- for rc in self.rcues_annee:
- if rc.ue_1.niveau_competence_id == niveau.id:
- rcue = rc
- break
- if rcue is not None:
- dec_rcue = DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
- rc_niveaux.append((dec_rcue, niveau.id))
- # prévient les UE concernées :-)
- self.decisions_ues[dec_rcue.rcue.ue_1.id].set_rcue(dec_rcue.rcue)
- self.decisions_ues[dec_rcue.rcue.ue_2.id].set_rcue(dec_rcue.rcue)
- # Ordonne par numéro d'UE
- rc_niveaux.sort(key=lambda x: x[0].rcue.ue_1.numero)
- decisions_rcue_by_niveau = {x[1]: x[0] for x in rc_niveaux}
- return decisions_rcue_by_niveau
-
- def _dec_rcue_by_ue(self) -> dict[int, "DecisionsProposeesRCUE"]:
- """construit dict { ue_id : DecisionsProposeesRCUE }
- à partir de self.decisions_rcue_by_niveau"""
- d = {}
- for dec_rcue in self.decisions_rcue_by_niveau.values():
- d[dec_rcue.rcue.ue_1.id] = dec_rcue
- d[dec_rcue.rcue.ue_2.id] = dec_rcue
- return d
-
- def next_annee_semestre_id(self, code: str) -> int:
- """L'indice du semestre dans lequel l'étudiant est autorisé à
- poursuivre l'année suivante. None si aucun."""
- if self.formsemestre_pair is None:
- return None # seulement sur année
- if code == RED:
- return self.formsemestre_pair.semestre_id - 1
- elif (
- code in sco_codes.BUT_CODES_PASSAGE
- and self.formsemestre_pair.semestre_id < sco_codes.ParcoursBUT.NB_SEM
- ):
- return self.formsemestre_pair.semestre_id + 1
- return None
-
- def record_form(self, form: dict):
- """Enregistre les codes de jury en base
- form dict:
- - 'code_ue_1896' : 'AJ' code pour l'UE id 1896
- - 'code_rcue_6" : 'ADM' code pour le RCUE du niveau 6
- - 'code_annee' : 'ADM' code pour l'année
-
- Si les code_rcue et le code_annee ne sont pas fournis,
- et qu'il n'y en a pas déjà, enregistre ceux par défaut.
- """
- log("jury_but.DecisionsProposeesAnnee.record_form")
- with sco_cache.DeferredSemCacheManager():
- for key in form:
- code = form[key]
- # Codes d'UE
- m = re.match(r"^code_ue_(\d+)$", key)
- if m:
- ue_id = int(m.group(1))
- dec_ue = self.decisions_ues.get(ue_id)
- if not dec_ue:
- raise ScoValueError(f"UE invalide ue_id={ue_id}")
- dec_ue.record(code)
- else:
- # Codes de RCUE
- m = re.match(r"^code_rcue_(\d+)$", key)
- if m:
- niveau_id = int(m.group(1))
- dec_rcue = self.decisions_rcue_by_niveau.get(niveau_id)
- if not dec_rcue:
- raise ScoValueError(f"RCUE invalide niveau_id={niveau_id}")
- dec_rcue.record(code)
- elif key == "code_annee":
- # Code annuel
- self.record(code)
-
- self.record_all()
- db.session.commit()
-
- def record(self, code: str, no_overwrite=False):
- """Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
- Si no_overwrite, ne fait rien si un code est déjà enregistré.
- """
- if code and not code in self.codes:
- raise ScoValueError(
- f"code annee {html.escape(code)} invalide pour formsemestre {html.escape(self.formsemestre)}"
- )
- if code == self.code_valide or (self.code_valide is not None and no_overwrite):
- self.recorded = True
- return # no change
- if self.validation:
- db.session.delete(self.validation)
- db.session.flush()
- if code is None:
- self.validation = None
- else:
- self.validation = ApcValidationAnnee(
- etudid=self.etud.id,
- formsemestre=self.formsemestre_impair,
- ordre=self.annee_but,
- annee_scolaire=self.annee_scolaire(),
- code=code,
- )
- Scolog.logdb(
- method="jury_but",
- etudid=self.etud.id,
- msg=f"Validation année BUT{self.annee_but}: {code}",
- )
- db.session.add(self.validation)
- # --- Autorisation d'inscription dans semestre suivant ?
- if self.formsemestre_pair is not None:
- if code is None:
- ScolarAutorisationInscription.delete_autorisation_etud(
- etudid=self.etud.id,
- origin_formsemestre_id=self.formsemestre_pair.id,
- )
- else:
- next_semestre_id = self.next_annee_semestre_id(code)
- if next_semestre_id is not None:
- ScolarAutorisationInscription.autorise_etud(
- self.etud.id,
- self.formsemestre_pair.formation.formation_code,
- self.formsemestre_pair.id,
- next_semestre_id,
- )
-
- self.recorded = True
- self.invalidate_formsemestre_cache()
-
- def invalidate_formsemestre_cache(self):
- "invalide le résultats des deux formsemestres"
- if self.formsemestre_impair is not None:
- sco_cache.invalidate_formsemestre(
- formsemestre_id=self.formsemestre_impair.id
- )
- if self.formsemestre_pair is not None:
- sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
-
- def record_all(self):
- """Enregistre les codes qui n'ont pas été spécifiés par le formulaire, et sont donc en mode "automatique" """
- decisions = (
- list(self.decisions_ues.values())
- + list(self.decisions_rcue_by_niveau.values())
- + [self]
- )
- for dec in decisions:
- if not dec.recorded:
- # rappel: le code par défaut est en tête
- code = dec.codes[0] if dec.codes else None
- # s'il n'y a pas de code, efface
- dec.record(code, no_overwrite=True)
-
- def erase(self):
- """Efface les décisions de jury de cet étudiant
- pour cette année: décisions d'UE, de RCUE, d'année,
- et autorisations d'inscription émises.
- """
- for dec_ue in self.decisions_ues.values():
- dec_ue.erase()
- for dec_rcue in self.decisions_rcue_by_niveau.values():
- dec_rcue.erase()
- if self.formsemestre_impair:
- ScolarAutorisationInscription.delete_autorisation_etud(
- self.etud.id, self.formsemestre_impair.id
- )
- if self.formsemestre_pair:
- ScolarAutorisationInscription.delete_autorisation_etud(
- self.etud.id, self.formsemestre_pair.id
- )
- validations = ApcValidationAnnee.query.filter_by(
- etudid=self.etud.id,
- formsemestre_id=self.formsemestre_impair.id,
- ordre=self.annee_but,
- )
- for validation in validations:
- db.session.delete(validation)
- db.session.flush()
- self.invalidate_formsemestre_cache()
-
- def get_autorisations_passage(self) -> list[int]:
- """Les liste des indices de semestres auxquels on est autorisé à
- s'inscrire depuis cette année"""
- formsemestre = self.formsemestre_pair or self.formsemestre_impair
- if not formsemestre:
- return []
- return [
- a.semestre_id
- for a in ScolarAutorisationInscription.query.filter_by(
- etudid=self.etud.id,
- origin_formsemestre_id=formsemestre.id,
- )
- ]
-
- def descr_niveaux_validation(self, line_sep: str = "\n") -> str:
- """Description textuelle des niveaux validés (enregistrés)
- pour PV jurys
- """
- validations = [
- dec_rcue.descr_validation()
- for dec_rcue in self.decisions_rcue_by_niveau.values()
- ]
- return line_sep.join(v for v in validations if v)
-
- def descr_ues_validation(self, line_sep: str = "\n") -> str:
- """Description textuelle des UE validées (enregistrés)
- pour PV jurys
- """
- validations = []
- for res in (self.res_impair, self.res_pair):
- if res:
- dec_ues = [
- self.decisions_ues[ue.id]
- for ue in res.ues
- if ue.type == UE_STANDARD and ue.id in self.decisions_ues
- ]
- valids = [dec_ue.descr_validation() for dec_ue in dec_ues]
- validations.append(", ".join(v for v in valids if v))
- return line_sep.join(validations)
-
-
-class DecisionsProposeesRCUE(DecisionsProposees):
- """Liste des codes de décisions que l'on peut proposer pour
- le RCUE de cet étudiant dans cette année.
-
- ADM, CMP, ADJ, AJ, RAT, DEF, ABAN
- """
-
- codes_communs = [
- sco_codes.ADJ,
- sco_codes.ATJ,
- sco_codes.RAT,
- sco_codes.DEF,
- sco_codes.ABAN,
- ]
-
- def __init__(
- self,
- dec_prop_annee: DecisionsProposeesAnnee,
- rcue: RegroupementCoherentUE,
- inscription_etat: str = scu.INSCRIT,
- ):
- super().__init__(etud=dec_prop_annee.etud)
- self.rcue = rcue
- if rcue is None: # RCUE non dispo, eg un seul semestre
- self.codes = []
- return
- self.inscription_etat = inscription_etat
- "inscription: I, DEM, DEF"
- self.parcour = dec_prop_annee.parcour
- if inscription_etat != scu.INSCRIT:
- self.validation = None # cache toute validation
- self.explanation = "non incrit (dem. ou déf.)"
- self.codes = [
- sco_codes.DEM if inscription_etat == scu.DEMISSION else sco_codes.DEF
- ]
- return
- self.validation = rcue.query_validations().first()
- if self.validation is not None:
- self.code_valide = self.validation.code
- if rcue.est_compensable():
- self.codes.insert(0, sco_codes.CMP)
- # les interprétations varient, on autorise aussi ADM:
- self.codes.insert(1, sco_codes.ADM)
- elif rcue.est_validable():
- self.codes.insert(0, sco_codes.ADM)
- else:
- self.codes.insert(0, sco_codes.AJ)
-
- def record(self, code: str, no_overwrite=False):
- """Enregistre le code"""
- if code and not code in self.codes:
- raise ScoValueError(
- f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
- )
- if code == self.code_valide or (self.code_valide is not None and no_overwrite):
- self.recorded = True
- return # no change
- parcours_id = self.parcour.id if self.parcour is not None else None
- if self.validation:
- db.session.delete(self.validation)
- db.session.flush()
- if code is None:
- self.validation = None
- else:
- self.validation = ApcValidationRCUE(
- etudid=self.etud.id,
- formsemestre_id=self.rcue.formsemestre_2.id,
- ue1_id=self.rcue.ue_1.id,
- ue2_id=self.rcue.ue_2.id,
- parcours_id=parcours_id,
- code=code,
- )
- Scolog.logdb(
- method="jury_but",
- etudid=self.etud.id,
- msg=f"Validation RCUE {repr(self.rcue)}",
- )
- db.session.add(self.validation)
- if self.rcue.formsemestre_1 is not None:
- sco_cache.invalidate_formsemestre(
- formsemestre_id=self.rcue.formsemestre_1.id
- )
- if self.rcue.formsemestre_2 is not None:
- sco_cache.invalidate_formsemestre(
- formsemestre_id=self.rcue.formsemestre_2.id
- )
- self.recorded = True
-
- def erase(self):
- """Efface la décision de jury de cet étudiant pour cet RCUE"""
- # par prudence, on requete toutes les validations, en cas de doublons
- validations = self.rcue.query_validations()
- for validation in validations:
- db.session.delete(validation)
- db.session.flush()
-
- def descr_validation(self) -> str:
- """Description validation niveau enregistrée, pour PV jury.
- Si le niveau est validé, done son acronyme, sinon chaine vide.
- """
- if self.code_valide in sco_codes.CODES_RCUE_VALIDES:
- if (
- self.rcue and self.rcue.ue_1 and self.rcue.ue_1.niveau_competence
- ): # prudence !
- niveau_titre = self.rcue.ue_1.niveau_competence.competence.titre or ""
- ordre = self.rcue.ue_1.niveau_competence.ordre
- else:
- return "?" # oups ?
- return f"{niveau_titre} niv. {ordre}"
- return ""
-
-
-class DecisionsProposeesUE(DecisionsProposees):
- """Décisions de jury sur une UE du BUT
-
- Liste des codes de décisions que l'on peut proposer pour
- cette UE d'un étudiant dans un semestre.
-
- Si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL
-
- si moy_ue > 10, ADM
- sinon si compensation dans RCUE: CMP
- sinon: ADJ, AJ
-
- et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL (codes_communs)
- """
-
- # Codes toujours proposés sauf si include_communs est faux:
- codes_communs = [
- sco_codes.RAT,
- sco_codes.DEF,
- sco_codes.ABAN,
- sco_codes.ATJ,
- sco_codes.DEM,
- sco_codes.UEBSL,
- ]
-
- def __init__(
- self,
- etud: Identite,
- formsemestre: FormSemestre,
- ue: UniteEns,
- inscription_etat: str = scu.INSCRIT,
- ):
- # Une UE peut être validée plusieurs fois en cas de redoublement (qu'elle soit capitalisée ou non)
- # mais ici on a restreint au formsemestre donc une seule (prend la première)
- self.validation = ScolarFormSemestreValidation.query.filter_by(
- etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id
- ).first()
- super().__init__(
- etud=etud,
- code_valide=self.validation.code if self.validation is not None else None,
- )
- # log(f"built {self}")
- self.formsemestre = formsemestre
- self.ue: UniteEns = ue
- self.rcue: RegroupementCoherentUE = None
- "Le rcue auquel est rattaché cette UE, ou None"
- self.inscription_etat = inscription_etat
- "inscription: I, DEM, DEF"
- if ue.type == sco_codes.UE_SPORT:
- self.explanation = "UE bonus, pas de décision de jury"
- self.codes = [] # aucun code proposé
- return
- if inscription_etat != scu.INSCRIT:
- self.validation = None # cache toute validation
- self.explanation = "non incrit (dem. ou déf.)"
- self.codes = [
- sco_codes.DEM if inscription_etat == scu.DEMISSION else sco_codes.DEF
- ]
- self.moy_ue = np.NaN
- return
-
- # Moyenne de l'UE ?
- res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
- if not ue.id in res.etud_moy_ue:
- self.explanation = "UE sans résultat"
- return
- if not etud.id in res.etud_moy_ue[ue.id]:
- self.explanation = "Étudiant sans résultat dans cette UE"
- return
- self.moy_ue = res.etud_moy_ue[ue.id][etud.id]
-
- def set_rcue(self, rcue: RegroupementCoherentUE):
- """Rattache cette UE à un RCUE. Cela peut modifier les codes
- proposés (si compensation)"""
- self.rcue = rcue
-
- def compute_codes(self):
- """Calcul des .codes attribuables et de l'explanation associée"""
- if self.inscription_etat != scu.INSCRIT:
- return
- if self.moy_ue > (sco_codes.ParcoursBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE):
- self.codes.insert(0, sco_codes.ADM)
- self.explanation = (f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20",)
- elif self.rcue and self.rcue.est_compensable():
- self.codes.insert(0, sco_codes.CMP)
- self.explanation = "compensable dans le RCUE"
- else:
- # Échec à valider cette UE
- self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
- self.explanation = "notes insuffisantes"
-
- def record(self, code: str, no_overwrite=False):
- """Enregistre le code jury pour cette UE.
- Si no_overwrite, n'enregistre pas s'il y a déjà un code.
- """
- if code and not code in self.codes:
- raise ScoValueError(
- f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
- )
- if code == self.code_valide or (self.code_valide is not None and no_overwrite):
- self.recorded = True
- return # no change
- self.erase()
- if code is None:
- self.validation = None
- else:
- self.validation = ScolarFormSemestreValidation(
- etudid=self.etud.id,
- formsemestre_id=self.formsemestre.id,
- ue_id=self.ue.id,
- code=code,
- moy_ue=self.moy_ue,
- )
- Scolog.logdb(
- method="jury_but",
- etudid=self.etud.id,
- msg=f"Validation UE {self.ue.id}",
- )
- log(f"DecisionsProposeesUE: recording {self.validation}")
- db.session.add(self.validation)
-
- sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id)
- self.recorded = True
-
- def erase(self):
- """Efface la décision de jury de cet étudiant pour cette UE"""
- # par prudence, on requete toutes les validations, en cas de doublons
- validations = ScolarFormSemestreValidation.query.filter_by(
- etudid=self.etud.id, formsemestre_id=self.formsemestre.id, ue_id=self.ue.id
- )
- for validation in validations:
- log(f"DecisionsProposeesUE: deleting {validation}")
- db.session.delete(validation)
- db.session.flush()
-
- def descr_validation(self) -> str:
- """Description validation niveau enregistrée, pour PV jury.
- Si l'UE est validée, donne son acronyme, sinon chaine vide.
- """
- if self.code_valide in sco_codes.CODES_UE_VALIDES:
- return f"{self.ue.acronyme}"
- return ""
-
-
-class BUTCursusEtud: # WIP TODO
- """Validation du cursus d'un étudiant"""
-
- def __init__(self, formsemestre: FormSemestre, etud: Identite):
- if formsemestre.formation.referentiel_competence is None:
- raise ScoException("BUTCursusEtud: pas de référentiel de compétences")
- assert len(etud.formsemestre_inscriptions) > 0
- self.formsemestre = formsemestre
- self.etud = etud
- #
- # La dernière inscription en date va donner le parcours (donc les compétences à valider)
- self.last_inscription = sorted(
- etud.formsemestre_inscriptions, key=attrgetter("formsemestre.date_debut")
- )[-1]
-
- def est_diplomable(self) -> bool:
- """Vrai si toutes les compétences sont validables"""
- return all(
- self.competence_validable(competence)
- for competence in self.competences_du_parcours()
- )
-
- def est_diplome(self) -> bool:
- """Vrai si BUT déjà validé"""
- # vrai si la troisième année est validée
- # On cherche les validations de 3ieme annee (ordre=3) avec le même référentiel
- # de formation que nous.
- return (
- ApcValidationAnnee.query.filter_by(etudid=self.etud.id, ordre=3)
- .join(FormSemestre, FormSemestre.id == ApcValidationAnnee.formsemestre_id)
- .join(Formation, FormSemestre.formation_id == Formation.id)
- .filter(
- Formation.referentiel_competence_id
- == self.formsemestre.formation.referentiel_competence_id
- )
- .count()
- > 0
- )
-
- def competences_du_parcours(self) -> list[ApcCompetence]:
- """Construit liste des compétences du parcours, qui doivent être
- validées pour obtenir le diplôme.
- Le parcours est celui de la dernière inscription.
- """
- parcour = self.last_inscription.parcour
- query = self.formsemestre.formation.formation.query_competences_parcour(parcour)
- if query is None:
- return []
- return query.all()
-
- def competence_validee(self, competence: ApcCompetence) -> bool:
- """Vrai si la compétence est validée, c'est à dire que tous ses
- niveaux sont validés (ApcValidationRCUE).
- """
- # XXX A REVOIR
- validations = (
- ApcValidationRCUE.query.filter_by(etudid=self.etud.id)
- .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
- .join(ApcNiveau, ApcNiveau.id == UniteEns.niveau_competence_id)
- .join(ApcCompetence, ApcCompetence.id == ApcNiveau.competence_id)
- )
-
- def competence_validable(self, competence: ApcCompetence):
- """Vrai si la compétence est "validable" automatiquement, c'est à dire
- que les conditions de notes sont satisfaites pour l'acquisition de
- son niveau le plus élevé, qu'il ne manque que l'enregistrement de la décision.
-
- En vertu de la règle "La validation des deux UE du niveau d’une compétence
- emporte la validation de l'ensemble des UE du niveau inférieur de cette
- même compétence.",
- il suffit de considérer le dernier niveau dans lequel l'étudiant est inscrit.
- """
- pass
-
- def ues_emportees(self, niveau: ApcNiveau) -> list[tuple[FormSemestre, UniteEns]]:
- """La liste des UE à valider si on valide ce niveau.
- Ne liste que les UE qui ne sont pas déjà acquises.
-
- Selon la règle donnée par l'arrêté BUT:
- * La validation des deux UE du niveau d’une compétence emporte la validation de
- l'ensemble des UE du niveau inférieur de cette même compétence.
- """
- pass
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Jury BUT: logique de gestion
+
+Utilisation:
+ 1) chargement page jury, pour un étudiant et un formsemestre BUT quelconque
+ - DecisionsProposeesAnnee(formsemestre)
+ cherche l'autre formsemestre de la même année scolaire (peut ne pas exister)
+ cherche les RCUEs de l'année (BUT1, 2, 3)
+ pour un redoublant, le RCUE peut considérer un formsemestre d'une année antérieure.
+
+ on instancie des DecisionsProposees pour les
+ différents éléments (UEs, RCUEs, Année, Diplôme)
+ Cela donne
+ - les codes possibles (dans .codes)
+ - le code actuel si une décision existe déjà (dans code_valide)
+ - pour les UEs, le rcue s'il y en a un)
+
+ 2) Validation pour l'utilisateur (form)) => enregistrement code
+ - on vérifie que le code soumis est bien dans les codes possibles
+ - on enregistre la décision (dans ScolarFormSemestreValidation pour les UE,
+ ApcValidationRCUE pour les RCUE, et ApcValidationAnnee pour les années)
+ - Si RCUE validé, on déclenche d'éventuelles validations:
+ ("La validation des deux UE du niveau d’une compétence emporte la validation
+ de l’ensemble des UE du niveau inférieur de cette même compétence.")
+
+Les jurys de semestre BUT impairs entrainent systématiquement la génération d'une
+autorisation d'inscription dans le semestre pair suivant: `ScolarAutorisationInscription`.
+Les jurys de semestres pairs non (S2, S4, S6): il y a une décision sur l'année (ETP)
+ - autorisation en S_2n+1 (S3 ou S5) si: ADM, ADJ, PASD, PAS1CN
+ - autorisation en S2n-1 (S1, S3 ou S5) si: RED
+ - rien si pour les autres codes d'année.
+
+Le formulaire permet de choisir des codes d'UE, RCUE et Année (ETP).
+Mais normalement, les codes d'UE sont à choisir: les RCUE et l'année s'en déduisent.
+Si l'utilisateur coche "décision manuelle", il peut alors choisir les codes RCUE et années.
+
+La soumission du formulaire:
+ - etud, formation
+ - UEs: [(formsemestre, ue, code), ...]
+ - RCUE: [(formsemestre, ue, code), ...] le formsemestre est celui d'indice pair du niveau
+ (S2, S4 ou S6), il sera regoupé avec celui impair de la même année ou de la suivante.
+ - Année: [(formsemestre, code)]
+
+DecisionsProposeesAnnee:
+ si 1/2 des rcue et aucun < 8 + pour S5 condition sur les UE de BUT1 et BUT2
+ => charger les DecisionsProposeesRCUE
+
+DecisionsProposeesRCUE: les RCUEs pour cette année
+ validable, compensable, ajourné. Utilise classe RegroupementCoherentUE
+
+DecisionsProposeesUE: décisions de jury sur une UE du BUT
+ initialisation sans compensation (ue isolée), mais
+ DecisionsProposeesRCUE appelera .set_compensable()
+ si on a la possibilité de la compenser dans le RCUE.
+"""
+import html
+from operator import attrgetter
+import re
+from typing import Union
+
+import numpy as np
+from flask import g, url_for
+
+from app import db
+from app import log
+from app.comp.res_but import ResultatsSemestreBUT
+from app.comp import res_sem
+
+from app.models.but_refcomp import (
+ ApcAnneeParcours,
+ ApcCompetence,
+ ApcNiveau,
+ ApcParcours,
+ ApcParcoursNiveauCompetence,
+)
+from app.models import Scolog, ScolarAutorisationInscription
+from app.models.but_validations import (
+ ApcValidationAnnee,
+ ApcValidationRCUE,
+ RegroupementCoherentUE,
+)
+from app.models.etudiants import Identite
+from app.models.formations import Formation
+from app.models.formsemestre import FormSemestre, FormSemestreInscription
+from app.models.ues import UniteEns
+from app.models.validations import ScolarFormSemestreValidation
+from app.scodoc import sco_cache
+from app.scodoc import sco_codes_parcours as sco_codes
+from app.scodoc.sco_codes_parcours import RED, UE_STANDARD
+from app.scodoc import sco_utils as scu
+from app.scodoc.sco_exceptions import ScoException, ScoValueError
+
+
+class NoRCUEError(ScoValueError):
+ """Erreur en cas de RCUE manquant"""
+
+ def __init__(self, deca: "DecisionsProposeesAnnee", ue: UniteEns):
+ if all(u.niveau_competence for u in deca.ues_pair):
+ warning_pair = ""
+ else:
+ warning_pair = """
certaines UE du semestre pair ne sont pas associées à un niveau de compétence
"""
+ if all(u.niveau_competence for u in deca.ues_impair):
+ warning_impair = ""
+ else:
+ warning_impair = """
certaines UE du semestre impair ne sont pas associées à un niveau de compétence
"""
+ msg = (
+ f"""
Pas de RCUE pour l'UE {ue.acronyme}
+ {warning_impair}
+ {warning_pair}
+
UE {ue.acronyme}: niveau {html.escape(str(ue.niveau_competence))}
+
UEs impaires: {html.escape(', '.join(str(u.niveau_competence or "pas de niveau")
+ for u in deca.ues_impair))}
+
+ """
+ + deca.infos()
+ )
+ super().__init__(msg)
+
+
+class DecisionsProposees:
+ """Une décision de jury proposé, constituée d'une liste de codes et d'une explication.
+ Super-classe, spécialisée pour les UE, les RCUE, les années et le diplôme.
+
+ validation : None ou une instance de d'une classe avec un champ code
+ ApcValidationRCUE, ApcValidationAnnee ou ScolarFormSemestreValidation
+ """
+
+ # Codes toujours proposés sauf si include_communs est faux:
+ codes_communs = [
+ sco_codes.RAT,
+ sco_codes.DEF,
+ sco_codes.ABAN,
+ sco_codes.DEM,
+ sco_codes.UEBSL,
+ ]
+
+ def __init__(
+ self,
+ etud: Identite = None,
+ code: Union[str, list[str]] = None,
+ explanation="",
+ code_valide=None,
+ include_communs=True,
+ ):
+ self.etud = etud
+ self.codes = []
+ "Les codes attribuables par ce jury"
+ if include_communs:
+ self.codes = self.codes_communs.copy()
+ if isinstance(code, list):
+ self.codes = code + self.codes
+ elif code is not None:
+ self.codes = [code] + self.codes
+ self.validation = None
+ "Validation enregistrée"
+ self.code_valide: str = code_valide
+ "Code décision actuel enregistré"
+ # S'assure que le code enregistré est toujours présent dans le menu
+ if self.code_valide and self.code_valide not in self.codes:
+ self.codes.append(self.code_valide)
+ self.explanation: str = explanation
+ "Explication à afficher à côté de la décision"
+ self.recorded = False
+ "true si la décision vient d'être enregistrée"
+
+ def __repr__(self) -> str:
+ return f"""<{self.__class__.__name__} valid={self.code_valide
+ } codes={self.codes} explanation={self.explanation}"""
+
+
+class DecisionsProposeesAnnee(DecisionsProposees):
+ """Décisions de jury sur une année (ETP) du BUT
+
+ Le texte:
+ La poursuite d'études dans un semestre pair d’une même année est de droit
+ pour tout étudiant. La poursuite d’études dans un semestre impair est
+ possible si et seulement si l’étudiant a obtenu :
+ - la moyenne à plus de la moitié des regroupements cohérents d’UE;
+ - et une moyenne égale ou supérieure à 8 sur 20 à chaque RCUE.
+ La poursuite d'études dans le semestre 5 nécessite de plus la validation
+ de toutes les UE des semestres 1 et 2 dans les conditions de validation
+ des points 4.3 (moy_ue >= 10) et 4.4 (compensation rcue), ou par décision
+ de jury.
+ """
+
+ # Codes toujours proposés sauf si include_communs est faux:
+ codes_communs = [
+ sco_codes.RAT,
+ sco_codes.ABAN,
+ sco_codes.ABL,
+ sco_codes.ATJ,
+ sco_codes.DEF,
+ sco_codes.DEM,
+ sco_codes.EXCLU,
+ ]
+
+ def __init__(
+ self,
+ etud: Identite,
+ formsemestre: FormSemestre,
+ ):
+ super().__init__(etud=etud)
+ self.formsemestre_id = formsemestre.id
+ formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre)
+ assert (
+ (formsemestre_pair is None)
+ or (formsemestre_impair is None)
+ or (
+ ((formsemestre_pair.semestre_id - formsemestre_impair.semestre_id) == 1)
+ and (
+ formsemestre_pair.formation.referentiel_competence_id
+ == formsemestre_impair.formation.referentiel_competence_id
+ )
+ )
+ )
+
+ self.formsemestre_impair = formsemestre_impair
+ "le 1er semestre de l'année scolaire considérée (S1, S3, S5)"
+ self.formsemestre_pair = formsemestre_pair
+ "le second formsemestre de la même année scolaire (S2, S4, S6)"
+ formsemestre_last = formsemestre_pair or formsemestre_impair
+ "le formsemestre le plus avancé dans cette année"
+
+ self.annee_but = (formsemestre_last.semestre_id + 1) // 2
+ "le rang de l'année dans le BUT: 1, 2, 3"
+ assert self.annee_but in (1, 2, 3)
+ self.rcues_annee = []
+ "RCUEs de l'année"
+ self.inscription_etat = etud.inscription_etat(formsemestre_last.id)
+
+ if self.formsemestre_impair is not None:
+ self.validation = ApcValidationAnnee.query.filter_by(
+ etudid=self.etud.id,
+ formsemestre_id=formsemestre_impair.id,
+ ordre=self.annee_but,
+ ).first()
+ else:
+ self.validation = None
+ if self.validation is not None:
+ self.code_valide = self.validation.code
+ self.parcour = None
+ "Le parcours considéré (celui du semestre pair, ou à défaut impair)"
+ if self.formsemestre_pair is not None:
+ self.res_pair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
+ self.formsemestre_pair
+ )
+ else:
+ self.res_pair = None
+ if self.formsemestre_impair is not None:
+ self.res_impair: ResultatsSemestreBUT = res_sem.load_formsemestre_results(
+ self.formsemestre_impair
+ )
+ else:
+ self.res_impair = None
+
+ self.ues_impair, self.ues_pair = self.compute_ues_annee() # pylint: disable=all
+ self.decisions_ues = {
+ ue.id: DecisionsProposeesUE(
+ etud, formsemestre_impair, ue, self.inscription_etat
+ )
+ for ue in self.ues_impair
+ }
+ "{ue_id : DecisionsProposeesUE} pour toutes les UE de l'année"
+ self.decisions_ues.update(
+ {
+ ue.id: DecisionsProposeesUE(
+ etud, formsemestre_pair, ue, self.inscription_etat
+ )
+ for ue in self.ues_pair
+ }
+ )
+ self.rcues_annee = self.compute_rcues_annee()
+
+ formation = (
+ self.formsemestre_impair.formation
+ if self.formsemestre_impair
+ else self.formsemestre_pair.formation
+ )
+ self.niveaux_competences = ApcNiveau.niveaux_annee_de_parcours(
+ self.parcour, self.annee_but, formation.referentiel_competence
+ ).all() # non triés
+ "liste des niveaux de compétences associés à cette année"
+ self.decisions_rcue_by_niveau = self.compute_decisions_niveaux()
+ "les décisions rcue associées aux niveau_id"
+ self.dec_rcue_by_ue = self._dec_rcue_by_ue()
+ "{ ue_id : DecisionsProposeesRCUE } pour toutes les UE associées à un niveau"
+ self.nb_competences = len(self.niveaux_competences)
+ "le nombre de niveaux de compétences à valider cette année"
+ rcues_avec_niveau = [d.rcue for d in self.decisions_rcue_by_niveau.values()]
+ self.nb_validables = len(
+ [rcue for rcue in rcues_avec_niveau if rcue.est_validable()]
+ )
+ "le nombre de comp. validables (éventuellement par compensation)"
+ self.nb_rcue_valides = len(
+ [rcue for rcue in rcues_avec_niveau if rcue.code_valide()]
+ )
+ "le nombre de niveaux validés (déc. jury prise)"
+ self.nb_rcues_under_8 = len(
+ [rcue for rcue in rcues_avec_niveau if not rcue.est_suffisant()]
+ )
+ "le nb de comp. sous la barre de 8/20"
+ # année ADM si toutes RCUE validées (sinon PASD) et non DEM ou DEF
+ self.admis = (self.nb_validables == self.nb_competences) and (
+ self.inscription_etat == scu.INSCRIT
+ )
+ "vrai si l'année est réussie, tous niveaux validables ou validés par le jury"
+ self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2)
+ # Peut passer si plus de la moitié validables et tous > 8
+ self.passage_de_droit = self.valide_moitie_rcue and (self.nb_rcues_under_8 == 0)
+ # XXX TODO ajouter condition pour passage en S5
+
+ # Enfin calcule les codes des UE:
+ for dec_ue in self.decisions_ues.values():
+ dec_ue.compute_codes()
+
+ # Reste à attribuer ADM, ADJ, PASD, PAS1NCI, RED, NAR
+ expl_rcues = (
+ f"{self.nb_validables} niveau validable(s) sur {self.nb_competences}"
+ )
+ if self.admis:
+ self.codes = [sco_codes.ADM] + self.codes
+ self.explanation = expl_rcues
+ elif self.inscription_etat != scu.INSCRIT:
+ self.codes = [
+ sco_codes.DEM
+ if self.inscription_etat == scu.DEMISSION
+ else sco_codes.DEF,
+ # propose aussi d'autres codes, au cas où...
+ sco_codes.DEM
+ if self.inscription_etat != scu.DEMISSION
+ else sco_codes.DEF,
+ sco_codes.ABAN,
+ sco_codes.ABL,
+ sco_codes.EXCLU,
+ ]
+ elif self.passage_de_droit:
+ self.codes = [sco_codes.PASD, sco_codes.ADJ] + self.codes
+ self.explanation = expl_rcues
+ elif self.valide_moitie_rcue: # mais au moins 1 rcue insuffisante
+ self.codes = [
+ sco_codes.RED,
+ sco_codes.NAR,
+ sco_codes.PAS1NCI,
+ sco_codes.ADJ,
+ ] + self.codes
+ self.explanation = expl_rcues + f" et {self.nb_rcues_under_8} < 8"
+ else:
+ self.codes = [
+ sco_codes.RED,
+ sco_codes.NAR,
+ sco_codes.PAS1NCI,
+ sco_codes.ADJ,
+ sco_codes.PASD, # voir #488 (discutable, conventions locales)
+ ] + self.codes
+ self.explanation = (
+ expl_rcues
+ + f""" et {self.nb_rcues_under_8}
+ niveau{'x' if self.nb_rcues_under_8 > 1 else ''} < 8"""
+ )
+ # Si l'un des semestres est extérieur, propose ADM
+ if (
+ self.formsemestre_impair.modalite == "EXT"
+ or self.formsemestre_pair.modalite == "EXT"
+ ):
+ self.codes.insert(0, sco_codes.ADM)
+
+ #
+
+ def infos(self) -> str:
+ "informations, for debugging purpose"
+ return f"""DecisionsProposeesAnnee
+
+ """
+
+ def annee_scolaire(self) -> int:
+ "L'année de début de l'année scolaire"
+ formsemestre = self.formsemestre_impair or self.formsemestre_pair
+ return formsemestre.annee_scolaire()
+
+ def annee_scolaire_str(self) -> str:
+ "L'année scolaire, eg '2021 - 2022'"
+ formsemestre = self.formsemestre_impair or self.formsemestre_pair
+ return formsemestre.annee_scolaire_str().replace(" ", "")
+
+ def comp_formsemestres(
+ self, formsemestre: FormSemestre
+ ) -> tuple[FormSemestre, FormSemestre]:
+ """les deux formsemestres de l'année scolaire à laquelle appartient formsemestre."""
+ if not formsemestre.formation.is_apc(): # garde fou
+ return None, None
+ if formsemestre.semestre_id % 2 == 0:
+ other_semestre_id = formsemestre.semestre_id - 1
+ else:
+ other_semestre_id = formsemestre.semestre_id + 1
+ annee_scolaire = formsemestre.annee_scolaire()
+ other_formsemestre = None
+ for inscr in self.etud.formsemestre_inscriptions:
+ if (
+ # Même spécialité BUT (tolère ainsi des variantes de formation)
+ (
+ inscr.formsemestre.formation.referentiel_competence
+ == formsemestre.formation.referentiel_competence
+ )
+ # L'autre semestre
+ and (inscr.formsemestre.semestre_id == other_semestre_id)
+ # de la même année scolaire:
+ and (inscr.formsemestre.annee_scolaire() == annee_scolaire)
+ ):
+ other_formsemestre = inscr.formsemestre
+ if formsemestre.semestre_id % 2 == 0:
+ return other_formsemestre, formsemestre
+ return formsemestre, other_formsemestre
+
+ def compute_ues_annee(self) -> list[list[UniteEns], list[UniteEns]]:
+ """UEs à valider cette année pour cet étudiant, selon son parcours.
+ Ramène [ listes des UE du semestre impair, liste des UE du semestre pair ].
+ """
+ etudid = self.etud.id
+ ues_sems = []
+ for (formsemestre, res) in (
+ (self.formsemestre_impair, self.res_impair),
+ (self.formsemestre_pair, self.res_pair),
+ ):
+ if (formsemestre is None) or (not formsemestre.formation.is_apc()):
+ ues = []
+ else:
+ formation: Formation = formsemestre.formation
+ # Parcour dans lequel l'étudiant est inscrit, et liste des UEs
+ if res.etuds_parcour_id[etudid] is None:
+ # pas de parcour: prend toutes les UEs (non bonus)
+ ues = [ue for ue in res.etud_ues(etudid) if ue.type == UE_STANDARD]
+ ues.sort(key=lambda u: u.numero)
+ else:
+ parcour = ApcParcours.query.get(res.etuds_parcour_id[etudid])
+ if parcour is not None:
+ self.parcour = parcour
+ ues = (
+ formation.query_ues_parcour(parcour)
+ .filter_by(semestre_idx=formsemestre.semestre_id)
+ .order_by(UniteEns.numero)
+ .all()
+ )
+ ues_sems.append(ues)
+ return ues_sems
+
+ def check_ues_ready_jury(self) -> list[str]:
+ """Vérifie que les toutes les UEs (hors bonus) de l'année sont
+ bien associées à des niveaux de compétences.
+ Renvoie liste vide si ok, sinon liste de message explicatifs
+ """
+ messages = []
+ for ue in self.ues_impair + self.ues_pair:
+ if ue.niveau_competence is None:
+ messages.append(
+ f"UE {ue.acronyme} non associée à un niveau de compétence"
+ )
+ if ue.semestre_idx is None:
+ messages.append(
+ f"UE {ue.acronyme} n'a pas d'indice de semestre dans la formation"
+ )
+ return messages
+
+ def compute_rcues_annee(self) -> list[RegroupementCoherentUE]:
+ """Liste des regroupements d'UE à considérer cette année.
+ Pour le moment on ne considère pas de RCUE à cheval sur plusieurs années (redoublants).
+ Si on n'a pas les deux semestres, aucun RCUE.
+ Raises ScoValueError s'il y a des UE sans RCUE.
+ """
+ if self.formsemestre_pair is None or self.formsemestre_impair is None:
+ return []
+ rcues_annee = []
+ ues_impair_sans_rcue = {ue.id for ue in self.ues_impair}
+ for ue_pair in self.ues_pair:
+ rcue = None
+ for ue_impair in self.ues_impair:
+ if ue_pair.niveau_competence_id == ue_impair.niveau_competence_id:
+ rcue = RegroupementCoherentUE(
+ self.etud,
+ self.formsemestre_impair,
+ ue_impair,
+ self.formsemestre_pair,
+ ue_pair,
+ self.inscription_etat,
+ )
+ ues_impair_sans_rcue.discard(ue_impair.id)
+ break
+ if rcue is None:
+ raise NoRCUEError(deca=self, ue=ue_pair)
+ rcues_annee.append(rcue)
+ if len(ues_impair_sans_rcue) > 0:
+ ue = UniteEns.query.get(ues_impair_sans_rcue.pop())
+ raise NoRCUEError(deca=self, ue=ue)
+ return rcues_annee
+
+ def compute_decisions_niveaux(self) -> dict[int, "DecisionsProposeesRCUE"]:
+ """Pour chaque niveau de compétence de cette année, construit
+ le DecisionsProposeesRCUE,
+ ou None s'il n'y en a pas
+ (ne devrait pas arriver car compute_rcues_annee vérifie déjà cela).
+ Return: { niveau_id : DecisionsProposeesRCUE }
+ """
+ # Retrouve le RCUE associé à chaque niveau
+ rc_niveaux = []
+ for niveau in self.niveaux_competences:
+ rcue = None
+ for rc in self.rcues_annee:
+ if rc.ue_1.niveau_competence_id == niveau.id:
+ rcue = rc
+ break
+ if rcue is not None:
+ dec_rcue = DecisionsProposeesRCUE(self, rcue, self.inscription_etat)
+ rc_niveaux.append((dec_rcue, niveau.id))
+ # prévient les UE concernées :-)
+ self.decisions_ues[dec_rcue.rcue.ue_1.id].set_rcue(dec_rcue.rcue)
+ self.decisions_ues[dec_rcue.rcue.ue_2.id].set_rcue(dec_rcue.rcue)
+ # Ordonne par numéro d'UE
+ rc_niveaux.sort(key=lambda x: x[0].rcue.ue_1.numero)
+ decisions_rcue_by_niveau = {x[1]: x[0] for x in rc_niveaux}
+ return decisions_rcue_by_niveau
+
+ def _dec_rcue_by_ue(self) -> dict[int, "DecisionsProposeesRCUE"]:
+ """construit dict { ue_id : DecisionsProposeesRCUE }
+ à partir de self.decisions_rcue_by_niveau"""
+ d = {}
+ for dec_rcue in self.decisions_rcue_by_niveau.values():
+ d[dec_rcue.rcue.ue_1.id] = dec_rcue
+ d[dec_rcue.rcue.ue_2.id] = dec_rcue
+ return d
+
+ def next_annee_semestre_id(self, code: str) -> int:
+ """L'indice du semestre dans lequel l'étudiant est autorisé à
+ poursuivre l'année suivante. None si aucun."""
+ if self.formsemestre_pair is None:
+ return None # seulement sur année
+ if code == RED:
+ return self.formsemestre_pair.semestre_id - 1
+ elif (
+ code in sco_codes.BUT_CODES_PASSAGE
+ and self.formsemestre_pair.semestre_id < sco_codes.ParcoursBUT.NB_SEM
+ ):
+ return self.formsemestre_pair.semestre_id + 1
+ return None
+
+ def record_form(self, form: dict):
+ """Enregistre les codes de jury en base
+ form dict:
+ - 'code_ue_1896' : 'AJ' code pour l'UE id 1896
+ - 'code_rcue_6" : 'ADM' code pour le RCUE du niveau 6
+ - 'code_annee' : 'ADM' code pour l'année
+
+ Si les code_rcue et le code_annee ne sont pas fournis,
+ et qu'il n'y en a pas déjà, enregistre ceux par défaut.
+ """
+ log("jury_but.DecisionsProposeesAnnee.record_form")
+ with sco_cache.DeferredSemCacheManager():
+ for key in form:
+ code = form[key]
+ # Codes d'UE
+ m = re.match(r"^code_ue_(\d+)$", key)
+ if m:
+ ue_id = int(m.group(1))
+ dec_ue = self.decisions_ues.get(ue_id)
+ if not dec_ue:
+ raise ScoValueError(f"UE invalide ue_id={ue_id}")
+ dec_ue.record(code)
+ else:
+ # Codes de RCUE
+ m = re.match(r"^code_rcue_(\d+)$", key)
+ if m:
+ niveau_id = int(m.group(1))
+ dec_rcue = self.decisions_rcue_by_niveau.get(niveau_id)
+ if not dec_rcue:
+ raise ScoValueError(f"RCUE invalide niveau_id={niveau_id}")
+ dec_rcue.record(code)
+ elif key == "code_annee":
+ # Code annuel
+ self.record(code)
+
+ self.record_all()
+ db.session.commit()
+
+ def record(self, code: str, no_overwrite=False):
+ """Enregistre le code de l'année, et au besoin l'autorisation d'inscription.
+ Si no_overwrite, ne fait rien si un code est déjà enregistré.
+ """
+ if code and not code in self.codes:
+ raise ScoValueError(
+ f"code annee {html.escape(code)} invalide pour formsemestre {html.escape(self.formsemestre)}"
+ )
+ if code == self.code_valide or (self.code_valide is not None and no_overwrite):
+ self.recorded = True
+ return # no change
+ if self.validation:
+ db.session.delete(self.validation)
+ db.session.flush()
+ if code is None:
+ self.validation = None
+ else:
+ self.validation = ApcValidationAnnee(
+ etudid=self.etud.id,
+ formsemestre=self.formsemestre_impair,
+ ordre=self.annee_but,
+ annee_scolaire=self.annee_scolaire(),
+ code=code,
+ )
+ Scolog.logdb(
+ method="jury_but",
+ etudid=self.etud.id,
+ msg=f"Validation année BUT{self.annee_but}: {code}",
+ )
+ db.session.add(self.validation)
+ # --- Autorisation d'inscription dans semestre suivant ?
+ if self.formsemestre_pair is not None:
+ if code is None:
+ ScolarAutorisationInscription.delete_autorisation_etud(
+ etudid=self.etud.id,
+ origin_formsemestre_id=self.formsemestre_pair.id,
+ )
+ else:
+ next_semestre_id = self.next_annee_semestre_id(code)
+ if next_semestre_id is not None:
+ ScolarAutorisationInscription.autorise_etud(
+ self.etud.id,
+ self.formsemestre_pair.formation.formation_code,
+ self.formsemestre_pair.id,
+ next_semestre_id,
+ )
+
+ self.recorded = True
+ self.invalidate_formsemestre_cache()
+
+ def invalidate_formsemestre_cache(self):
+ "invalide le résultats des deux formsemestres"
+ if self.formsemestre_impair is not None:
+ sco_cache.invalidate_formsemestre(
+ formsemestre_id=self.formsemestre_impair.id
+ )
+ if self.formsemestre_pair is not None:
+ sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre_pair.id)
+
+ def record_all(self):
+ """Enregistre les codes qui n'ont pas été spécifiés par le formulaire, et sont donc en mode "automatique" """
+ decisions = (
+ list(self.decisions_ues.values())
+ + list(self.decisions_rcue_by_niveau.values())
+ + [self]
+ )
+ for dec in decisions:
+ if not dec.recorded:
+ # rappel: le code par défaut est en tête
+ code = dec.codes[0] if dec.codes else None
+ # s'il n'y a pas de code, efface
+ dec.record(code, no_overwrite=True)
+
+ def erase(self):
+ """Efface les décisions de jury de cet étudiant
+ pour cette année: décisions d'UE, de RCUE, d'année,
+ et autorisations d'inscription émises.
+ """
+ for dec_ue in self.decisions_ues.values():
+ dec_ue.erase()
+ for dec_rcue in self.decisions_rcue_by_niveau.values():
+ dec_rcue.erase()
+ if self.formsemestre_impair:
+ ScolarAutorisationInscription.delete_autorisation_etud(
+ self.etud.id, self.formsemestre_impair.id
+ )
+ if self.formsemestre_pair:
+ ScolarAutorisationInscription.delete_autorisation_etud(
+ self.etud.id, self.formsemestre_pair.id
+ )
+ validations = ApcValidationAnnee.query.filter_by(
+ etudid=self.etud.id,
+ formsemestre_id=self.formsemestre_impair.id,
+ ordre=self.annee_but,
+ )
+ for validation in validations:
+ db.session.delete(validation)
+ db.session.flush()
+ self.invalidate_formsemestre_cache()
+
+ def get_autorisations_passage(self) -> list[int]:
+ """Les liste des indices de semestres auxquels on est autorisé à
+ s'inscrire depuis cette année"""
+ formsemestre = self.formsemestre_pair or self.formsemestre_impair
+ if not formsemestre:
+ return []
+ return [
+ a.semestre_id
+ for a in ScolarAutorisationInscription.query.filter_by(
+ etudid=self.etud.id,
+ origin_formsemestre_id=formsemestre.id,
+ )
+ ]
+
+ def descr_niveaux_validation(self, line_sep: str = "\n") -> str:
+ """Description textuelle des niveaux validés (enregistrés)
+ pour PV jurys
+ """
+ validations = [
+ dec_rcue.descr_validation()
+ for dec_rcue in self.decisions_rcue_by_niveau.values()
+ ]
+ return line_sep.join(v for v in validations if v)
+
+ def descr_ues_validation(self, line_sep: str = "\n") -> str:
+ """Description textuelle des UE validées (enregistrés)
+ pour PV jurys
+ """
+ validations = []
+ for res in (self.res_impair, self.res_pair):
+ if res:
+ dec_ues = [
+ self.decisions_ues[ue.id]
+ for ue in res.ues
+ if ue.type == UE_STANDARD and ue.id in self.decisions_ues
+ ]
+ valids = [dec_ue.descr_validation() for dec_ue in dec_ues]
+ validations.append(", ".join(v for v in valids if v))
+ return line_sep.join(validations)
+
+
+class DecisionsProposeesRCUE(DecisionsProposees):
+ """Liste des codes de décisions que l'on peut proposer pour
+ le RCUE de cet étudiant dans cette année.
+
+ ADM, CMP, ADJ, AJ, RAT, DEF, ABAN
+ """
+
+ codes_communs = [
+ sco_codes.ADJ,
+ sco_codes.ATJ,
+ sco_codes.RAT,
+ sco_codes.DEF,
+ sco_codes.ABAN,
+ ]
+
+ def __init__(
+ self,
+ dec_prop_annee: DecisionsProposeesAnnee,
+ rcue: RegroupementCoherentUE,
+ inscription_etat: str = scu.INSCRIT,
+ ):
+ super().__init__(etud=dec_prop_annee.etud)
+ self.rcue = rcue
+ if rcue is None: # RCUE non dispo, eg un seul semestre
+ self.codes = []
+ return
+ self.inscription_etat = inscription_etat
+ "inscription: I, DEM, DEF"
+ self.parcour = dec_prop_annee.parcour
+ if inscription_etat != scu.INSCRIT:
+ self.validation = None # cache toute validation
+ self.explanation = "non incrit (dem. ou déf.)"
+ self.codes = [
+ sco_codes.DEM if inscription_etat == scu.DEMISSION else sco_codes.DEF
+ ]
+ return
+ self.validation = rcue.query_validations().first()
+ if self.validation is not None:
+ self.code_valide = self.validation.code
+ if rcue.est_compensable():
+ self.codes.insert(0, sco_codes.CMP)
+ # les interprétations varient, on autorise aussi ADM:
+ self.codes.insert(1, sco_codes.ADM)
+ elif rcue.est_validable():
+ self.codes.insert(0, sco_codes.ADM)
+ else:
+ self.codes.insert(0, sco_codes.AJ)
+ # Si au moins l'un des semestres est extérieur, propose ADM au cas où
+ if (
+ dec_prop_annee.formsemestre_impair.modalite == "EXT"
+ or dec_prop_annee.formsemestre_pair.modalite == "EXT"
+ ):
+ self.codes.insert(0, sco_codes.ADM)
+
+ def record(self, code: str, no_overwrite=False):
+ """Enregistre le code"""
+ if code and not code in self.codes:
+ raise ScoValueError(
+ f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
+ )
+ if code == self.code_valide or (self.code_valide is not None and no_overwrite):
+ self.recorded = True
+ return # no change
+ parcours_id = self.parcour.id if self.parcour is not None else None
+ if self.validation:
+ db.session.delete(self.validation)
+ db.session.flush()
+ if code is None:
+ self.validation = None
+ else:
+ self.validation = ApcValidationRCUE(
+ etudid=self.etud.id,
+ formsemestre_id=self.rcue.formsemestre_2.id,
+ ue1_id=self.rcue.ue_1.id,
+ ue2_id=self.rcue.ue_2.id,
+ parcours_id=parcours_id,
+ code=code,
+ )
+ Scolog.logdb(
+ method="jury_but",
+ etudid=self.etud.id,
+ msg=f"Validation RCUE {repr(self.rcue)}",
+ )
+ db.session.add(self.validation)
+ if self.rcue.formsemestre_1 is not None:
+ sco_cache.invalidate_formsemestre(
+ formsemestre_id=self.rcue.formsemestre_1.id
+ )
+ if self.rcue.formsemestre_2 is not None:
+ sco_cache.invalidate_formsemestre(
+ formsemestre_id=self.rcue.formsemestre_2.id
+ )
+ self.recorded = True
+
+ def erase(self):
+ """Efface la décision de jury de cet étudiant pour cet RCUE"""
+ # par prudence, on requete toutes les validations, en cas de doublons
+ validations = self.rcue.query_validations()
+ for validation in validations:
+ db.session.delete(validation)
+ db.session.flush()
+
+ def descr_validation(self) -> str:
+ """Description validation niveau enregistrée, pour PV jury.
+ Si le niveau est validé, done son acronyme, sinon chaine vide.
+ """
+ if self.code_valide in sco_codes.CODES_RCUE_VALIDES:
+ if (
+ self.rcue and self.rcue.ue_1 and self.rcue.ue_1.niveau_competence
+ ): # prudence !
+ niveau_titre = self.rcue.ue_1.niveau_competence.competence.titre or ""
+ ordre = self.rcue.ue_1.niveau_competence.ordre
+ else:
+ return "?" # oups ?
+ return f"{niveau_titre} niv. {ordre}"
+ return ""
+
+
+class DecisionsProposeesUE(DecisionsProposees):
+ """Décisions de jury sur une UE du BUT
+
+ Liste des codes de décisions que l'on peut proposer pour
+ cette UE d'un étudiant dans un semestre.
+
+ Si DEF ou DEM ou ABAN ou ABL sur année BUT: seulement DEF, DEM, ABAN, ABL
+
+ si moy_ue > 10, ADM
+ sinon si compensation dans RCUE: CMP
+ sinon: ADJ, AJ
+
+ et proposer toujours: RAT, DEF, ABAN, DEM, UEBSL (codes_communs)
+ """
+
+ # Codes toujours proposés sauf si include_communs est faux:
+ codes_communs = [
+ sco_codes.RAT,
+ sco_codes.DEF,
+ sco_codes.ABAN,
+ sco_codes.ATJ,
+ sco_codes.DEM,
+ sco_codes.UEBSL,
+ ]
+
+ def __init__(
+ self,
+ etud: Identite,
+ formsemestre: FormSemestre,
+ ue: UniteEns,
+ inscription_etat: str = scu.INSCRIT,
+ ):
+ # Une UE peut être validée plusieurs fois en cas de redoublement (qu'elle soit capitalisée ou non)
+ # mais ici on a restreint au formsemestre donc une seule (prend la première)
+ self.validation = ScolarFormSemestreValidation.query.filter_by(
+ etudid=etud.id, formsemestre_id=formsemestre.id, ue_id=ue.id
+ ).first()
+ super().__init__(
+ etud=etud,
+ code_valide=self.validation.code if self.validation is not None else None,
+ )
+ # log(f"built {self}")
+ self.formsemestre = formsemestre
+ self.ue: UniteEns = ue
+ self.rcue: RegroupementCoherentUE = None
+ "Le rcue auquel est rattaché cette UE, ou None"
+ self.inscription_etat = inscription_etat
+ "inscription: I, DEM, DEF"
+ if ue.type == sco_codes.UE_SPORT:
+ self.explanation = "UE bonus, pas de décision de jury"
+ self.codes = [] # aucun code proposé
+ return
+ if inscription_etat != scu.INSCRIT:
+ self.validation = None # cache toute validation
+ self.explanation = "non incrit (dem. ou déf.)"
+ self.codes = [
+ sco_codes.DEM if inscription_etat == scu.DEMISSION else sco_codes.DEF
+ ]
+ self.moy_ue = np.NaN
+ return
+
+ # Moyenne de l'UE ?
+ res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre)
+ if not ue.id in res.etud_moy_ue:
+ self.explanation = "UE sans résultat"
+ return
+ if not etud.id in res.etud_moy_ue[ue.id]:
+ self.explanation = "Étudiant sans résultat dans cette UE"
+ return
+ self.moy_ue = res.etud_moy_ue[ue.id][etud.id]
+
+ def set_rcue(self, rcue: RegroupementCoherentUE):
+ """Rattache cette UE à un RCUE. Cela peut modifier les codes
+ proposés (si compensation)"""
+ self.rcue = rcue
+
+ def compute_codes(self):
+ """Calcul des .codes attribuables et de l'explanation associée"""
+ if self.inscription_etat != scu.INSCRIT:
+ return
+ if (
+ self.moy_ue > (sco_codes.ParcoursBUT.BARRE_MOY - sco_codes.NOTES_TOLERANCE)
+ ) or self.formsemestre.modalite == "EXT":
+ self.codes.insert(0, sco_codes.ADM)
+ self.explanation = (f"Moyenne >= {sco_codes.ParcoursBUT.BARRE_MOY}/20",)
+ elif self.rcue and self.rcue.est_compensable():
+ self.codes.insert(0, sco_codes.CMP)
+ self.explanation = "compensable dans le RCUE"
+ else:
+ # Échec à valider cette UE
+ self.codes = [sco_codes.AJ, sco_codes.ADJ] + self.codes
+ self.explanation = "notes insuffisantes"
+
+ def record(self, code: str, no_overwrite=False):
+ """Enregistre le code jury pour cette UE.
+ Si no_overwrite, n'enregistre pas s'il y a déjà un code.
+ """
+ if code and not code in self.codes:
+ raise ScoValueError(
+ f"code UE invalide pour ue_id={self.ue.id}: {html.escape(code)}"
+ )
+ if code == self.code_valide or (self.code_valide is not None and no_overwrite):
+ self.recorded = True
+ return # no change
+ self.erase()
+ if code is None:
+ self.validation = None
+ else:
+ self.validation = ScolarFormSemestreValidation(
+ etudid=self.etud.id,
+ formsemestre_id=self.formsemestre.id,
+ ue_id=self.ue.id,
+ code=code,
+ moy_ue=self.moy_ue,
+ )
+ Scolog.logdb(
+ method="jury_but",
+ etudid=self.etud.id,
+ msg=f"Validation UE {self.ue.id}",
+ )
+ log(f"DecisionsProposeesUE: recording {self.validation}")
+ db.session.add(self.validation)
+
+ sco_cache.invalidate_formsemestre(formsemestre_id=self.formsemestre.id)
+ self.recorded = True
+
+ def erase(self):
+ """Efface la décision de jury de cet étudiant pour cette UE"""
+ # par prudence, on requete toutes les validations, en cas de doublons
+ validations = ScolarFormSemestreValidation.query.filter_by(
+ etudid=self.etud.id, formsemestre_id=self.formsemestre.id, ue_id=self.ue.id
+ )
+ for validation in validations:
+ log(f"DecisionsProposeesUE: deleting {validation}")
+ db.session.delete(validation)
+ db.session.flush()
+
+ def descr_validation(self) -> str:
+ """Description validation niveau enregistrée, pour PV jury.
+ Si l'UE est validée, donne son acronyme, sinon chaine vide.
+ """
+ if self.code_valide in sco_codes.CODES_UE_VALIDES:
+ return f"{self.ue.acronyme}"
+ return ""
+
+
+class BUTCursusEtud: # WIP TODO
+ """Validation du cursus d'un étudiant"""
+
+ def __init__(self, formsemestre: FormSemestre, etud: Identite):
+ if formsemestre.formation.referentiel_competence is None:
+ raise ScoException("BUTCursusEtud: pas de référentiel de compétences")
+ assert len(etud.formsemestre_inscriptions) > 0
+ self.formsemestre = formsemestre
+ self.etud = etud
+ #
+ # La dernière inscription en date va donner le parcours (donc les compétences à valider)
+ self.last_inscription = sorted(
+ etud.formsemestre_inscriptions, key=attrgetter("formsemestre.date_debut")
+ )[-1]
+
+ def est_diplomable(self) -> bool:
+ """Vrai si toutes les compétences sont validables"""
+ return all(
+ self.competence_validable(competence)
+ for competence in self.competences_du_parcours()
+ )
+
+ def est_diplome(self) -> bool:
+ """Vrai si BUT déjà validé"""
+ # vrai si la troisième année est validée
+ # On cherche les validations de 3ieme annee (ordre=3) avec le même référentiel
+ # de formation que nous.
+ return (
+ ApcValidationAnnee.query.filter_by(etudid=self.etud.id, ordre=3)
+ .join(FormSemestre, FormSemestre.id == ApcValidationAnnee.formsemestre_id)
+ .join(Formation, FormSemestre.formation_id == Formation.id)
+ .filter(
+ Formation.referentiel_competence_id
+ == self.formsemestre.formation.referentiel_competence_id
+ )
+ .count()
+ > 0
+ )
+
+ def competences_du_parcours(self) -> list[ApcCompetence]:
+ """Construit liste des compétences du parcours, qui doivent être
+ validées pour obtenir le diplôme.
+ Le parcours est celui de la dernière inscription.
+ """
+ parcour = self.last_inscription.parcour
+ query = self.formsemestre.formation.formation.query_competences_parcour(parcour)
+ if query is None:
+ return []
+ return query.all()
+
+ def competence_validee(self, competence: ApcCompetence) -> bool:
+ """Vrai si la compétence est validée, c'est à dire que tous ses
+ niveaux sont validés (ApcValidationRCUE).
+ """
+ # XXX A REVOIR
+ validations = (
+ ApcValidationRCUE.query.filter_by(etudid=self.etud.id)
+ .join(UniteEns, UniteEns.id == ApcValidationRCUE.ue1_id)
+ .join(ApcNiveau, ApcNiveau.id == UniteEns.niveau_competence_id)
+ .join(ApcCompetence, ApcCompetence.id == ApcNiveau.competence_id)
+ )
+
+ def competence_validable(self, competence: ApcCompetence):
+ """Vrai si la compétence est "validable" automatiquement, c'est à dire
+ que les conditions de notes sont satisfaites pour l'acquisition de
+ son niveau le plus élevé, qu'il ne manque que l'enregistrement de la décision.
+
+ En vertu de la règle "La validation des deux UE du niveau d’une compétence
+ emporte la validation de l'ensemble des UE du niveau inférieur de cette
+ même compétence.",
+ il suffit de considérer le dernier niveau dans lequel l'étudiant est inscrit.
+ """
+ pass
+
+ def ues_emportees(self, niveau: ApcNiveau) -> list[tuple[FormSemestre, UniteEns]]:
+ """La liste des UE à valider si on valide ce niveau.
+ Ne liste que les UE qui ne sont pas déjà acquises.
+
+ Selon la règle donnée par l'arrêté BUT:
+ * La validation des deux UE du niveau d’une compétence emporte la validation de
+ l'ensemble des UE du niveau inférieur de cette même compétence.
+ """
+ pass
diff --git a/app/scodoc/sco_formsemestre_exterieurs.py b/app/scodoc/sco_formsemestre_exterieurs.py
index ba0595ca..baec914d 100644
--- a/app/scodoc/sco_formsemestre_exterieurs.py
+++ b/app/scodoc/sco_formsemestre_exterieurs.py
@@ -1,527 +1,529 @@
-# -*- 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
-#
-##############################################################################
-
-"""Saisie et gestion des semestres extérieurs à ScoDoc dans un parcours.
-
-On va créer/gérer des semestres de la même formation que le semestre ScoDoc
-où est inscrit l'étudiant, leur attribuer la modalité 'EXT'.
-Ces semestres n'auront qu'un seul inscrit !
-"""
-import time
-
-import flask
-from flask import url_for, g, request
-from flask_login import current_user
-
-from app.comp import res_sem
-from app.comp.res_compat import NotesTableCompat
-from app.models import (
- FormSemestre,
- FormSemestreUECoef,
- Identite,
- ScolarFormSemestreValidation,
- UniteEns,
-)
-import app.scodoc.sco_utils as scu
-from app import log
-from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
-from app.scodoc import html_sco_header
-from app.scodoc import sco_formations
-from app.scodoc import sco_formsemestre
-from app.scodoc import sco_formsemestre_inscriptions
-from app.scodoc import sco_formsemestre_validation
-from app.scodoc import sco_etud
-from app.scodoc.sco_codes_parcours import UE_SPORT
-
-
-def formsemestre_ext_create(etudid, sem_params):
- """Crée un formsemestre exterieur et y inscrit l'étudiant.
- sem_params: dict nécessaire à la création du formsemestre
- """
- # Check args
- _formation = sco_formations.formation_list(
- args={"formation_id": sem_params["formation_id"]}
- )[0]
- if etudid:
- _etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
-
- # Create formsemestre
- sem_params["modalite"] = "EXT"
- sem_params["etapes"] = None
- sem_params["responsables"] = [current_user.id]
- formsemestre_id = sco_formsemestre.do_formsemestre_create(sem_params, silent=True)
- # nota: le semestre est créé vide: pas de modules
-
- # Inscription au semestre
- sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules(
- formsemestre_id,
- etudid,
- method="formsemestre_ext_create",
- )
- return formsemestre_id
-
-
-def formsemestre_ext_create_form(etudid, formsemestre_id):
- """Formulaire création/inscription à un semestre extérieur"""
- etud = Identite.query.get_or_404(etudid)
- H = [
- html_sco_header.sco_header(),
- f"""
Enregistrement d'une inscription antérieure dans un autre
- établissement
-
- Cette opération crée un semestre extérieur ("ancien") de la même
- formation que le semestre courant, et y inscrit juste cet étudiant.
- La décision de jury peut ensuite y être saisie.
-
-
- Notez que si un semestre extérieur similaire a déjà été créé pour un autre
- étudiant, il est préférable d'utiliser la fonction
- "
- inscrire à un autre semestre"
-
- """,
- ]
- F = html_sco_header.sco_footer()
- orig_sem = sco_formsemestre.get_formsemestre(formsemestre_id)
- # Ne propose que des semestres de semestre_id strictement inférieur
- # au semestre courant
- # et seulement si pas inscrit au même semestre_id d'un semestre ordinaire ScoDoc.
- # 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"}
- )
- semlist = [sco_formsemestre.get_formsemestre(i["formsemestre_id"]) for i in insem]
- existing_semestre_ids = {s["semestre_id"] for s in semlist}
- min_semestre_id = 1
- max_semestre_id = orig_sem["semestre_id"]
- semestre_ids = set(range(min_semestre_id, max_semestre_id)) - existing_semestre_ids
- H.append(
- f"""
L'étudiant est déjà inscrit dans des semestres ScoDoc de rangs:
- { sorted(list(existing_semestre_ids)) }
-
- """
- )
- if not semestre_ids:
- H.append(
- f"""
pas de semestres extérieurs possibles
- (indices entre {min_semestre_id} et {max_semestre_id}, semestre courant.)
-
"""
- )
- return "\n".join(H) + F
- # Formulaire
- semestre_ids_list = sorted(semestre_ids)
- semestre_ids_labels = [f"S{x}" for x in semestre_ids_list]
- descr = [
- ("formsemestre_id", {"input_type": "hidden"}),
- ("etudid", {"input_type": "hidden"}),
- (
- "semestre_id",
- {
- "input_type": "menu",
- "title": "Indice du semestre dans le cursus",
- "allowed_values": semestre_ids_list,
- "labels": semestre_ids_labels,
- },
- ),
- (
- "titre",
- {
- "size": 40,
- "title": "Nom de ce semestre extérieur",
- "explanation": """par exemple: établissement.
- N'indiquez pas les dates, ni le semestre, ni la modalité dans
- le titre: ils seront automatiquement ajoutés""",
- },
- ),
- (
- "date_debut",
- {
- "title": "Date de début", # j/m/a
- "input_type": "datedmy",
- "explanation": "j/m/a (peut être approximatif)",
- "size": 9,
- "allow_null": False,
- },
- ),
- (
- "date_fin",
- {
- "title": "Date de fin", # j/m/a
- "input_type": "datedmy",
- "explanation": "j/m/a (peut être approximatif)",
- "size": 9,
- "allow_null": False,
- },
- ),
- (
- "elt_help_ue",
- {
- "title": """Les notes et coefficients des UE
- capitalisées seront saisis ensuite""",
- "input_type": "separator",
- },
- ),
- ]
-
- tf = TrivialFormulator(
- request.base_url,
- scu.get_request_args(),
- descr,
- cancelbutton="Annuler",
- method="post",
- submitlabel="Créer semestre extérieur et y inscrire l'étudiant",
- cssclass="inscription",
- name="tf",
- )
- if tf[0] == 0:
- H.append(
- """
Ce formulaire sert à enregistrer un semestre antérieur dans
- la formation effectué dans un autre établissement.
-
"""
- )
- return "\n".join(H) + "\n" + tf[1] + F
- elif tf[0] == -1:
- return flask.redirect(
- url_for(
- "notes.formsemestre_bulletinetud",
- scodoc_dept=g.scodoc_dept,
- formsemestre_id=formsemestre_id,
- etudid=etudid,
- )
- )
- else:
- # Le semestre extérieur est créé dans la même formation que le semestre courant
- tf[2]["formation_id"] = orig_sem["formation_id"]
- formsemestre_ext_create(etudid, tf[2])
- return flask.redirect(
- url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
- )
-
-
-def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid):
- """Edition des validations d'UE et de semestre (jury)
- pour un semestre extérieur.
- On peut saisir pour chaque UE du programme de formation
- sa validation, son code jury, sa note, son coefficient
- (sauf en BUT où le coef. des UE est toujours égal aux ECTS).
-
- La moyenne générale indicative du semestre est calculée et affichée,
- mais pas enregistrée.
- """
- formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
- etud = Identite.query.get_or_404(etudid)
- ues = formsemestre.formation.ues.filter(UniteEns.type != UE_SPORT).order_by(
- UniteEns.semestre_idx, UniteEns.numero
- )
- descr = _ue_form_description(formsemestre, etud, ues, scu.get_request_args())
- initvalues = {}
- if request.method == "GET":
- for ue in ues:
- validation = ScolarFormSemestreValidation.query.filter_by(
- ue_id=ue.id, etudid=etud.id, formsemestre_id=formsemestre.id
- ).first()
- initvalues[f"note_{ue.id}"] = validation.moy_ue if validation else ""
-
- tf = TrivialFormulator(
- request.base_url,
- scu.get_request_args(),
- descr,
- submitlabel="Enregistrer ces validations",
- cancelbutton="Annuler",
- initvalues=initvalues,
- cssclass="tf_ext_edit_ue_validations ext_apc"
- if formsemestre.formation.is_apc()
- else "tf_ext_edit_ue_validations",
- # En APC, stocke les coefficients pour l'affichage de la moyenne en direct
- form_attrs=f"""data-ue_coefs='[{', '.join(str(ue.ects or 0) for ue in ues)}]'"""
- if formsemestre.formation.is_apc()
- else "",
- )
- if tf[0] == -1:
- return "
{etud.nomprenom} est inscrit{etud.e} à ce semestre extérieur.
-
Voici ses UE enregistrées avec leur notes
- { "et coefficients" if not formsemestre.formation.is_apc()
- else " (en BUT, les coefficients sont égaux aux ECTS)"}.
-
- """,
- f"""
La moyenne de ce semestre serait:
- {moy_gen} / 20
-
- """,
- html_sco_header.sco_footer(),
- ]
- return H
-
-
-_UE_VALID_CODES = {
- None: "Non inscrit",
- "ADM": "Capitalisée (ADM)",
- # "CMP": "Acquise (car semestre validé)",
-}
-
-
-def _ue_form_description(
- formsemestre: FormSemestre, etud: Identite, ues: list[UniteEns], values
-):
- """Description du formulaire de saisie des UE / validations
- Pour chaque UE, on peut saisir: son code jury, sa note, son coefficient.
- """
- descr = [
- (
- "head_sep",
- {
- "input_type": "separator",
- "template": """
UE
-
Code jury
Note/20
- """
- + (
- """
Coefficient UE
"""
- if not formsemestre.formation.is_apc()
- else ""
- )
- + "
",
- },
- ),
- ("formsemestre_id", {"input_type": "hidden"}),
- ("etudid", {"input_type": "hidden"}),
- ]
- for ue in ues:
- # Menu pour code validation UE:
- # Ne propose que ADM, CMP et "Non inscrit"
- select_name = f"valid_{ue.id}"
- menu_code_ue = f""""
- if formsemestre.formation.is_apc():
- coef_disabled = 'disabled="1"'
- cur_coef_value = ue.ects or 0
- coef_input_class = "ext_coef_disabled"
- else:
- cur_coef_value = values.get(f"coef_{ue.id}", False)
- coef_input_class = ""
- if cur_coef_value is False: # pas dans le form, cherche en base
- ue_coef: FormSemestreUECoef = FormSemestreUECoef.query.filter_by(
- formsemestre_id=formsemestre.id, ue_id=ue.id
- ).first()
- cur_coef_value = (ue_coef.coefficient if ue_coef else "") or ""
- itemtemplate = (
- f"""
-
-
%(label)s
-
{ menu_code_ue }
-
%(elem)s
- """
- + (
- f"""
-
-
"""
- if not formsemestre.formation.is_apc()
- else ""
- )
- + """
"""
- )
-
- descr.append(
- (
- f"note_{ue.id}",
- {
- "input_type": "text",
- "size": 4,
- "template": itemtemplate,
- "title": ""
- + (f"S{ue.semestre_idx} " if ue.semestre_idx is not None else "")
- + f"{ue.acronyme} {ue.titre}"
- + f" ({ue.ects} ECTS)"
- if ue.ects is not None
- else "",
- "attributes": [coef_disabled],
- },
- )
- )
- return descr
-
-
-def _check_values(formsemestre: FormSemestre, ue_list, values):
- """Check that form values are ok
- for each UE:
- code != None => note and coef
- note or coef => code != None
- note float in [0, 20]
- note => coef
- coef float >= 0
- """
- for ue in ue_list:
- pu = f" pour UE {ue.acronyme}"
- code = values.get(f"valid_{ue.id}", False)
- if code == "None":
- code = None
- note = values.get(f"note_{ue.id}", False)
- try:
- note = _convert_field_to_float(note)
- except ValueError:
- return False, "note invalide" + pu
-
- if code is not False:
- if code not in _UE_VALID_CODES:
- return False, "code invalide" + pu
- if code is not None:
- if note is False or note == "":
- return False, "note manquante" + pu
- coef = values.get(f"coef_{ue.id}", False)
- try:
- coef = _convert_field_to_float(coef)
- except ValueError:
- return False, "coefficient invalide" + pu
- if note is not False and note != "":
- if code is None:
- return (
- False,
- f"""code jury incohérent (code {code}, note {note}) {pu}
- (supprimer note)""",
- )
- if note < 0 or note > 20:
- return False, "valeur note invalide" + pu
- if not isinstance(coef, float) and not formsemestre.formation.is_apc():
- return False, f"coefficient manquant pour note {note} {pu}"
-
- # Vérifie valeur coef seulement pour formations classiques:
- if not formsemestre.formation.is_apc():
- if coef is not False and coef != "":
- if coef < 0:
- return False, "valeur coefficient invalide" + pu
-
- return True, "ok"
-
-
-def _convert_field_to_float(val):
- """val may be empty, False (left unchanged), or a float. Raise exception ValueError"""
- if val is not False:
- val = val.strip()
- if val:
- val = float(val)
- return val
-
-
-def _record_ue_validations_and_coefs(
- formsemestre: FormSemestre, etud: Identite, ues: list[UniteEns], values
-):
- """Enregistre en base les validations
- En APC, le coef est toujours NULL
- """
- for ue in ues:
- code = values.get(f"valid_{ue.id}", False)
- if code == "None":
- code = None
- note = values.get(f"note_{ue.id}", False)
- note = _convert_field_to_float(note)
- coef = values.get(f"coef_{ue.id}", False)
- coef = _convert_field_to_float(coef)
- if coef == "" or coef is False:
- coef = None
- now_dmy = time.strftime("%d/%m/%Y")
- log(
- f"_record_ue_validations_and_coefs: {formsemestre.id} etudid={etud.id} ue_id={ue.id} moy_ue={note} ue_coef={coef}"
- )
- assert code is None or (note) # si code validant, il faut une note
- sco_formsemestre_validation.do_formsemestre_validate_previous_ue(
- formsemestre.id,
- etud.id,
- ue.id,
- note,
- now_dmy,
- code=code,
- ue_coefficient=coef,
- )
+# -*- 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
+#
+##############################################################################
+
+"""Saisie et gestion des semestres extérieurs à ScoDoc dans un parcours.
+
+On va créer/gérer des semestres de la même formation que le semestre ScoDoc
+où est inscrit l'étudiant, leur attribuer la modalité 'EXT'.
+Ces semestres n'auront qu'un seul inscrit !
+"""
+import time
+
+import flask
+from flask import url_for, g, request
+from flask_login import current_user
+
+from app.comp import res_sem
+from app.comp.res_compat import NotesTableCompat
+from app.models import (
+ FormSemestre,
+ FormSemestreUECoef,
+ Identite,
+ ScolarFormSemestreValidation,
+ UniteEns,
+)
+import app.scodoc.sco_utils as scu
+from app import log
+from app.scodoc.TrivialFormulator import TrivialFormulator, tf_error_message
+from app.scodoc import html_sco_header
+from app.scodoc import sco_formations
+from app.scodoc import sco_formsemestre
+from app.scodoc import sco_formsemestre_inscriptions
+from app.scodoc import sco_formsemestre_validation
+from app.scodoc import sco_etud
+from app.scodoc.sco_codes_parcours import UE_SPORT
+
+
+def formsemestre_ext_create(etudid, sem_params):
+ """Crée un formsemestre exterieur et y inscrit l'étudiant.
+ sem_params: dict nécessaire à la création du formsemestre
+ """
+ # Check args
+ _formation = sco_formations.formation_list(
+ args={"formation_id": sem_params["formation_id"]}
+ )[0]
+ if etudid:
+ _etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
+
+ # Create formsemestre
+ sem_params["modalite"] = "EXT"
+ sem_params["etapes"] = None
+ sem_params["responsables"] = [current_user.id]
+ formsemestre_id = sco_formsemestre.do_formsemestre_create(sem_params, silent=True)
+ # nota: le semestre est créé vide: pas de modules
+
+ # Inscription au semestre
+ sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules(
+ formsemestre_id,
+ etudid,
+ method="formsemestre_ext_create",
+ )
+ return formsemestre_id
+
+
+def formsemestre_ext_create_form(etudid, formsemestre_id):
+ """Formulaire création/inscription à un semestre extérieur"""
+ etud = Identite.query.get_or_404(etudid)
+ H = [
+ html_sco_header.sco_header(),
+ f"""
Enregistrement d'une inscription antérieure dans un autre
+ établissement
+
+ Cette opération crée un semestre extérieur ("ancien") de la même
+ formation que le semestre courant, et y inscrit juste cet étudiant.
+ La décision de jury peut ensuite y être saisie.
+
+
+ Notez que si un semestre extérieur similaire a déjà été créé pour un autre
+ étudiant, il est préférable d'utiliser la fonction
+ "
+ inscrire à un autre semestre"
+
+ """,
+ ]
+ F = html_sco_header.sco_footer()
+ orig_sem = sco_formsemestre.get_formsemestre(formsemestre_id)
+ # Ne propose que des semestres de semestre_id strictement inférieur
+ # au semestre courant
+ # et seulement si pas inscrit au même semestre_id d'un semestre ordinaire ScoDoc.
+ # 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"}
+ )
+ semlist = [sco_formsemestre.get_formsemestre(i["formsemestre_id"]) for i in insem]
+ existing_semestre_ids = {s["semestre_id"] for s in semlist}
+ min_semestre_id = 1
+ max_semestre_id = orig_sem["semestre_id"]
+ semestre_ids = set(range(min_semestre_id, max_semestre_id)) - existing_semestre_ids
+ H.append(
+ f"""
L'étudiant est déjà inscrit dans des semestres ScoDoc de rangs:
+ { sorted(list(existing_semestre_ids)) }
+
+ """
+ )
+ if not semestre_ids:
+ H.append(
+ f"""
pas de semestres extérieurs possibles
+ (indices entre {min_semestre_id} et {max_semestre_id}, semestre courant.)
+
"""
+ )
+ return "\n".join(H) + F
+ # Formulaire
+ semestre_ids_list = sorted(semestre_ids)
+ semestre_ids_labels = [f"S{x}" for x in semestre_ids_list]
+ descr = [
+ ("formsemestre_id", {"input_type": "hidden"}),
+ ("etudid", {"input_type": "hidden"}),
+ (
+ "semestre_id",
+ {
+ "input_type": "menu",
+ "title": "Indice du semestre dans le cursus",
+ "allowed_values": semestre_ids_list,
+ "labels": semestre_ids_labels,
+ },
+ ),
+ (
+ "titre",
+ {
+ "size": 40,
+ "title": "Nom de ce semestre extérieur",
+ "explanation": """par exemple: établissement.
+ N'indiquez pas les dates, ni le semestre, ni la modalité dans
+ le titre: ils seront automatiquement ajoutés""",
+ },
+ ),
+ (
+ "date_debut",
+ {
+ "title": "Date de début", # j/m/a
+ "input_type": "datedmy",
+ "explanation": "j/m/a (peut être approximatif)",
+ "size": 9,
+ "allow_null": False,
+ },
+ ),
+ (
+ "date_fin",
+ {
+ "title": "Date de fin", # j/m/a
+ "input_type": "datedmy",
+ "explanation": "j/m/a (peut être approximatif)",
+ "size": 9,
+ "allow_null": False,
+ },
+ ),
+ (
+ "elt_help_ue",
+ {
+ "title": """Les notes et coefficients des UE
+ capitalisées seront saisis ensuite""",
+ "input_type": "separator",
+ },
+ ),
+ ]
+
+ tf = TrivialFormulator(
+ request.base_url,
+ scu.get_request_args(),
+ descr,
+ cancelbutton="Annuler",
+ method="post",
+ submitlabel="Créer semestre extérieur et y inscrire l'étudiant",
+ cssclass="inscription",
+ name="tf",
+ )
+ if tf[0] == 0:
+ H.append(
+ """
Ce formulaire sert à enregistrer un semestre antérieur dans
+ la formation effectué dans un autre établissement.
+
"""
+ )
+ return "\n".join(H) + "\n" + tf[1] + F
+ elif tf[0] == -1:
+ return flask.redirect(
+ url_for(
+ "notes.formsemestre_bulletinetud",
+ scodoc_dept=g.scodoc_dept,
+ formsemestre_id=formsemestre_id,
+ etudid=etudid,
+ )
+ )
+ else:
+ # Le semestre extérieur est créé dans la même formation que le semestre courant
+ tf[2]["formation_id"] = orig_sem["formation_id"]
+ formsemestre_ext_create(etudid, tf[2])
+ return flask.redirect(
+ url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etudid)
+ )
+
+
+def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid):
+ """Edition des validations d'UE et de semestre (jury)
+ pour un semestre extérieur.
+ On peut saisir pour chaque UE du programme de formation
+ sa validation, son code jury, sa note, son coefficient
+ (sauf en BUT où le coef. des UE est toujours égal aux ECTS).
+
+ La moyenne générale indicative du semestre est calculée et affichée,
+ mais pas enregistrée.
+ """
+ formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
+ etud = Identite.query.get_or_404(etudid)
+ ues = formsemestre.formation.ues.filter(UniteEns.type != UE_SPORT).order_by(
+ UniteEns.semestre_idx, UniteEns.numero
+ )
+ if formsemestre.formation.is_apc():
+ ues = ues.filter_by(semestre_idx=formsemestre.semestre_id)
+ descr = _ue_form_description(formsemestre, etud, ues, scu.get_request_args())
+ initvalues = {}
+ if request.method == "GET":
+ for ue in ues:
+ validation = ScolarFormSemestreValidation.query.filter_by(
+ ue_id=ue.id, etudid=etud.id, formsemestre_id=formsemestre.id
+ ).first()
+ initvalues[f"note_{ue.id}"] = validation.moy_ue if validation else ""
+
+ tf = TrivialFormulator(
+ request.base_url,
+ scu.get_request_args(),
+ descr,
+ submitlabel="Enregistrer ces validations",
+ cancelbutton="Annuler",
+ initvalues=initvalues,
+ cssclass="tf_ext_edit_ue_validations ext_apc"
+ if formsemestre.formation.is_apc()
+ else "tf_ext_edit_ue_validations",
+ # En APC, stocke les coefficients pour l'affichage de la moyenne en direct
+ form_attrs=f"""data-ue_coefs='[{', '.join(str(ue.ects or 0) for ue in ues)}]'"""
+ if formsemestre.formation.is_apc()
+ else "",
+ )
+ if tf[0] == -1:
+ return "
{etud.nomprenom} est inscrit{etud.e} à ce semestre extérieur.
+
Voici ses UE enregistrées avec leur notes
+ { "et coefficients" if not formsemestre.formation.is_apc()
+ else " (en BUT, les coefficients sont égaux aux ECTS)"}.
+
+ """,
+ f"""
La moyenne de ce semestre serait:
+ {moy_gen} / 20
+
+ """,
+ html_sco_header.sco_footer(),
+ ]
+ return H
+
+
+_UE_VALID_CODES = {
+ None: "Non inscrit",
+ "ADM": "Capitalisée (ADM)",
+ # "CMP": "Acquise (car semestre validé)",
+}
+
+
+def _ue_form_description(
+ formsemestre: FormSemestre, etud: Identite, ues: list[UniteEns], values
+):
+ """Description du formulaire de saisie des UE / validations
+ Pour chaque UE, on peut saisir: son code jury, sa note, son coefficient.
+ """
+ descr = [
+ (
+ "head_sep",
+ {
+ "input_type": "separator",
+ "template": """
UE
+
Code jury
Note/20
+ """
+ + (
+ """
Coefficient UE
"""
+ if not formsemestre.formation.is_apc()
+ else ""
+ )
+ + "
",
+ },
+ ),
+ ("formsemestre_id", {"input_type": "hidden"}),
+ ("etudid", {"input_type": "hidden"}),
+ ]
+ for ue in ues:
+ # Menu pour code validation UE:
+ # Ne propose que ADM, CMP et "Non inscrit"
+ select_name = f"valid_{ue.id}"
+ menu_code_ue = f""""
+ if formsemestre.formation.is_apc():
+ coef_disabled = 'disabled="1"'
+ cur_coef_value = ue.ects or 0
+ coef_input_class = "ext_coef_disabled"
+ else:
+ cur_coef_value = values.get(f"coef_{ue.id}", False)
+ coef_input_class = ""
+ if cur_coef_value is False: # pas dans le form, cherche en base
+ ue_coef: FormSemestreUECoef = FormSemestreUECoef.query.filter_by(
+ formsemestre_id=formsemestre.id, ue_id=ue.id
+ ).first()
+ cur_coef_value = (ue_coef.coefficient if ue_coef else "") or ""
+ itemtemplate = (
+ f"""
+
+
%(label)s
+
{ menu_code_ue }
+
%(elem)s
+ """
+ + (
+ f"""
+
+
"""
+ if not formsemestre.formation.is_apc()
+ else ""
+ )
+ + """
"""
+ )
+
+ descr.append(
+ (
+ f"note_{ue.id}",
+ {
+ "input_type": "text",
+ "size": 4,
+ "template": itemtemplate,
+ "title": ""
+ + (f"S{ue.semestre_idx} " if ue.semestre_idx is not None else "")
+ + f"{ue.acronyme} {ue.titre}"
+ + f" ({ue.ects} ECTS)"
+ if ue.ects is not None
+ else "",
+ "attributes": [coef_disabled],
+ },
+ )
+ )
+ return descr
+
+
+def _check_values(formsemestre: FormSemestre, ue_list, values):
+ """Check that form values are ok
+ for each UE:
+ code != None => note and coef
+ note or coef => code != None
+ note float in [0, 20]
+ note => coef
+ coef float >= 0
+ """
+ for ue in ue_list:
+ pu = f" pour UE {ue.acronyme}"
+ code = values.get(f"valid_{ue.id}", False)
+ if code == "None":
+ code = None
+ note = values.get(f"note_{ue.id}", False)
+ try:
+ note = _convert_field_to_float(note)
+ except ValueError:
+ return False, "note invalide" + pu
+
+ if code is not False:
+ if code not in _UE_VALID_CODES:
+ return False, "code invalide" + pu
+ if code is not None:
+ if note is False or note == "":
+ return False, "note manquante" + pu
+ coef = values.get(f"coef_{ue.id}", False)
+ try:
+ coef = _convert_field_to_float(coef)
+ except ValueError:
+ return False, "coefficient invalide" + pu
+ if note is not False and note != "":
+ if code is None:
+ return (
+ False,
+ f"""code jury incohérent (code {code}, note {note}) {pu}
+ (supprimer note)""",
+ )
+ if note < 0 or note > 20:
+ return False, "valeur note invalide" + pu
+ if not isinstance(coef, float) and not formsemestre.formation.is_apc():
+ return False, f"coefficient manquant pour note {note} {pu}"
+
+ # Vérifie valeur coef seulement pour formations classiques:
+ if not formsemestre.formation.is_apc():
+ if coef is not False and coef != "":
+ if coef < 0:
+ return False, "valeur coefficient invalide" + pu
+
+ return True, "ok"
+
+
+def _convert_field_to_float(val):
+ """val may be empty, False (left unchanged), or a float. Raise exception ValueError"""
+ if val is not False:
+ val = val.strip()
+ if val:
+ val = float(val)
+ return val
+
+
+def _record_ue_validations_and_coefs(
+ formsemestre: FormSemestre, etud: Identite, ues: list[UniteEns], values
+):
+ """Enregistre en base les validations
+ En APC, le coef est toujours NULL
+ """
+ for ue in ues:
+ code = values.get(f"valid_{ue.id}", False)
+ if code == "None":
+ code = None
+ note = values.get(f"note_{ue.id}", False)
+ note = _convert_field_to_float(note)
+ coef = values.get(f"coef_{ue.id}", False)
+ coef = _convert_field_to_float(coef)
+ if coef == "" or coef is False:
+ coef = None
+ now_dmy = time.strftime("%d/%m/%Y")
+ log(
+ f"_record_ue_validations_and_coefs: {formsemestre.id} etudid={etud.id} ue_id={ue.id} moy_ue={note} ue_coef={coef}"
+ )
+ assert code is None or (note) # si code validant, il faut une note
+ sco_formsemestre_validation.do_formsemestre_validate_previous_ue(
+ formsemestre.id,
+ etud.id,
+ ue.id,
+ note,
+ now_dmy,
+ code=code,
+ ue_coefficient=coef,
+ )
diff --git a/app/templates/scodoc/help/modules.html b/app/templates/scodoc/help/modules.html
index b1382d5d..aabce645 100644
--- a/app/templates/scodoc/help/modules.html
+++ b/app/templates/scodoc/help/modules.html
@@ -1,50 +1,52 @@
-{# -*- mode: jinja-html -*- #}
-
-
- Les modules sont décrits dans le programme pédagogique. Un module est pour ce
- logiciel l'unité pédagogique élémentaire. On va lui associer une note
- à travers des évaluations.
- Cette note (moyenne de module) sera utilisée pour calculer la moyenne
- générale (et la moyenne de l'UE à laquelle appartient le module). Pour
- cela, on utilisera le coefficient associé au module.
-
-
-
Un module possède un enseignant responsable
- (typiquement celui qui dispense le cours magistral). On peut associer
- au module une liste d'enseignants (typiquement les chargés de TD).
- Tous ces enseignants, et le responsable du semestre, pourront
- saisir et modifier les notes de ce module.
-
- {%if is_apc%}
-
- Dans le BUT, les modules peuvent être de type "ressource" ou "Situation
- d'apprentissage et d'évaluation" (SAÉ). Ne pas oublier de préciser le
- type, et de saisir les coefficients pondérant l'influence de la
- ressource ou SAÉ vers les Unités d'Enseignement (UE).
- Voir les détails sur
- la documentation.
-
- {%endif%}
-
-
-{% if formsemestres %}
-
Module déjà utilisé dans des semestres, soyez prudents !
-
- Ce module est utilisé dans des semestres déjà mis en place, il faut prêter attention
- aux conséquences des changements effectués ici: par exemple les coefficients vont modifier
- les notes moyennes calculées. Les modules déjà utilisés ne peuvent pas être changés de semestre, ni détruits.
- Si vous souhaitez faire cela, allez d'abord modifier les semestres concernés pour déselectionner le module.
-
+ Les modules sont décrits dans le programme pédagogique. Un module est pour ce
+ logiciel l'unité pédagogique élémentaire. On va lui associer une note
+ à travers des évaluations.
+ Cette note (moyenne de module) sera utilisée pour calculer la moyenne
+ générale (et la moyenne de l'UE à laquelle appartient le module). Pour
+ cela, on utilisera le coefficient associé au module.
+
+
+
Un module possède un enseignant responsable
+ (typiquement celui qui dispense le cours magistral). On peut associer
+ au module une liste d'enseignants (typiquement les chargés de TD).
+ Tous ces enseignants, et le responsable du semestre, pourront
+ saisir et modifier les notes de ce module.
+
+ {%if is_apc%}
+
+ Dans le BUT, les modules peuvent être de type "ressource" ou "Situation
+ d'apprentissage et d'évaluation" (SAÉ). Ne pas oublier de préciser le
+ type, et de saisir les coefficients pondérant l'influence de la
+ ressource ou SAÉ vers les Unités d'Enseignement (UE).
+ Voir les détails sur
+ la documentation.
+
+ {%endif%}
+
+
+{% if formsemestres %}
+
Module déjà utilisé dans des semestres, soyez prudents !
+
+ Ce module est utilisé dans des semestres déjà mis en place, il faut prêter attention
+ aux conséquences des changements effectués ici: par exemple les coefficients vont modifier
+ les notes moyennes calculées. Les modules déjà utilisés ne peuvent pas être changés de semestre, ni détruits.
+ Si vous souhaitez faire cela, allez d'abord modifier les semestres concernés pour déselectionner le module.
+