diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 diff --git a/app/api/etudiants.py b/app/api/etudiants.py index 1d33b04b..46478231 100644 --- a/app/api/etudiants.py +++ b/app/api/etudiants.py @@ -212,7 +212,9 @@ def etudiant_formsemestres(etudid: int = None, nip: int = None, ine: int = None) formsemestres = query.order_by(FormSemestre.date_debut) - return jsonify([formsemestre.to_dict() for formsemestre in formsemestres]) + return jsonify( + [formsemestre.to_dict(convert_parcours=True) for formsemestre in formsemestres] + ) @bp.route( @@ -471,7 +473,7 @@ def etudiant_bulletin_semestre( # XXX TODO Ajouter la possibilité de retourner return response return sco_bulletins.get_formsemestre_bulletin_etud_json( - formsemestre, etud, version + formsemestre, etud, version=version ) diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py index c086b627..e3d9a761 100644 --- a/app/api/formsemestres.py +++ b/app/api/formsemestres.py @@ -62,7 +62,7 @@ def formsemestre(formsemestre_id: int): formsemestre: FormSemestre = models.FormSemestre.query.filter_by( id=formsemestre_id ).first_or_404() - data = formsemestre.to_dict() + data = formsemestre.to_dict(convert_parcours=True) # Pour le moment on a besoin de fixer le departement # pour accéder aux préferences dept = Departement.query.get(formsemestre.dept_id) @@ -92,13 +92,9 @@ def formsemestre_apo(etape_apo: str): FormSemestreEtape.formsemestre_id == FormSemestre.id, ) - res = [formsemestre.to_dict() for formsemestre in formsemestres] - if len(res) == 0: - return error_response( - 404, message="Aucun formsemestre trouvé avec cette étape apogée" - ) - else: - return jsonify(res) + return jsonify( + [formsemestre.to_dict(convert_parcours=True) for formsemestre in formsemestres] + ) @bp.route("/formsemestre//bulletins", methods=["GET"]) diff --git a/app/but/bulletin_but.py b/app/but/bulletin_but.py index 279cf86e..3b90187b 100644 --- a/app/but/bulletin_but.py +++ b/app/but/bulletin_but.py @@ -14,6 +14,7 @@ from flask import url_for, g from app.comp.res_but import ResultatsSemestreBUT from app.models import FormSemestre, Identite +from app.models import but_validations from app.models.groups import GroupDescr from app.models.ues import UniteEns from app.scodoc import sco_bulletins, sco_utils as scu @@ -323,9 +324,13 @@ class BulletinBUT: ects_tot = sum([ue.ects or 0 for ue in res.ues]) if res.ues else 0.0 ects_acquis = sum([d.get("ects", 0) for d in decisions_ues.values()]) semestre_infos["ECTS"] = {"acquis": ects_acquis, "total": ects_tot} - semestre_infos.update( - sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id) - ) + if sco_preferences.get_preference("bul_show_decision", formsemestre.id): + semestre_infos.update( + sco_bulletins_json.dict_decision_jury(etud.id, formsemestre.id) + ) + semestre_infos.update( + but_validations.dict_decision_jury(etud, formsemestre) + ) if etat_inscription == scu.INSCRIT: # moyenne des moyennes générales du semestre semestre_infos["notes"] = { diff --git a/app/but/jury_but.py b/app/but/jury_but.py index 39d477bf..397bd05f 100644 --- a/app/but/jury_but.py +++ b/app/but/jury_but.py @@ -68,7 +68,7 @@ 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.comp import inscr_mod, res_sem from app.models import formsemestre from app.models.but_refcomp import ( @@ -189,6 +189,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): sco_codes.RAT, sco_codes.ABAN, sco_codes.ABL, + sco_codes.ATJ, sco_codes.DEF, sco_codes.DEM, sco_codes.EXCLU, @@ -200,6 +201,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): formsemestre: FormSemestre, ): super().__init__(etud=etud) + self.formsemestre_id = formsemestre.id formsemestre_impair, formsemestre_pair = self.comp_formsemestres(formsemestre) assert ( (formsemestre_pair is None) @@ -217,15 +219,16 @@ class DecisionsProposeesAnnee(DecisionsProposees): "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)" - self.annee_but = ( - formsemestre_impair.semestre_id // 2 + 1 - if formsemestre_impair - else formsemestre_pair.semestre_id // 2 - ) + 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, @@ -253,13 +256,17 @@ class DecisionsProposeesAnnee(DecisionsProposees): self.ues_impair, self.ues_pair = self.compute_ues_annee() # pylint: disable=all self.decisions_ues = { - ue.id: DecisionsProposeesUE(etud, formsemestre_impair, ue) + 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) + ue.id: DecisionsProposeesUE( + etud, formsemestre_pair, ue, self.inscription_etat + ) for ue in self.ues_pair } ) @@ -289,8 +296,10 @@ class DecisionsProposeesAnnee(DecisionsProposees): [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) - self.admis = self.nb_validables == self.nb_competences + # 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" self.valide_moitie_rcue = self.nb_validables > (self.nb_competences // 2) # Peut passer si plus de la moitié validables et tous > 8 @@ -308,6 +317,19 @@ class DecisionsProposeesAnnee(DecisionsProposees): 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 @@ -385,7 +407,9 @@ class DecisionsProposeesAnnee(DecisionsProposees): def comp_formsemestres( self, formsemestre: FormSemestre ) -> tuple[FormSemestre, FormSemestre]: - "les deux formsemestres de l'année scolaire à laquelle appartient 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: @@ -419,7 +443,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): (self.formsemestre_impair, self.res_impair), (self.formsemestre_pair, self.res_pair), ): - if formsemestre is None: + if (formsemestre is None) or (not formsemestre.formation.is_apc()): ues = [] else: formation: Formation = formsemestre.formation @@ -478,6 +502,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): ue_impair, self.formsemestre_pair, ue_pair, + self.inscription_etat, ) ues_impair_sans_rcue.discard(ue_impair.id) break @@ -505,7 +530,7 @@ class DecisionsProposeesAnnee(DecisionsProposees): rcue = rc break if rcue is not None: - dec_rcue = DecisionsProposeesRCUE(self, rcue) + 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) @@ -663,6 +688,46 @@ class DecisionsProposeesAnnee(DecisionsProposees): db.session.delete(validation) db.session.flush() + 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 @@ -673,20 +738,33 @@ class DecisionsProposeesRCUE(DecisionsProposees): 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 + 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 @@ -737,6 +815,21 @@ class DecisionsProposeesRCUE(DecisionsProposees): 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 @@ -758,6 +851,7 @@ class DecisionsProposeesUE(DecisionsProposees): sco_codes.RAT, sco_codes.DEF, sco_codes.ABAN, + sco_codes.ATJ, sco_codes.DEM, sco_codes.UEBSL, ] @@ -767,12 +861,27 @@ class DecisionsProposeesUE(DecisionsProposees): etud: Identite, formsemestre: FormSemestre, ue: UniteEns, + inscription_etat: str = scu.INSCRIT, ): super().__init__(etud=etud) self.formsemestre = formsemestre self.ue: UniteEns = ue self.rcue: RegroupementCoherentUE = None "Le rcu 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 = "-" + return # 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( @@ -780,10 +889,6 @@ class DecisionsProposeesUE(DecisionsProposees): ).first() if self.validation is not None: self.code_valide = self.validation.code - if ue.type == sco_codes.UE_SPORT: - self.explanation = "UE bonus, pas de décision de jury" - self.codes = [] # aucun code proposé - return # Moyenne de l'UE ? res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) @@ -802,6 +907,8 @@ class DecisionsProposeesUE(DecisionsProposees): 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",) @@ -853,6 +960,14 @@ class DecisionsProposeesUE(DecisionsProposees): 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""" @@ -932,7 +1047,7 @@ class BUTCursusEtud: # WIP TODO """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 donéne par l'arrêté BUT: + 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. """ diff --git a/app/but/jury_but_pv.py b/app/but/jury_but_pv.py new file mode 100644 index 00000000..095c5efc --- /dev/null +++ b/app/but/jury_but_pv.py @@ -0,0 +1,137 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Jury BUT: table synthèse résultats semestre / PV +""" +from flask import g, request, url_for + +from openpyxl.styles import Font, Border, Side, Alignment, PatternFill + +from app import log +from app.but import jury_but +from app.models.etudiants import Identite +from app.models.formsemestre import FormSemestre +from app.scodoc.gen_tables import GenTable +from app.scodoc import sco_excel +from app.scodoc.sco_exceptions import ScoValueError +from app.scodoc import sco_preferences +from app.scodoc import sco_utils as scu + + +def _descr_cursus_but(etud: Identite) -> str: + "description de la liste des semestres BUT suivis" + # prend simplement tous les semestre de type APC, ce qui sera faux si + # l'étudiant change de spécialité au sein du même département + # (ce qui ne peut normalement pas se produire) + indices = sorted( + [ + ins.formsemestre.semestre_id + if ins.formsemestre.semestre_id is not None + else -1 + for ins in etud.formsemestre_inscriptions + if ins.formsemestre.formation.is_apc() + ] + ) + return ", ".join(f"S{indice}" for indice in indices) + + +def pvjury_table_but(formsemestre_id: int, format="html") -> list[dict]: + """Page récapitulant les décisions de jury BUT + formsemestre peut être pair ou impair + """ + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) + assert formsemestre.formation.is_apc() + title = "Procès-verbal de jury BUT annuel" + + if format == "html": + line_sep = "
" + else: + line_sep = "\n" + # remplace pour le BUT la fonction sco_pvjury.pvjury_table + annee_but = (formsemestre.semestre_id + 1) // 2 + titles = { + "nom": "Nom", + "cursus": "Cursus", + "ues": "UE validées", + "niveaux": "Niveaux de compétences validés", + "decision_but": f"Décision BUT{annee_but}", + "diplome": "Résultat au diplôme", + "devenir": "Devenir", + "observations": "Observations", + } + rows = [] + for etudid in formsemestre.etuds_inscriptions: + etud: Identite = Identite.query.get(etudid) + try: + deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) + if deca.annee_but != annee_but: # wtf ? + log( + f"pvjury_table_but: inconsistent annee_but {deca.annee_but} != {annee_but}" + ) + continue + except ScoValueError: + deca = None + row = { + "nom": etud.etat_civil_pv(line_sep=line_sep), + "_nom_order": etud.sort_key, + "_nom_target_attrs": f'class="etudinfo" id="{etud.id}"', + "_nom_td_attrs": f'id="{etud.id}" class="etudinfo"', + "_nom_target": url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etud.id, + ), + "cursus": _descr_cursus_but(etud), + "ues": deca.descr_ues_validation(line_sep=line_sep) if deca else "-", + "niveaux": deca.descr_niveaux_validation(line_sep=line_sep) + if deca + else "-", + "decision_but": deca.code_valide if deca else "", + "devenir": ", ".join([f"S{i}" for i in deca.get_autorisations_passage()]), + } + + rows.append(row) + + rows.sort(key=lambda x: x["_nom_order"]) + + # Style excel... passages à la ligne sur \n + xls_style_base = sco_excel.excel_make_style() + xls_style_base["alignment"] = Alignment(wrapText=True, vertical="top") + + tab = GenTable( + base_url=f"{request.base_url}?formsemestre_id={formsemestre_id}", + caption=title, + columns_ids=titles.keys(), + html_caption=title, + html_class="pvjury_table_but table_leftalign", + html_title=f"""
{title} + + version excel
+ + """, + html_with_td_classes=True, + origin=f"Généré par {scu.sco_version.SCONAME} le {scu.timedate_human_repr()}", + page_title=title, + pdf_title=title, + preferences=sco_preferences.SemPreferences(), + rows=rows, + table_id="formation_table_recap", + titles=titles, + xls_columns_width={ + "nom": 32, + "cursus": 12, + "ues": 32, + "niveaux": 32, + "decision_but": 14, + "diplome": 17, + "devenir": 8, + "observations": 12, + }, + xls_style_base=xls_style_base, + ) + return tab.make_page(format=format, javascripts=["js/etud_info.js"], init_qtip=True) diff --git a/app/but/jury_but_recap.py b/app/but/jury_but_recap.py index 333fb13b..7d47cbe5 100644 --- a/app/but/jury_but_recap.py +++ b/app/but/jury_but_recap.py @@ -36,7 +36,7 @@ from app.scodoc.sco_exceptions import ScoValueError def formsemestre_saisie_jury_but( formsemestre2: FormSemestre, - readonly: bool = False, + read_only: bool = False, selected_etudid: int = None, mode="jury", ) -> str: @@ -72,7 +72,7 @@ def formsemestre_saisie_jury_but( ) rows, titles, column_ids = get_table_jury_but( - formsemestre2, readonly=readonly, mode=mode + formsemestre2, read_only=read_only, mode=mode ) if not rows: return ( @@ -98,7 +98,16 @@ def formsemestre_saisie_jury_but( ] if mode == "recap": H.append( - """

Décisions de jury enregistrées pour les étudiants de ce semestre

""" + f"""

Décisions de jury enregistrées pour les étudiants de ce semestre

+ + """ ) H.append( f""" @@ -109,7 +118,7 @@ def formsemestre_saisie_jury_but( """ ) - if (mode == "recap") and not readonly: + if (mode == "recap") and not read_only: H.append( f"""

0 else ""), "col_rcue col_rcues_validables" + klass, ) + self["_rcues_validables_data"] = { + "etudid": deca.etud.id, + "nomprenom": deca.etud.nomprenom, + } if len(deca.rcues_annee) > 0: # permet un tri par nb de niveaux validables + moyenne gen indicative S_pair if deca.res_pair and deca.etud.id in deca.res_pair.etud_moy_gen: @@ -353,7 +366,7 @@ class RowCollector: def get_table_jury_but( - formsemestre2: FormSemestre, readonly: bool = False, mode="jury" + formsemestre2: FormSemestre, read_only: bool = False, mode="jury" ) -> tuple[list[dict], list[str], list[str]]: """Construit la table des résultats annuels pour le jury BUT""" res2: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre2) @@ -383,7 +396,7 @@ def get_table_jury_but( "col_code_annee", ) # --- Le lien de saisie - if not readonly and not mode == "recap": + if mode != "recap": row.add_cell( "lien_saisie", "", @@ -394,9 +407,11 @@ def get_table_jury_but( etudid=etud.id, formsemestre_id=formsemestre2.id, )}" class="stdlink"> - {"modif." if deca.code_valide else "saisie"} + {"voir" if read_only else ("modif." if deca.code_valide else "saisie")} décision - """, + """ + if deca.inscription_etat == scu.INSCRIT + else deca.inscription_etat, "col_lien_saisie_but", ) rows.append(row) diff --git a/app/but/jury_but_view.py b/app/but/jury_but_view.py new file mode 100644 index 00000000..fa517120 --- /dev/null +++ b/app/but/jury_but_view.py @@ -0,0 +1,173 @@ +############################################################################## +# ScoDoc +# Copyright (c) 1999 - 2022 Emmanuel Viennet. All rights reserved. +# See LICENSE +############################################################################## + +"""Jury BUT: affichage/formulaire +""" +from flask import g, url_for +from app.models.etudiants import Identite + +from app.scodoc import sco_utils as scu +from app.but.jury_but import DecisionsProposeesAnnee, DecisionsProposeesUE +from app.models import FormSemestre, FormSemestreInscription, UniteEns +from app.scodoc.sco_exceptions import ScoValueError + + +def show_etud(deca: DecisionsProposeesAnnee, read_only: bool = True) -> str: + """Affichage des décisions annuelles BUT + Si pas read_only, menus sélection codes jury. + """ + H = [] + if deca.code_valide and not read_only: + erase_span = f"""effacer décisions""" + else: + erase_span = "" + + H.append( + f"""

+
+ Décision de jury pour l'année : { + _gen_but_select("code_annee", deca.codes, deca.code_valide, + disabled=True, klass="manual") + } + ({'non ' if deca.code_valide is None else ''}enregistrée) + {erase_span} +
+
{deca.explanation}
+
""" + ) + + H.append( + f""" +
Niveaux de compétences et unités d'enseignement :
+
+
+
S{1}
+
S{2}
+
RCUE
+ """ + ) + for niveau in deca.niveaux_competences: + H.append( + f"""
+
{niveau.competence.titre}
+
""" + ) + dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) + if dec_rcue is None: + break + # Semestre impair + H.append( + _gen_but_niveau_ue( + dec_rcue.rcue.ue_1, + dec_rcue.rcue.moy_ue_1, + deca.decisions_ues[dec_rcue.rcue.ue_1.id], + disabled=read_only, + ) + ) + # Semestre pair + H.append( + _gen_but_niveau_ue( + dec_rcue.rcue.ue_2, + dec_rcue.rcue.moy_ue_2, + deca.decisions_ues[dec_rcue.rcue.ue_2.id], + disabled=read_only, + ) + ) + # RCUE + H.append( + f"""
+
{scu.fmt_note(dec_rcue.rcue.moy_rcue)}
+
{ + _gen_but_select("code_rcue_"+str(niveau.id), + dec_rcue.codes, + dec_rcue.code_valide, + disabled=True, klass="manual" + ) + }
+
""" + ) + H.append("
") # but_annee + return "\n".join(H) + + +def _gen_but_select( + name: str, + codes: list[str], + code_valide: str, + disabled: bool = False, + klass: str = "", +) -> str: + "Le menu html select avec les codes" + h = "\n".join( + [ + f"""""" + for code in codes + ] + ) + return f""" + """ + + +def _gen_but_niveau_ue( + ue: UniteEns, moy_ue: float, dec_ue: DecisionsProposeesUE, disabled=False +): + return f"""
+
{ue.acronyme}
+
{scu.fmt_note(moy_ue)}
+
{ + _gen_but_select("code_ue_"+str(ue.id), + dec_ue.codes, + dec_ue.code_valide, disabled=disabled + ) + }
+
""" + + +# +def infos_fiche_etud_html(etudid: int) -> str: + """Section html pour fiche etudiant + provisoire pour BUT 2022 + """ + etud: Identite = Identite.query.get_or_404(etudid) + inscriptions = ( + FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) + .filter( + FormSemestreInscription.etudid == etud.id, + ) + .order_by(FormSemestre.date_debut) + ) + formsemestres_but = [ + i.formsemestre for i in inscriptions if i.formsemestre.formation.is_apc() + ] + if len(formsemestres_but) == 0: + return "" + + # temporaire quick & dirty: affiche le dernier + try: + deca = DecisionsProposeesAnnee(etud, formsemestres_but[-1]) + if len(deca.rcues_annee) > 0: + return f"""
+ {show_etud(deca, read_only=True)} +
+ """ + except ScoValueError: + pass + + return "" diff --git a/app/comp/moy_ue.py b/app/comp/moy_ue.py index 2d337a3d..5f432387 100644 --- a/app/comp/moy_ue.py +++ b/app/comp/moy_ue.py @@ -496,17 +496,26 @@ def compute_malus( """ ues_idx = [ue.id for ue in ues] malus = pd.DataFrame(index=modimpl_inscr_df.index, columns=ues_idx, dtype=float) + if len(sem_modimpl_moys.flat) == 0: # vide + return malus + if len(sem_modimpl_moys.shape) > 2: + # BUT: ne retient que la 1er composante du malus qui est scalaire + # au sens ou chaque note de malus n'affecte que la moyenne de l'UE + # de rattachement de son module. + sem_modimpl_moys_scalar = sem_modimpl_moys[:, :, 0] + else: # classic + sem_modimpl_moys_scalar = sem_modimpl_moys for ue in ues: if ue.type != UE_SPORT: modimpl_mask = np.array( [ (m.module.module_type == ModuleType.MALUS) - and (m.module.ue.id == ue.id) + and (m.module.ue.id == ue.id) # UE de rattachement for m in formsemestre.modimpls_sorted ] ) if len(modimpl_mask): - malus_moys = sem_modimpl_moys[:, modimpl_mask].sum(axis=1) + malus_moys = sem_modimpl_moys_scalar[:, modimpl_mask].sum(axis=1) malus[ue.id] = malus_moys malus.fillna(0.0, inplace=True) diff --git a/app/comp/res_common.py b/app/comp/res_common.py index 86230f06..98b5a9c6 100644 --- a/app/comp/res_common.py +++ b/app/comp/res_common.py @@ -399,7 +399,7 @@ class ResultatsSemestre(ResultatsCache): # --- TABLEAU RECAP def get_table_recap( - self, convert_values=False, include_evaluations=False, modejury=False + self, convert_values=False, include_evaluations=False, mode_jury=False ): """Result: tuple avec - rows: liste de dicts { column_id : value } @@ -550,7 +550,7 @@ class ResultatsSemestre(ResultatsCache): titles_bot[ f"_{col_id}_target_attrs" ] = f"""title="{ue.titre} S{ue.semestre_idx or '?'}" """ - if modejury: + if mode_jury: # pas d'autre colonnes de résultats continue # Bonus (sport) dans cette UE ? @@ -650,7 +650,17 @@ class ResultatsSemestre(ResultatsCache): elif nb_ues_validables < len(ues_sans_bonus): row["_ues_validables_class"] += " moy_inf" row["_ues_validables_order"] = nb_ues_validables # pour tri - if modejury: + if mode_jury and self.validations: + dec_sem = self.validations.decisions_jury.get(etudid) + jury_code_sem = dec_sem["code"] if dec_sem else "" + idx = add_cell( + row, + "jury_code_sem", + "Jury", + jury_code_sem, + "jury_code_sem", + 1000, + ) idx = add_cell( row, "jury_link", @@ -660,7 +670,7 @@ class ResultatsSemestre(ResultatsCache): ) }">saisir décision""", "col_jury_link", - 1000, + idx, ) rows.append(row) diff --git a/app/comp/res_compat.py b/app/comp/res_compat.py index 5ac18ff4..d48c727d 100644 --- a/app/comp/res_compat.py +++ b/app/comp/res_compat.py @@ -54,6 +54,7 @@ class NotesTableCompat(ResultatsSemestre): self.ue_rangs_by_group = {} # { ue_id : {group_id : (Series, Series)}} self.expr_diagnostics = "" self.parcours = self.formsemestre.formation.get_parcours() + self._modimpls_dict_by_ue = {} # local cache def get_inscrits(self, include_demdef=True, order_by=False) -> list[Identite]: """Liste des étudiants inscrits @@ -145,6 +146,10 @@ class NotesTableCompat(ResultatsSemestre): """Liste des modules pour une UE (ou toutes si ue_id==None), triés par numéros (selon le type de formation) """ + # cached ? + modimpls_dict = self._modimpls_dict_by_ue.get(ue_id) + if modimpls_dict: + return modimpls_dict modimpls_dict = [] for modimpl in self.formsemestre.modimpls_sorted: if (ue_id is None) or (modimpl.module.ue.id == ue_id): @@ -152,6 +157,7 @@ class NotesTableCompat(ResultatsSemestre): # compat ScoDoc < 9.2: ajoute matières d["mat"] = modimpl.module.matiere.to_dict() modimpls_dict.append(d) + self._modimpls_dict_by_ue[ue_id] = modimpls_dict return modimpls_dict def compute_rangs(self): diff --git a/app/forms/main/config_logos.py b/app/forms/main/config_logos.py index 0d0ac0d5..b35ac34e 100644 --- a/app/forms/main/config_logos.py +++ b/app/forms/main/config_logos.py @@ -43,7 +43,7 @@ from app.scodoc import sco_logos, html_sco_header from app.scodoc import sco_utils as scu from app.scodoc.sco_config_actions import LogoInsert - +from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.sco_logos import find_logo @@ -108,6 +108,8 @@ def dept_key_to_id(dept_key): def logo_name_validator(message=None): def validate_logo_name(form, field): name = field.data if field.data else "" + if "." in name: + raise ValidationError(message) if not scu.is_valid_filename(name): raise ValidationError(message) @@ -199,9 +201,12 @@ class LogoForm(FlaskForm): def __init__(self, *args, **kwargs): kwargs["meta"] = {"csrf": False} super().__init__(*args, **kwargs) - self.logo = find_logo( + logo = find_logo( logoname=self.logo_id.data, dept_id=dept_key_to_id(self.dept_key.data) - ).select() + ) + if logo is None: + raise ScoValueError("logo introuvable") + self.logo = logo.select() self.description = None self.titre = None self.can_delete = True diff --git a/app/models/but_refcomp.py b/app/models/but_refcomp.py index a2d25a24..3aff0e9c 100644 --- a/app/models/but_refcomp.py +++ b/app/models/but_refcomp.py @@ -255,6 +255,7 @@ class ApcCompetence(db.Model, XMLModel): return f"" def to_dict(self): + "repr dict recursive sur situations, composantes, niveaux" return { "id_orebut": self.id_orebut, "titre": self.titre, @@ -268,6 +269,16 @@ class ApcCompetence(db.Model, XMLModel): "niveaux": {x.annee: x.to_dict() for x in self.niveaux}, } + def to_dict_bul(self) -> dict: + "dict court pour bulletins" + return { + "id_orebut": self.id_orebut, + "titre": self.titre, + "titre_long": self.titre_long, + "couleur": self.couleur, + "numero": self.numero, + } + class ApcSituationPro(db.Model, XMLModel): "Situation professionnelle" @@ -341,6 +352,7 @@ class ApcNiveau(db.Model, XMLModel): self.annee!r} {self.competence!r}>""" def to_dict(self): + "as a dict, recursif sur les AC" return { "libelle": self.libelle, "annee": self.annee, @@ -348,6 +360,15 @@ class ApcNiveau(db.Model, XMLModel): "app_critiques": {x.code: x.to_dict() for x in self.app_critiques}, } + def to_dict_bul(self): + "dict pour bulletins: indique la compétence, pas les ACs (pour l'instant ?)" + return { + "libelle": self.libelle, + "annee": self.annee, + "ordre": self.ordre, + "competence": self.competence.to_dict_bul(), + } + @classmethod def niveaux_annee_de_parcours( cls, @@ -430,6 +451,7 @@ class ApcAppCritique(db.Model, XMLModel): if competence is not None: query = query.filter(ApcNiveau.competence == competence) return query +<<<<<<< HEAD def __init__(self, id, niveau_id, code, libelle, modules): self.id = id @@ -437,6 +459,8 @@ class ApcAppCritique(db.Model, XMLModel): self.code = code self.libelle = libelle self.modules = modules +======= +>>>>>>> 7c340c798ad59c41653efc83bfd079f11fce1938 def to_dict(self) -> dict: return {"libelle": self.libelle} @@ -523,11 +547,14 @@ class ApcAnneeParcours(db.Model, XMLModel): ) ordre = db.Column(db.Integer) "numéro de l'année: 1, 2, 3" +<<<<<<< HEAD def __init__(self, id, parcours_id, ordre): self.id = id self.parcours_id = parcours_id self.ordre = ordre +======= +>>>>>>> 7c340c798ad59c41653efc83bfd079f11fce1938 def __repr__(self): return f"<{self.__class__.__name__} ordre={self.ordre!r} parcours={self.parcours.code!r}>" diff --git a/app/models/but_validations.py b/app/models/but_validations.py index 651e6fc2..9d42c14d 100644 --- a/app/models/but_validations.py +++ b/app/models/but_validations.py @@ -13,8 +13,10 @@ from app.models import CODE_STR_LEN from app.models.but_refcomp import ApcNiveau from app.models.etudiants import Identite from app.models.ues import UniteEns +from app.models.formations import Formation from app.models.formsemestre import FormSemestre from app.scodoc import sco_codes_parcours as sco_codes +from app.scodoc import sco_utils as scu class ApcValidationRCUE(db.Model): @@ -41,6 +43,7 @@ class ApcValidationRCUE(db.Model): formsemestre_id = db.Column( db.Integer, db.ForeignKey("notes_formsemestre.id"), index=True, nullable=True ) + "formsemestre pair du RCUE" # Les deux UE associées à ce niveau: ue1_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False) ue2_id = db.Column(db.Integer, db.ForeignKey("notes_ue.id"), nullable=False) @@ -63,6 +66,10 @@ class ApcValidationRCUE(db.Model): # Par convention, il est donné par la seconde UE return self.ue2.niveau_competence + def to_dict_bul(self) -> dict: + "Export dict pour bulletins" + return {"code": self.code, "niveau": self.niveau().to_dict_bul()} + # Attention: ce n'est pas un modèle mais une classe ordinaire: class RegroupementCoherentUE: @@ -79,6 +86,7 @@ class RegroupementCoherentUE: ue_1: UniteEns, formsemestre_2: FormSemestre, ue_2: UniteEns, + inscription_etat: str, ): from app.comp import res_sem from app.comp.res_but import ResultatsSemestreBUT @@ -104,6 +112,11 @@ class RegroupementCoherentUE: "semestre pair" self.ue_2 = ue_2 # Stocke les moyennes d'UE + if inscription_etat != scu.INSCRIT: + self.moy_rcue = None + self.moy_ue_1 = self.moy_ue_2 = "-" + self.moy_ue_1_val = self.moy_ue_2_val = 0.0 + return res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre_1) if ue_1.id in res.etud_moy_ue and etud.id in res.etud_moy_ue[ue_1.id]: self.moy_ue_1 = res.etud_moy_ue[ue_1.id][etud.id] @@ -190,14 +203,15 @@ class RegroupementCoherentUE: "Si ce RCUE est ADM, CMP ou ADJ, la validation. Sinon, None" validation = self.query_validations().first() if (validation is not None) and ( - validation.code in {sco_codes.ADM, sco_codes.ADJ, sco_codes.CMP} + validation.code in sco_codes.CODES_RCUE_VALIDES ): return validation return None +# unused def find_rcues( - formsemestre: FormSemestre, ue: UniteEns, etud: Identite + formsemestre: FormSemestre, ue: UniteEns, etud: Identite, inscription_etat: str ) -> list[RegroupementCoherentUE]: """Les RCUE (niveau de compétence) à considérer pour cet étudiant dans ce semestre pour cette UE. @@ -245,7 +259,9 @@ def find_rcues( other_ue = UniteEns.query.get(ue_id) other_formsemestre = FormSemestre.query.get(formsemestre_id) rcues.append( - RegroupementCoherentUE(etud, formsemestre, ue, other_formsemestre, other_ue) + RegroupementCoherentUE( + etud, formsemestre, ue, other_formsemestre, other_ue, inscription_etat + ) ) # safety check: 1 seul niveau de comp. concerné: assert len({rcue.ue_1.niveau_competence_id for rcue in rcues}) == 1 @@ -280,3 +296,45 @@ class ApcValidationAnnee(db.Model): def __repr__(self): return f"<{self.__class__.__name__} {self.id} {self.etud} BUT{self.ordre}/{self.annee_scolaire}:{self.code!r}>" + + def to_dict_bul(self) -> dict: + "dict pour bulletins" + return { + "annee_scolaire": self.annee_scolaire, + "date": self.date.isoformat(), + "code": self.code, + "ordre": self.ordre, + } + + +def dict_decision_jury(etud: Identite, formsemestre: FormSemestre) -> dict: + """ + Un dict avec les décisions de jury BUT enregistrées. + Ne reprend pas les décisions d'UE, non spécifiques au BUT. + """ + decisions = {} + # --- RCUEs: seulement sur semestres pairs XXX à améliorer + if formsemestre.semestre_id % 2 == 0: + # validations émises depuis ce formsemestre: + validations_rcues = ApcValidationRCUE.query.filter_by( + etudid=etud.id, formsemestre_id=formsemestre.id + ) + decisions["decision_rcue"] = [v.to_dict_bul() for v in validations_rcues] + else: + decisions["decision_rcue"] = [] + # --- Année: prend la validation pour l'année scolaire de ce semestre + validation = ( + ApcValidationAnnee.query.filter_by( + etudid=etud.id, + annee_scolaire=formsemestre.annee_scolaire(), + ) + .join(ApcValidationAnnee.formsemestre) + .join(FormSemestre.formation) + .filter(Formation.formation_code == formsemestre.formation.formation_code) + .first() + ) + if validation: + decisions["decision_annee"] = validation.to_dict_bul() + else: + decisions["decision_annee"] = None + return decisions diff --git a/app/models/config.py b/app/models/config.py index 817125c3..cb65d519 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -26,6 +26,7 @@ from app.scodoc.sco_codes_parcours import ( PASD, PAS1NCI, RAT, + RED, ) CODES_SCODOC_TO_APO = { @@ -46,6 +47,7 @@ CODES_SCODOC_TO_APO = { PASD: "PASD", PAS1NCI: "PAS1NCI", RAT: "ATT", + RED: "RED", "NOTES_FMT": "%3.2f", } diff --git a/app/models/etudiants.py b/app/models/etudiants.py index 21b3a638..30333a6b 100644 --- a/app/models/etudiants.py +++ b/app/models/etudiants.py @@ -136,9 +136,9 @@ class Identite(db.Model): "clé pour tris par ordre alphabétique" return ( scu.sanitize_string( - scu.suppress_accents(self.nom_usuel or self.nom or "").lower() - ), - scu.sanitize_string(scu.suppress_accents(self.prenom or "").lower()), + self.nom_usuel or self.nom or "", remove_spaces=False + ).lower(), + scu.sanitize_string(self.prenom or "", remove_spaces=False).lower(), ) def get_first_email(self, field="email") -> str: @@ -205,6 +205,19 @@ class Identite(db.Model): d.update(adresse.to_dict(convert_nulls_to_str=True)) return d + def inscriptions(self) -> list["FormSemestreInscription"]: + "Liste des inscriptions à des formsemestres, triée, la plus récente en tête" + from app.models.formsemestre import FormSemestre, FormSemestreInscription + + return ( + FormSemestreInscription.query.join(FormSemestreInscription.formsemestre) + .filter( + FormSemestreInscription.etudid == self.id, + ) + .order_by(desc(FormSemestre.date_debut)) + .all() + ) + def inscription_courante(self): """La première inscription à un formsemestre _actuellement_ en cours. None s'il n'y en a pas (ou plus, ou pas encore). @@ -216,7 +229,7 @@ class Identite(db.Model): ] return r[0] if r else None - def inscriptions_courantes(self) -> list: # -> list[FormSemestreInscription]: + def inscriptions_courantes(self) -> list["FormSemestreInscription"]: """Liste des inscriptions à des semestres _courants_ (il est rare qu'il y en ai plus d'une, mais c'est possible). Triées par date de début de semestre décroissante (le plus récent en premier). @@ -244,18 +257,6 @@ class Identite(db.Model): ] return r[0] if r else None - def inscription_etat(self, formsemestre_id): - """État de l'inscription de cet étudiant au semestre: - False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF - """ - # voir si ce n'est pas trop lent: - ins = models.FormSemestreInscription.query.filter_by( - etudid=self.id, formsemestre_id=formsemestre_id - ).first() - if ins: - return ins.etat - return False - def inscription_descr(self) -> dict: """Description de l'état d'inscription""" inscription_courante = self.inscription_courante() @@ -294,6 +295,18 @@ class Identite(db.Model): "situation": situation, } + def inscription_etat(self, formsemestre_id): + """État de l'inscription de cet étudiant au semestre: + False si pas inscrit, ou scu.INSCRIT, DEMISSION, DEF + """ + # voir si ce n'est pas trop lent: + ins = models.FormSemestreInscription.query.filter_by( + etudid=self.id, formsemestre_id=formsemestre_id + ).first() + if ins: + return ins.etat + return False + def descr_situation_etud(self) -> str: """Chaîne décrivant la situation _actuelle_ de l'étudiant. Exemple: @@ -365,6 +378,15 @@ class Identite(db.Model): return situation + def etat_civil_pv(self, line_sep="\n") -> str: + """Présentation, pour PV jury + M. Pierre Dupont + n° 12345678 + né(e) le 7/06/1974 + à Paris + """ + return f"""{self.nomprenom}{line_sep}n°{self.code_nip or ""}{line_sep}né{self.e} le {self.date_naissance.strftime("%d/%m/%Y") if self.date_naissance else ""}{line_sep}à {self.lieu_naissance or ""}""" + def photo_html(self, title=None, size="small") -> str: """HTML img tag for the photo, either in small size (h90) or original size (size=="orig") diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py index 5ce432b4..bafb1c80 100644 --- a/app/models/formsemestre.py +++ b/app/models/formsemestre.py @@ -141,7 +141,7 @@ class FormSemestre(db.Model): def __repr__(self): return f"<{self.__class__.__name__} {self.id} {self.titre_num()}>" - def to_dict(self): + def to_dict(self, convert_parcours=False): "dict (compatible ScoDoc7)" d = dict(self.__dict__) d.pop("_sa_instance_state", None) @@ -160,6 +160,8 @@ class FormSemestre(db.Model): d["date_fin"] = d["date_fin_iso"] = "" d["responsables"] = [u.id for u in self.responsables] d["titre_formation"] = self.titre_formation() + if convert_parcours: + d["parcours"] = [p.to_dict() for p in self.parcours] return d def to_dict_api(self): @@ -507,6 +509,19 @@ class FormSemestre(db.Model): etudid, self.date_debut.isoformat(), self.date_fin.isoformat() ) + def get_codes_apogee(self, category=None) -> set[str]: + """Les codes Apogée (codés en base comme "VRT1,VRT2") + category: None: tous, "etapes": étapes associées, "sem: code semestre", "annee": code annuel + """ + codes = set() + if category is None or category == "etapes": + codes |= {e.etape_apo for e in self.etapes if e} + if (category is None or category == "sem") and self.elt_sem_apo: + codes |= {x.strip() for x in self.elt_sem_apo.split(",") if x} + if (category is None or category == "annee") and self.elt_annee_apo: + codes |= {x.strip() for x in self.elt_annee_apo.split(",") if x} + return codes + def get_inscrits(self, include_demdef=False, order=False) -> list[Identite]: """Liste des étudiants inscrits à ce semestre Si include_demdef, tous les étudiants, avec les démissionnaires diff --git a/app/models/modules.py b/app/models/modules.py index 5ab4466b..b3655772 100644 --- a/app/models/modules.py +++ b/app/models/modules.py @@ -175,6 +175,12 @@ class Module(db.Model): # Liste seulement les coefs définis: return [(c.ue, c.coef) for c in self.get_ue_coefs_sorted()] + def get_codes_apogee(self) -> set[str]: + """Les codes Apogée (codés en base comme "VRT1,VRT2")""" + if self.code_apogee: + return {x.strip() for x in self.code_apogee.split(",") if x} + return set() + class ModuleUECoef(db.Model): """Coefficients des modules vers les UE (APC, BUT) diff --git a/app/models/ues.py b/app/models/ues.py index 52cd3788..450482bc 100644 --- a/app/models/ues.py +++ b/app/models/ues.py @@ -120,3 +120,9 @@ class UniteEns(db.Model): (Module.module_type != scu.ModuleType.SAE), (Module.module_type != scu.ModuleType.RESSOURCE), ).all() + + def get_codes_apogee(self) -> set[str]: + """Les codes Apogée (codés en base comme "VRT1,VRT2")""" + if self.code_apogee: + return {x.strip() for x in self.code_apogee.split(",") if x} + return set() diff --git a/app/scodoc/gen_tables.py b/app/scodoc/gen_tables.py index 1c708ca5..0fab06ea 100644 --- a/app/scodoc/gen_tables.py +++ b/app/scodoc/gen_tables.py @@ -45,7 +45,7 @@ import random from collections import OrderedDict from xml.etree import ElementTree import json - +from openpyxl.utils import get_column_letter from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Frame, PageBreak from reportlab.platypus import Table, TableStyle, Image, KeepInFrame from reportlab.lib.colors import Color @@ -127,6 +127,8 @@ class GenTable(object): filename="table", # filename, without extension xls_sheet_name="feuille", xls_before_table=[], # liste de cellules a placer avant la table + xls_style_base=None, # style excel pour les cellules + xls_columns_width=None, # { col_id : largeur en "pixels excel" } pdf_title="", # au dessus du tableau en pdf pdf_table_style=None, pdf_col_widths=None, @@ -151,6 +153,8 @@ class GenTable(object): self.page_title = page_title self.pdf_link = pdf_link self.xls_link = xls_link + self.xls_style_base = xls_style_base + self.xls_columns_width = xls_columns_width or {} self.xml_link = xml_link # HTML parameters: if not table_id: # random id @@ -495,7 +499,8 @@ class GenTable(object): sheet = wb.create_sheet(sheet_name=self.xls_sheet_name) sheet.rows += self.xls_before_table style_bold = sco_excel.excel_make_style(bold=True) - style_base = sco_excel.excel_make_style() + style_base = self.xls_style_base or sco_excel.excel_make_style() + sheet.append_row(sheet.make_row(self.get_titles_list(), style_bold)) for line in self.get_data_list(xls_mode=True): sheet.append_row(sheet.make_row(line, style_base)) @@ -505,6 +510,16 @@ class GenTable(object): if self.origin: sheet.append_blank_row() # empty line sheet.append_single_cell_row(self.origin, style_base) + # Largeurs des colonnes + columns_ids = list(self.columns_ids) + for col_id, width in self.xls_columns_width.items(): + try: + idx = columns_ids.index(col_id) + col = get_column_letter(idx + 1) + sheet.set_column_dimension_width(col, width) + except ValueError: + pass + if wb is None: return sheet.generate() diff --git a/app/scodoc/sco_apogee_csv.py b/app/scodoc/sco_apogee_csv.py index 7fe9501d..e309beda 100644 --- a/app/scodoc/sco_apogee_csv.py +++ b/app/scodoc/sco_apogee_csv.py @@ -258,11 +258,16 @@ class ApoEtud(dict): self["nom"] = nom self["prenom"] = prenom self["naissance"] = naissance - self.cols = cols # { col_id : value } colid = 'apoL_c0001' + self.cols = cols + "{ col_id : value } colid = 'apoL_c0001'" + self.col_elts = {} + "{'V1RT': {'R': 'ADM', 'J': '', 'B': 20, 'N': '12.14'}}" self.new_cols = {} # { col_id : value to record in csv } - self.etud = None # etud ScoDoc + self.etud: Identite = None + "etudiant ScoDoc associé" self.etat = None # ETUD_OK, ... - self.is_NAR = False # set to True si NARé dans un semestre + self.is_NAR = False + "True si NARé dans un semestre" self.log = [] self.has_logged_no_decision = False self.export_res_etape = export_res_etape # VET, ... @@ -276,7 +281,7 @@ class ApoEtud(dict): ) def __repr__(self): - return "ApoEtud( nom='%s', nip='%s' )" % (self["nom"], self["nip"]) + return f"""ApoEtud( nom='{self["nom"]}', nip='{self["nip"]}' )""" def lookup_scodoc(self, etape_formsemestre_ids): """Cherche l'étudiant ScoDoc associé à cet étudiant Apogée. @@ -284,6 +289,10 @@ class ApoEtud(dict): met .etud à None. Sinon, cherche le semestre, et met l'état à ETUD_OK ou ETUD_NON_INSCRIT. """ + + # futur: #WIP + # etud: Identite = Identite.query.filter_by(code_nip=self["nip"]).first() + # self.etud = etud etuds = sco_etud.get_etud_info(code_nip=self["nip"], filled=True) if not etuds: # pas dans ScoDoc @@ -291,13 +300,16 @@ class ApoEtud(dict): self.log.append("non inscrit dans ScoDoc") self.etat = ETUD_ORPHELIN else: + # futur: #WIP + # formsemestre_ids = { + # ins.formsemestre_id for ins in etud.formsemestre_inscriptions + # } + # in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids) self.etud = etuds[0] # cherche le semestre ScoDoc correspondant à l'un de ceux de l'etape: formsemestre_ids = {s["formsemestre_id"] for s in self.etud["sems"]} - self.in_formsemestre_ids = formsemestre_ids.intersection( - etape_formsemestre_ids - ) - if not self.in_formsemestre_ids: + in_formsemestre_ids = formsemestre_ids.intersection(etape_formsemestre_ids) + if not in_formsemestre_ids: self.log.append( "connu dans ScoDoc, mais pas inscrit dans un semestre de cette étape" ) @@ -305,7 +317,7 @@ class ApoEtud(dict): else: self.etat = ETUD_OK - def associate_sco(self, apo_data): + def associate_sco(self, apo_data: "ApoData"): """Recherche les valeurs des éléments Apogée pour cet étudiant Set .new_cols """ @@ -327,7 +339,7 @@ class ApoEtud(dict): cur_sem, autre_sem = self.etud_semestres_de_etape(apo_data) for sem in apo_data.sems_etape: el = self.search_elt_in_sem(code, sem, cur_sem, autre_sem) - if el != None: + if el is not None: sco_elts[code] = el break self.col_elts[code] = el @@ -338,15 +350,15 @@ class ApoEtud(dict): self.new_cols[col_id] = sco_elts[code][ apo_data.cols[col_id]["Type Rés."] ] - except KeyError: + except KeyError as exc: log( - "associate_sco: missing key, etud=%s\ncode='%s'\netape='%s'" - % (self, code, apo_data.etape_apogee) + f"associate_sco: missing key, etud={self}\ncode='{code}'\netape='{apo_data.etape_apogee}'" ) raise ScoValueError( - """L'élément %s n'a pas de résultat: peut-être une erreur dans les codes sur le programme pédagogique (vérifier qu'il est bien associé à une UE ou semestre)?""" - % code - ) + f"""L'élément {code} n'a pas de résultat: peut-être une erreur + dans les codes sur le programme pédagogique + (vérifier qu'il est bien associé à une UE ou semestre)?""" + ) from exc # recopie les 4 premieres colonnes (nom, ..., naissance): for col_id in apo_data.col_ids[:4]: self.new_cols[col_id] = self.cols[col_id] @@ -356,7 +368,7 @@ class ApoEtud(dict): # codes = set([apo_data.cols[col_id].code for col_id in apo_data.col_ids]) # return codes - set(sco_elts) - def search_elt_in_sem(self, code, sem, cur_sem, autre_sem): + def search_elt_in_sem(self, code, sem, cur_sem, autre_sem) -> dict: """ VET code jury etape ELP élément pédagogique: UE, module @@ -820,10 +832,8 @@ class ApoData(object): elts[col["Code"]] = ApoElt([col]) return elts # { code apo : ApoElt } - def apo_read_etuds(self, f): - """Lecture des etudiants (et resultats) du fichier CSV Apogée - -> liste de dicts - """ + def apo_read_etuds(self, f) -> list[ApoEtud]: + """Lecture des etudiants (et resultats) du fichier CSV Apogée""" L = [] while True: line = f.readline() @@ -958,36 +968,38 @@ class ApoData(object): """ codes_by_sem = {} for sem in self.sems_etape: + formsemestre: FormSemestre = FormSemestre.query.get_or_404( + sem["formsemestre_id"] + ) + # L'ensemble des codes apo associés aux éléments: + codes_semestre = formsemestre.get_codes_apogee() + codes_modules = set().union( + *[ + modimpl.module.get_codes_apogee() + for modimpl in formsemestre.modimpls + ] + ) + codes_ues = set().union( + *[ + ue.get_codes_apogee() + for ue in formsemestre.query_ues(with_sport=True) + ] + ) s = set() codes_by_sem[sem["formsemestre_id"]] = s for col_id in self.col_ids[4:]: code = self.cols[col_id]["Code"] # 'V1RT' - # associé à l'étape, l'année ou les semestre: - if ( - sco_formsemestre.sem_has_etape(sem, code) - or (code in {x.strip() for x in sem["elt_sem_apo"].split(",")}) - or (code in {x.strip() for x in sem["elt_annee_apo"].split(",")}) - ): + # associé à l'étape, l'année ou le semestre: + if code in codes_semestre: s.add(code) continue # associé à une UE: - formsemestre = FormSemestre.query.get_or_404(sem["formsemestre_id"]) - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - for ue in nt.get_ues_stat_dict(): - if ue["code_apogee"]: - codes = {x.strip() for x in ue["code_apogee"].split(",")} - if code in codes: - s.add(code) - continue + if code in codes_ues: + s.add(code) + continue # associé à un module: - modimpls = nt.get_modimpls_dict() - for modimpl in modimpls: - module = modimpl["module"] - if module["code_apogee"]: - codes = {x.strip() for x in module["code_apogee"].split(",")} - if code in codes: - s.add(code) - continue + if code in codes_modules: + s.add(code) # log('codes_by_sem=%s' % pprint.pformat(codes_by_sem)) return codes_by_sem diff --git a/app/scodoc/sco_archives.py b/app/scodoc/sco_archives.py index 7869bb18..26ce9f8c 100644 --- a/app/scodoc/sco_archives.py +++ b/app/scodoc/sco_archives.py @@ -47,6 +47,7 @@ qui est une description (humaine, format libre) de l'archive. """ +import chardet import datetime import glob import json @@ -55,7 +56,7 @@ import os import re import shutil import time -import chardet +from typing import Union import flask from flask import g, request @@ -232,14 +233,17 @@ class BaseArchiver(object): os.mkdir(archive_id) # if exists, raises an OSError finally: scu.GSL.release() - self.store(archive_id, "_description.txt", description.encode("utf-8")) + self.store(archive_id, "_description.txt", description) return archive_id - def store(self, archive_id: str, filename: str, data: bytes): + def store(self, archive_id: str, filename: str, data: Union[str, bytes]): """Store data in archive, under given filename. Filename may be modified (sanitized): return used filename The file is created or replaced. + data may be str or bytes """ + if isinstance(data, str): + data = data.encode(scu.SCO_ENCODING) self.initialize() filename = scu.sanitize_filename(filename) log("storing %s (%d bytes) in %s" % (filename, len(data), archive_id)) @@ -350,13 +354,11 @@ def do_formsemestre_archive( html_sco_header.sco_footer(), ] ) - data = data.encode(scu.SCO_ENCODING) PVArchive.store(archive_id, "Tableau_moyennes.html", data) # Bulletins en JSON data = gen_formsemestre_recapcomplet_json(formsemestre_id, xml_with_decisions=True) data_js = json.dumps(data, indent=1, cls=scu.ScoDocJSONEncoder) - data_js = data_js.encode(scu.SCO_ENCODING) if data: PVArchive.store(archive_id, "Bulletins.json", data_js) # Decisions de jury, en XLS diff --git a/app/scodoc/sco_bulletins.py b/app/scodoc/sco_bulletins.py index daf4a941..4a82674e 100644 --- a/app/scodoc/sco_bulletins.py +++ b/app/scodoc/sco_bulletins.py @@ -58,7 +58,6 @@ from app.scodoc import sco_formations from app.scodoc import sco_formsemestre from app.scodoc import sco_groups from app.scodoc import sco_permissions_check -from app.scodoc import sco_photos from app.scodoc import sco_preferences from app.scodoc import sco_pvjury from app.scodoc import sco_users @@ -66,15 +65,6 @@ import app.scodoc.sco_utils as scu from app.scodoc.sco_utils import ModuleType, fmt_note import app.scodoc.notesdb as ndb -# ----- CLASSES DE BULLETINS DE NOTES -from app.scodoc import sco_bulletins_standard -from app.scodoc import sco_bulletins_legacy - -# import sco_bulletins_example # format exemple (à désactiver en production) - -# ... ajouter ici vos modules ... -from app.scodoc import sco_bulletins_ucac # format expérimental UCAC Cameroun - def get_formsemestre_bulletin_etud_json( formsemestre: FormSemestre, diff --git a/app/scodoc/sco_bulletins_json.py b/app/scodoc/sco_bulletins_json.py index 78425028..c48c0d43 100644 --- a/app/scodoc/sco_bulletins_json.py +++ b/app/scodoc/sco_bulletins_json.py @@ -92,7 +92,6 @@ def formsemestre_bulletinetud_published_dict( nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) d = {"type": "classic", "version": "0"} - if (not sem["bul_hide_xml"]) or force_publishing: published = True else: @@ -134,6 +133,7 @@ def formsemestre_bulletinetud_published_dict( ) d["etudiant"]["sexe"] = d["etudiant"]["civilite"] # backward compat for our clients # Disponible pour publication ? + d["publie"] = published if not published: return d # stop ! @@ -364,8 +364,35 @@ def formsemestre_bulletinetud_published_dict( return d -def dict_decision_jury(etudid, formsemestre_id, with_decisions=False): - "dict avec decision pour bulletins json" +def dict_decision_jury(etudid, formsemestre_id, with_decisions=False) -> dict: + """dict avec decision pour bulletins json + - decision : décision semestre + - decision_ue : list des décisions UE + - situation + + with_decision donne les décision même si bul_show_decision est faux. + + Exemple: + { + 'autorisation_inscription': [{'semestre_id': 4}], + 'decision': {'code': 'ADM', + 'compense_formsemestre_id': None, + 'date': '2022-01-21', + 'etat': 'I'}, + 'decision_ue': [ + { + 'acronyme': 'UE31', + 'code': 'ADM', + 'ects': 16.0, + 'numero': 23, + 'titre': 'Approfondissement métiers', + 'ue_id': 1787 + }, + ... + ], + 'situation': 'Inscrit le 25/06/2021. Décision jury: Validé. UE acquises: ' + 'UE31, UE32. Diplôme obtenu.'} + """ from app.scodoc import sco_bulletins d = {} diff --git a/app/scodoc/sco_bulletins_standard.py b/app/scodoc/sco_bulletins_standard.py index 0277ee8c..e1e85372 100644 --- a/app/scodoc/sco_bulletins_standard.py +++ b/app/scodoc/sco_bulletins_standard.py @@ -441,13 +441,13 @@ class BulletinGeneratorStandard(sco_bulletins_generator.BulletinGenerator): t = { "titre": ue["acronyme"] + " " + (ue["titre"] or ""), "_titre_html": plusminus - + ue["acronyme"] + + (ue["acronyme"] or "") + " " - + ue["titre"] + + (ue["titre"] or "") + ' ' - + ue["ue_descr_txt"] + + (ue["ue_descr_txt"] or "") + "", - "_titre_help": ue["ue_descr_txt"], + "_titre_help": ue["ue_descr_txt"] or "", "_titre_colspan": 2, "module": ue_descr, "note": ue["moy_ue_txt"], diff --git a/app/scodoc/sco_codes_parcours.py b/app/scodoc/sco_codes_parcours.py index 6b4dd946..dc59aeb2 100644 --- a/app/scodoc/sco_codes_parcours.py +++ b/app/scodoc/sco_codes_parcours.py @@ -189,7 +189,7 @@ CODES_SEM_ATTENTES = {ATT: True, ATB: True, ATJ: True} # semestre en attente CODES_SEM_REO = {NAR: 1} # reorientation CODES_UE_VALIDES = {ADM: True, CMP: True, ADJ: True} # UE validée - +CODES_RCUE_VALIDES = CODES_UE_VALIDES # Niveau RCUE validé # Pour le BUT: CODES_ANNEE_ARRET = {DEF, DEM, ABAN, ABL} CODES_RCUE = {ADM, AJ, CMP} @@ -201,6 +201,7 @@ BUT_CODES_PASSAGE = { ADJ, PASD, PAS1NCI, + ATJ, } diff --git a/app/scodoc/sco_edit_module.py b/app/scodoc/sco_edit_module.py index 0e1a75d6..b94a6017 100644 --- a/app/scodoc/sco_edit_module.py +++ b/app/scodoc/sco_edit_module.py @@ -683,7 +683,7 @@ def module_edit( ] # Choix des Apprentissages Critiques if ue is not None: - annee = f"BUT{orig_semestre_idx//2 + 1}" + annee = f"BUT{(orig_semestre_idx+1)//2}" app_critiques = ApcAppCritique.app_critiques_ref_comp(ref_comp, annee) descr += ( [ diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py index 016d8880..def072c3 100644 --- a/app/scodoc/sco_edit_ue.py +++ b/app/scodoc/sco_edit_ue.py @@ -52,7 +52,6 @@ from app.scodoc.sco_exceptions import ( ) from app.scodoc import html_sco_header -from app.scodoc import sco_cache from app.scodoc import sco_codes_parcours from app.scodoc import sco_edit_apc from app.scodoc import sco_edit_matiere @@ -188,7 +187,11 @@ def do_ue_delete(ue_id, delete_validations=False, force=False): "DELETE FROM scolar_formsemestre_validation WHERE ue_id=%(ue_id)s", {"ue_id": ue.id}, ) - + # delete old formulas + ndb.SimpleQuery( + "DELETE FROM notes_formsemestre_ue_computation_expr WHERE ue_id=%(ue_id)s", + {"ue_id": ue.id}, + ) # delete all matiere in this UE mats = sco_edit_matiere.matiere_list({"ue_id": ue.id}) for mat in mats: @@ -448,7 +451,7 @@ def ue_edit(ue_id=None, create=False, formation_id=None, default_semestre_idx=No """ else: modules_div = "" diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py index 5d1b2d72..d6247207 100644 --- a/app/scodoc/sco_excel.py +++ b/app/scodoc/sco_excel.py @@ -59,7 +59,7 @@ class COLORS(Enum): LIGHT_YELLOW = "FFFFFF99" -# Un style est enregistré comme un dictionnaire qui précise la valeur d'un attributdans la liste suivante: +# Un style est enregistré comme un dictionnaire qui précise la valeur d'un attribut dans la liste suivante: # font, border, number_format, fill,... # (cf https://openpyxl.readthedocs.io/en/stable/styles.html#working-with-styles) @@ -288,7 +288,7 @@ class ScoExcelSheet: value -- contenu de la cellule (texte, numérique, booléen ou date) style -- style par défaut (dictionnaire cf. excel_make_style) de la feuille si non spécifié """ - # adapatation des valeurs si nécessaire + # adaptation des valeurs si nécessaire if value is None: value = "" elif value is True: diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py index aea7eb0a..ec4fb6da 100644 --- a/app/scodoc/sco_formsemestre_status.py +++ b/app/scodoc/sco_formsemestre_status.py @@ -1206,7 +1206,7 @@ def formsemestre_tableau_modules( ) H.append( '%s' - % (modimpl["moduleimpl_id"], mod_descr, mod.abbrev or mod.titre) + % (modimpl["moduleimpl_id"], mod_descr, mod.abbrev or mod.titre or "") ) H.append('%s' % len(mod_inscrits)) H.append( diff --git a/app/scodoc/sco_formsemestre_validation.py b/app/scodoc/sco_formsemestre_validation.py index 1f22f536..a9d13c01 100644 --- a/app/scodoc/sco_formsemestre_validation.py +++ b/app/scodoc/sco_formsemestre_validation.py @@ -35,13 +35,17 @@ from app.models.etudiants import Identite import app.scodoc.notesdb as ndb import app.scodoc.sco_utils as scu -from app import log +from app import db, log from app.comp import res_sem from app.comp.res_compat import NotesTableCompat from app.models import FormSemestre from app.models.notes import etud_has_notes_attente - +from app.models.validations import ( + ScolarAutorisationInscription, + ScolarFormSemestreValidation, +) +from app.models.but_validations import ApcValidationRCUE, ApcValidationAnnee from app.scodoc.sco_exceptions import ScoValueError from app.scodoc.scolog import logdb from app.scodoc.sco_codes_parcours import * @@ -111,7 +115,7 @@ def formsemestre_validation_etud_form( url_tableau = url_for( "notes.formsemestre_recapcomplet", scodoc_dept=g.scodoc_dept, - modejury=1, + mode_jury=1, formsemestre_id=formsemestre_id, selected_etudid=etudid, # va a la bonne ligne ) @@ -596,10 +600,12 @@ def formsemestre_recap_parcours_table( title="Bulletin de notes">{formsemestre.titre_annee()}{parcours_name} """ ) - if decision_sem: + if nt.is_apc: + H.append('BUT') + elif decision_sem: H.append('%s' % decision_sem["code"]) else: - H.append('en cours') + H.append("en cours") H.append('%s' % ass) # abs # acronymes UEs auxquelles l'étudiant est inscrit (ou capitalisé) ues = nt.get_ues_stat_dict(filter_sport=True) @@ -979,7 +985,7 @@ def do_formsemestre_validation_auto(formsemestre_id): H.append("") H.append( f"""continuer""" ) H.append(html_sco_header.sco_footer()) @@ -987,28 +993,32 @@ def do_formsemestre_validation_auto(formsemestre_id): def formsemestre_validation_suppress_etud(formsemestre_id, etudid): - """Suppression des decisions de jury pour un etudiant.""" - log("formsemestre_validation_suppress_etud( %s, %s)" % (formsemestre_id, etudid)) - cnx = ndb.GetDBConnexion() - cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor) - args = {"formsemestre_id": formsemestre_id, "etudid": etudid} - try: - # -- Validation du semestre et des UEs - cursor.execute( - """delete from scolar_formsemestre_validation - where etudid = %(etudid)s and formsemestre_id=%(formsemestre_id)s""", - args, - ) - # -- Autorisations d'inscription - cursor.execute( - """delete from scolar_autorisation_inscription - where etudid = %(etudid)s and origin_formsemestre_id=%(formsemestre_id)s""", - args, - ) - cnx.commit() - except: - cnx.rollback() - raise + """Suppression des décisions de jury pour un étudiant/formsemestre. + Efface toutes les décisions enregistrées concernant ce formsemestre et cet étudiant: + code semestre, UEs, autorisations d'inscription + """ + log(f"formsemestre_validation_suppress_etud( {formsemestre_id}, {etudid})") + + # Validations jury classiques (semestres, UEs, autorisations) + for v in ScolarFormSemestreValidation.query.filter_by( + etudid=etudid, formsemestre_id=formsemestre_id + ): + db.session.delete(v) + for v in ScolarAutorisationInscription.query.filter_by( + etudid=etudid, origin_formsemestre_id=formsemestre_id + ): + db.session.delete(v) + # Validations jury spécifiques BUT + for v in ApcValidationRCUE.query.filter_by( + etudid=etudid, formsemestre_id=formsemestre_id + ): + db.session.delete(v) + for v in ApcValidationAnnee.query.filter_by( + etudid=etudid, formsemestre_id=formsemestre_id + ): + db.session.delete(v) + + db.session.commit() sem = sco_formsemestre.get_formsemestre(formsemestre_id) _invalidate_etud_formation_caches( diff --git a/app/scodoc/sco_import_users.py b/app/scodoc/sco_import_users.py index 62967e3d..e5a3e1e0 100644 --- a/app/scodoc/sco_import_users.py +++ b/app/scodoc/sco_import_users.py @@ -150,22 +150,22 @@ def import_users(users, force=""): * ok: import ok or aborted * messages: the list of messages * the # of users created - """ - """ Implémentation: + + Implémentation: Pour chaque utilisateur à créer: * vérifier données (y compris que le même nom d'utilisateur n'est pas utilisé plusieurs fois) * générer mot de passe aléatoire * créer utilisateur et mettre le mot de passe * envoyer mot de passe par mail - Les utilisateurs à créer sont stockés dans un dictionnaire. + Les utilisateurs à créer sont stockés dans un dictionnaire. L'ajout effectif ne se fait qu'en fin de fonction si aucune erreur n'a été détectée """ + created = {} # uid créés if len(users) == 0: import_ok = False msg_list = ["Feuille vide ou illisible"] else: - created = {} # liste de uid créés msg_list = [] line = 1 # start from excel line #2 import_ok = True @@ -217,7 +217,7 @@ def import_users(users, force=""): else: import_ok = False except ScoValueError as value_error: - log("import_users: exception: abort create %s" % str(created.keys())) + log(f"import_users: exception: abort create {str(created.keys())}") raise ScoValueError(msg) from value_error if import_ok: for u in created.values(): @@ -228,7 +228,7 @@ def import_users(users, force=""): db.session.commit() mail_password(u) else: - created = [] # reset # of created users to 0 + created = {} # reset # of created users to 0 return import_ok, msg_list, len(created) diff --git a/app/scodoc/sco_logos.py b/app/scodoc/sco_logos.py index 27ce7573..343c4c46 100644 --- a/app/scodoc/sco_logos.py +++ b/app/scodoc/sco_logos.py @@ -121,7 +121,8 @@ def _list_dept_logos(dept_id=None, prefix=scu.LOGO_FILE_PREFIX): :return: le résultat de la recherche ou None si aucune image trouvée """ allowed_ext = "|".join(scu.LOGOS_IMAGES_ALLOWED_TYPES) - filename_parser = re.compile(f"{prefix}([^.]*).({allowed_ext})") + # parse filename 'logo_. . be carefull: logoname may include '.' + filename_parser = re.compile(f"{prefix}(([^.]*.)+)({allowed_ext})") logos = {} path_dir = Path(scu.SCODOC_LOGOS_DIR) if dept_id: @@ -135,7 +136,7 @@ def _list_dept_logos(dept_id=None, prefix=scu.LOGO_FILE_PREFIX): if os.access(path_dir.joinpath(entry).absolute(), os.R_OK): result = filename_parser.match(entry.name) if result: - logoname = result.group(1) + logoname = result.group(1)[:-1] # retreive logoname from filename (less final dot) logos[logoname] = Logo(logoname=logoname, dept_id=dept_id).select() return logos if len(logos.keys()) > 0 else None @@ -191,6 +192,9 @@ class Logo: ) self.mm = "Not initialized: call the select or create function before access" + def __repr__(self) -> str: + return f"Logo(logoname='{self.logoname}', filename='{self.filename}')" + def _set_format(self, fmt): self.suffix = fmt self.filepath = self.basepath + "." + fmt diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py index 2631ef45..b0690ea4 100644 --- a/app/scodoc/sco_page_etud.py +++ b/app/scodoc/sco_page_etud.py @@ -36,6 +36,7 @@ from flask_login import current_user import app.scodoc.sco_utils as scu import app.scodoc.notesdb as ndb from app import log +from app.but import jury_but_view from app.models.etudiants import make_etud_args from app.scodoc import html_sco_header from app.scodoc import htmlutils @@ -445,6 +446,10 @@ def ficheEtud(etudid=None): else: info["groupes_row"] = "" info["menus_etud"] = menus_etud(etudid) + + # raccordement provisoire pour juillet 2022, avant refonte complète de cette fiche... + info["but_infos_mkup"] = jury_but_view.infos_fiche_etud_html(etudid) + tmpl = """
@@ -477,6 +482,8 @@ def ficheEtud(etudid=None): %(inscriptions_mkup)s +%(but_infos_mkup)s +
%(adm_data)s @@ -513,7 +520,7 @@ def ficheEtud(etudid=None): """ header = html_sco_header.sco_header( page_title="Fiche étudiant %(prenom)s %(nom)s" % info, - cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css"], + cssstyles=["libjs/jQuery-tagEditor/jquery.tag-editor.css", "css/jury_but.css"], javascripts=[ "libjs/jinplace-1.2.1.min.js", "js/ue_list.js", diff --git a/app/scodoc/sco_parcours_dut.py b/app/scodoc/sco_parcours_dut.py index 8c1d23a4..f3441386 100644 --- a/app/scodoc/sco_parcours_dut.py +++ b/app/scodoc/sco_parcours_dut.py @@ -109,10 +109,14 @@ class DecisionSem(object): # log('%s: %s %s %s %s %s' % (self.codechoice,code_etat,new_code_prev,formsemestre_id_utilise_pour_compenser,devenir,assiduite) ) -def SituationEtudParcours(etud, formsemestre_id): +def SituationEtudParcours(etud: dict, formsemestre_id: int): """renvoie une instance de SituationEtudParcours (ou sous-classe spécialisée)""" formsemestre = FormSemestre.query.get_or_404(formsemestre_id) nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) + + # if formsemestre.formation.is_apc(): + # return SituationEtudParcoursBUT(etud, formsemestre_id, nt) + parcours = nt.parcours # if parcours.ECTS_ONLY: @@ -121,10 +125,10 @@ def SituationEtudParcours(etud, formsemestre_id): return SituationEtudParcoursGeneric(etud, formsemestre_id, nt) -class SituationEtudParcoursGeneric(object): +class SituationEtudParcoursGeneric: "Semestre dans un parcours" - def __init__(self, etud, formsemestre_id, nt): + def __init__(self, etud: dict, formsemestre_id: int, nt: NotesTableCompat): """ etud: dict filled by fill_etuds_info() """ @@ -132,7 +136,7 @@ class SituationEtudParcoursGeneric(object): self.etudid = etud["etudid"] self.formsemestre_id = formsemestre_id self.sem = sco_formsemestre.get_formsemestre(formsemestre_id) - self.nt = nt + self.nt: NotesTableCompat = nt self.formation = self.nt.formsemestre.formation self.parcours = self.nt.parcours # Ce semestre est-il le dernier de la formation ? (e.g. semestre 4 du DUT) diff --git a/app/scodoc/sco_pvjury.py b/app/scodoc/sco_pvjury.py index ecbe19f2..9b374b8e 100644 --- a/app/scodoc/sco_pvjury.py +++ b/app/scodoc/sco_pvjury.py @@ -52,7 +52,8 @@ from reportlab.platypus import Paragraph from reportlab.lib import styles import flask -from flask import url_for, g, redirect, request +from flask import flash, redirect, url_for +from flask import g, request from app.comp import res_sem from app.comp.res_compat import NotesTableCompat @@ -274,7 +275,10 @@ def dict_pvjury( _codes.add(ue["ue_code"]) d["decisions_ue_descr"] = ", ".join([ue["acronyme"] for ue in ue_uniq]) - d["decision_sem_descr"] = _descr_decision_sem(d["etat"], d["decision_sem"]) + if nt.is_apc: + d["decision_sem_descr"] = "" # pas de validation de semestre en BUT + else: + d["decision_sem_descr"] = _descr_decision_sem(d["etat"], d["decision_sem"]) d["autorisations"] = sco_parcours_dut.formsemestre_get_autorisation_inscription( etudid, formsemestre_id @@ -501,7 +505,7 @@ def formsemestre_pvjury(formsemestre_id, format="html", publish=True): from app.but import jury_but_recap return jury_but_recap.formsemestre_saisie_jury_but( - formsemestre, readonly=True, mode="recap" + formsemestre, read_only=True, mode="recap" ) # /XXX footer = html_sco_header.sco_footer() @@ -795,7 +799,7 @@ def descrform_pvjury(sem): def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]): "Lettres avis jury en PDF" - sem = sco_formsemestre.get_formsemestre(formsemestre_id) + formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id) if not group_ids: # tous les inscrits du semestre group_ids = [sco_groups.get_default_group(formsemestre_id)] @@ -811,10 +815,15 @@ def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]): cssstyles=sco_groups_view.CSSSTYLES, init_qtip=True, ), - """

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

- """ - % formsemestre_id, + f"""

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

+ """, ] F = html_sco_header.sco_footer() descr = descrform_lettres_individuelles() @@ -839,7 +848,11 @@ def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]): return "\n".join(H) + "\n" + tf[1] + F elif tf[0] == -1: return flask.redirect( - "formsemestre_pvjury?formsemestre_id=%s" % (formsemestre_id) + url_for( + "notes.formsemestre_pvjury", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + ) ) else: # submit @@ -857,15 +870,17 @@ def formsemestre_lettres_individuelles(formsemestre_id, group_ids=[]): finally: PDFLOCK.release() if not pdfdoc: + flash("Aucun étudiant n'a de décision de jury !") return flask.redirect( - "formsemestre_status?formsemestre_id={}&head_message=Aucun%20%C3%A9tudiant%20n%27a%20de%20d%C3%A9cision%20de%20jury".format( - formsemestre_id + url_for( + "notes.formsemestre_status", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, ) ) - sem = sco_formsemestre.get_formsemestre(formsemestre_id) - dt = time.strftime("%Y-%m-%d") + groups_filename = "-" + groups_infos.groups_filename - filename = "lettres-%s%s-%s.pdf" % (sem["titre_num"], groups_filename, dt) + filename = f"""lettres-{formsemestre.titre_num()}{groups_filename}-{time.strftime("%Y-%m-%d")}.pdf""" return scu.sendPDFFile(pdfdoc, filename) diff --git a/app/scodoc/sco_pvpdf.py b/app/scodoc/sco_pvpdf.py index b2515895..8ef1c12c 100644 --- a/app/scodoc/sco_pvpdf.py +++ b/app/scodoc/sco_pvpdf.py @@ -45,13 +45,14 @@ from flask import g import app.scodoc.sco_utils as scu from app.scodoc import sco_bulletins_pdf from app.scodoc import sco_codes_parcours +from app.scodoc import sco_etud from app.scodoc import sco_formsemestre from app.scodoc import sco_pdf from app.scodoc import sco_preferences -from app.scodoc import sco_etud -import sco_version from app.scodoc.sco_logos import find_logo +from app.scodoc.sco_parcours_dut import SituationEtudParcours from app.scodoc.sco_pdf import SU +import sco_version LOGO_FOOTER_ASPECT = scu.CONFIG.LOGO_FOOTER_ASPECT # XXX A AUTOMATISER LOGO_FOOTER_HEIGHT = scu.CONFIG.LOGO_FOOTER_HEIGHT * mm @@ -62,7 +63,7 @@ LOGO_HEADER_HEIGHT = scu.CONFIG.LOGO_HEADER_HEIGHT * mm LOGO_HEADER_WIDTH = LOGO_HEADER_HEIGHT * scu.CONFIG.LOGO_HEADER_ASPECT -def pageFooter(canvas, doc, logo, preferences, with_page_numbers=True): +def page_footer(canvas, doc, logo, preferences, with_page_numbers=True): "Add footer on page" width = doc.pagesize[0] # - doc.pageTemplate.left_p - doc.pageTemplate.right_p foot = Frame( @@ -78,24 +79,24 @@ def pageFooter(canvas, doc, logo, preferences, with_page_numbers=True): showBoundary=0, ) - LeftFootStyle = reportlab.lib.styles.ParagraphStyle({}) - LeftFootStyle.fontName = preferences["SCOLAR_FONT"] - LeftFootStyle.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"] - LeftFootStyle.leftIndent = 0 - LeftFootStyle.firstLineIndent = 0 - LeftFootStyle.alignment = TA_RIGHT - RightFootStyle = reportlab.lib.styles.ParagraphStyle({}) - RightFootStyle.fontName = preferences["SCOLAR_FONT"] - RightFootStyle.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"] - RightFootStyle.alignment = TA_RIGHT + left_foot_style = reportlab.lib.styles.ParagraphStyle({}) + left_foot_style.fontName = preferences["SCOLAR_FONT"] + left_foot_style.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"] + left_foot_style.leftIndent = 0 + left_foot_style.firstLineIndent = 0 + left_foot_style.alignment = TA_RIGHT + right_foot_style = reportlab.lib.styles.ParagraphStyle({}) + right_foot_style.fontName = preferences["SCOLAR_FONT"] + right_foot_style.fontSize = preferences["SCOLAR_FONT_SIZE_FOOT"] + right_foot_style.alignment = TA_RIGHT p = sco_pdf.makeParas( - """%s%s""" - % (preferences["INSTITUTION_NAME"], preferences["INSTITUTION_ADDRESS"]), - LeftFootStyle, + f"""{preferences["INSTITUTION_NAME"]}{ + preferences["INSTITUTION_ADDRESS"]}""", + left_foot_style, ) - np = Paragraph('%d' % doc.page, RightFootStyle) + np = Paragraph(f'{doc.page}', right_foot_style) tabstyle = TableStyle( [ ("LEFTPADDING", (0, 0), (-1, -1), 0), @@ -123,7 +124,7 @@ def pageFooter(canvas, doc, logo, preferences, with_page_numbers=True): canvas.restoreState() -def pageHeader(canvas, doc, logo, preferences, only_on_first_page=False): +def page_header(canvas, doc, logo, preferences, only_on_first_page=False): if only_on_first_page and int(doc.page) > 1: return height = doc.pagesize[1] @@ -260,7 +261,7 @@ class CourrierIndividuelTemplate(PageTemplate): # ---- Header/Footer if self.with_header: - pageHeader( + page_header( canvas, doc, self.logo_header, @@ -268,7 +269,7 @@ class CourrierIndividuelTemplate(PageTemplate): self.header_only_on_first_page, ) if self.with_footer: - pageFooter( + page_footer( canvas, doc, self.logo_footer, @@ -427,7 +428,7 @@ def pdf_lettre_individuelle(sem, decision, etud, params, signature=None): """ # formsemestre_id = sem["formsemestre_id"] - Se = decision["Se"] + Se: SituationEtudParcours = decision["Se"] t, s = _descr_jury(sem, Se.parcours_validated() or not Se.semestre_non_terminal) objects = [] style = reportlab.lib.styles.ParagraphStyle({}) diff --git a/app/scodoc/sco_recapcomplet.py b/app/scodoc/sco_recapcomplet.py index 1c5c19eb..aad0b96b 100644 --- a/app/scodoc/sco_recapcomplet.py +++ b/app/scodoc/sco_recapcomplet.py @@ -57,7 +57,7 @@ from app.scodoc import sco_preferences def formsemestre_recapcomplet( formsemestre_id=None, - modejury=False, + mode_jury=False, tabformat="html", sortcol=None, xml_with_decisions=False, @@ -78,7 +78,7 @@ def formsemestre_recapcomplet( xml, json : concaténation de tous les bulletins, au format demandé pdf : NON SUPPORTE (car tableau trop grand pour générer un pdf utilisable) - modejury: cache modules, affiche lien saisie decision jury + mode_jury: cache modules, affiche lien saisie decision jury xml_with_decisions: publie décisions de jury dans xml et json force_publishing: publie les xml et json même si bulletins non publiés selected_etudid: etudid sélectionné (pour scroller au bon endroit) @@ -91,14 +91,14 @@ def formsemestre_recapcomplet( if tabformat not in supported_formats: raise ScoValueError(f"Format non supporté: {tabformat}") is_file = tabformat in file_formats - modejury = int(modejury) + mode_jury = int(mode_jury) xml_with_decisions = int(xml_with_decisions) force_publishing = int(force_publishing) data = _do_formsemestre_recapcomplet( formsemestre_id, format=tabformat, - modejury=modejury, + mode_jury=mode_jury, sortcol=sortcol, xml_with_decisions=xml_with_decisions, force_publishing=force_publishing, @@ -123,9 +123,9 @@ def formsemestre_recapcomplet( """ ) - if modejury: + if mode_jury: H.append( - f'' + f'' ) H.append( '""" ] # header diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py index 9207aa7b..31de4bb2 100644 --- a/app/scodoc/sco_utils.py +++ b/app/scodoc/sco_utils.py @@ -588,7 +588,7 @@ def purge_chars(s, allowed_chars=""): return s.translate(PurgeChars(allowed_chars=allowed_chars)) -def sanitize_string(s): +def sanitize_string(s, remove_spaces=True): """s is an ordinary string, encoding given by SCO_ENCODING" suppress accents and chars interpreted in XML Irreversible (not a quote) @@ -596,8 +596,10 @@ def sanitize_string(s): For ids and some filenames """ # Table suppressing some chars: - trans = str.maketrans("", "", "'`\"<>!&\\ ") - return suppress_accents(s.translate(trans)).replace(" ", "_").replace("\t", "_") + to_del = "'`\"<>!&\\ " if remove_spaces else "'`\"<>!&" + trans = str.maketrans("", "", to_del) + + return suppress_accents(s.translate(trans)).replace("\t", "_") _BAD_FILENAME_CHARS = str.maketrans("", "", ":/\\&[]*?'") @@ -968,6 +970,8 @@ ICON_XLS = icontag("xlsicon_img", title="Version tableur") # HTML emojis EMO_WARNING = "⚠️" # warning /!\ EMO_RED_TRIANGLE_DOWN = "🔻" # red triangle pointed down +EMO_PREV_ARROW = "❮" +EMO_NEXT_ARROW = "❯" def sort_dates(L, reverse=False): @@ -1097,6 +1101,10 @@ def gen_cell(key: str, row: dict, elt="td", with_col_class=False): if with_col_class: klass = key + " " + klass attrs = f'class="{klass}"' if klass else "" + data = row.get(f"_{key}_data") # dict + if data: + for k in data: + attrs += f' data-{k}="{data[k]}"' order = row.get(f"_{key}_order") if order: attrs += f' data-order="{order}"' diff --git a/app/static/css/jury_but.css b/app/static/css/jury_but.css index 2804eab9..61211a57 100644 --- a/app/static/css/jury_but.css +++ b/app/static/css/jury_but.css @@ -65,6 +65,19 @@ font-weight: bold; } + +.but_navigation { + padding-top: 16px; + margin-left: 50px; + margin-right: 50px; +} + +.but_navigation div { + display: inline-block; + margin-left: 50px; + margin-right: 50px; +} + div.but_section_annee { margin-bottom: 10px; } @@ -73,9 +86,10 @@ div.but_settings { margin-top: 16px; } -span.but_explanation { +.but_explanation { color: blueviolet; font-style: italic; + padding-top: 12px; } select:disabled { diff --git a/app/static/css/releve-but.css b/app/static/css/releve-but.css index 41e15448..a38e1112 100644 --- a/app/static/css/releve-but.css +++ b/app/static/css/releve-but.css @@ -189,10 +189,10 @@ section>div:nth-child(1){ font-weight: bold; font-size: 20px; } -#ects_tot { - margin-left: 8px; +#ects_tot, .decision, .decision_annee { font-weight: bold; font-size: 20px; + margin-top: 8px; } .enteteSemestre{ color: black; diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css index ef35fcff..c4db0a26 100644 --- a/app/static/css/scodoc.css +++ b/app/static/css/scodoc.css @@ -2980,7 +2980,8 @@ td.rcp_dec { ; } -td.rcp_nonass { +td.rcp_nonass, +td.rcp_but { color: red; } @@ -3770,6 +3771,7 @@ table.table_recap .rang { } table.table_recap .col_ue, +table.table_recap .col_ue_code, table.table_recap .col_moy_gen, table.table_recap .group { border-left: 1px solid blue; @@ -3783,15 +3785,18 @@ table.table_recap.jury .col_ue { font-weight: normal; } -table.table_recap.jury .col_rcue { +table.table_recap.jury .col_rcue, +table.table_recap.jury .col_rcue_code { font-weight: bold; } -table.table_recap.jury tr.even td.col_rcue { +table.table_recap.jury tr.even td.col_rcue, +table.table_recap.jury tr.even td.col_rcue_code { background-color: #b0d4f8; } -table.table_recap.jury tr.odd td.col_rcue { +table.table_recap.jury tr.odd td.col_rcue, +table.table_recap.jury tr.odd td.col_rcue_code { background-color: #abcdef; } diff --git a/app/static/js/jury_but.js b/app/static/js/jury_but.js index e67362cb..1ef24c16 100644 --- a/app/static/js/jury_but.js +++ b/app/static/js/jury_but.js @@ -11,4 +11,52 @@ function change_menu_code(elt) { // TODO: comparer avec valeur enregistrée (à mettre en data-orig ?) // et colorer en fonction elt.parentElement.parentElement.classList.add("modified"); -} \ No newline at end of file +} + +$(function () { + // Recupère la liste ordonnées des etudids + // pour avoir le "suivant" etr le "précédent" + // (liens de navigation) + const url = new URL(document.URL); + const frags = url.pathname.split("/"); // .../formsemestre_validation_but/formsemestre_id/etudid + const etudid = frags[frags.length - 1]; + const formsemestre_id = frags[frags.length - 2]; + const etudids_key = JSON.stringify(["etudids", url.origin, formsemestre_id]); + const etudids_str = localStorage.getItem(etudids_key); + const noms_key = JSON.stringify(["noms", url.origin, formsemestre_id]); + const noms_str = localStorage.getItem(noms_key); + if (etudids_str && noms_str) { + const etudids = JSON.parse(etudids_str); + const noms = JSON.parse(noms_str); + const cur_idx = etudids.indexOf(etudid); + let prev_idx = -1; + let next_idx = -1 + if (cur_idx != -1) { + if (cur_idx > 0) { + prev_idx = cur_idx - 1; + } + if (cur_idx < etudids.length - 1) { + next_idx = cur_idx + 1; + } + } + if (prev_idx != -1) { + let elem = document.querySelector("div.prev a"); + if (elem) { + elem.href = elem.href.replace("PREV", etudids[prev_idx]); + elem.innerHTML = noms[prev_idx]; + } + } else { + document.querySelector("div.prev").innerHTML = ""; + } + if (next_idx != -1) { + let elem = document.querySelector("div.next a"); + if (elem) { + elem.href = elem.href.replace("NEXT", etudids[next_idx]); + elem.innerHTML = noms[next_idx]; + } + } else { + document.querySelector("div.next").innerHTML = ""; + } + + } +}); \ No newline at end of file diff --git a/app/static/js/releve-but.js b/app/static/js/releve-but.js index 1550f889..7d3bc985 100644 --- a/app/static/js/releve-but.js +++ b/app/static/js/releve-but.js @@ -83,7 +83,9 @@ class releveBUT extends HTMLElement {
-
+
+
+
Inscrit le
Les moyennes ci-dessus servent à situer l'étudiant dans la promotion et ne correspondent pas à des validations de compétences ou d'UE.
@@ -192,6 +194,20 @@ class releveBUT extends HTMLElement { /* Information sur le semestre */ /*******************************/ showSemestre(data) { + let correspondanceCodes = { + "ADM": "Admis", + "AJD": "Admis par décision de jury", + "PASD": "Passage de droit : tout n'est pas validé, mais d'après les règles du BUT, vous passez", + "PAS1NCI": "Vous passez par décision de jury mais attention, vous n'avez pas partout le niveau suffisant", + "RED": "Ajourné mais autorisé à redoubler", + "NAR": "Non admis et non autorisé à redoubler : réorientation", + "DEM": "Démission", + "ABAN": "Abandon constaté sans lettre de démission", + "RAT": "En attente d'un rattrapage", + "EXCLU": "Exclusion dans le cadre d'une décision disciplinaire", + "DEF": "Défaillance : non évalué par manque d'assiduité", + "ABL": "Année blanche" + } this.shadow.querySelector("#identite_etudiant").innerHTML = ` ${data.etudiant.nomprenom} `; this.shadow.querySelector(".dateInscription").innerHTML += this.ISOToDate(data.semestre.inscription); @@ -208,9 +224,26 @@ class releveBUT extends HTMLElement {
Non justifiées
${data.semestre.absences?.injustifie ?? "-"}
Total
${data.semestre.absences?.total ?? "-"}
-
- photo de l'étudiant - `; + `; + if(data.semestre.decision_rcue?.length){ + output += ` +
+
RCUE
+ ${(()=>{ + let output = ""; + data.semestre.decision_rcue.forEach(competence=>{ + output += `
${competence.niveau.competence.titre}
${competence.code}
`; + }) + return output; + })()} +
+ ` + } + + output += ` + + photo de l'étudiant + `; /*${data.semestre.groupes.map(groupe => { return `
@@ -224,10 +257,17 @@ class releveBUT extends HTMLElement { }).join("") }*/ this.shadow.querySelector(".infoSemestre").innerHTML = output; - if (data.semestre.decision?.code) { + + + /*if(data.semestre.decision_annee?.code){ + this.shadow.querySelector(".decision_annee").innerHTML = "Décision année : " + data.semestre.decision_annee.code + " - " + correspondanceCodes[data.semestre.decision_annee.code]; + }*/ + + this.shadow.querySelector(".decision").innerHTML = data.semestre.situation || ""; + /*if (data.semestre.decision?.code) { this.shadow.querySelector(".decision").innerHTML = "Décision jury: " + (data.semestre.decision?.code || ""); - } - this.shadow.querySelector("#ects_tot").innerHTML = "ECTS : " + (data.semestre.ECTS?.acquis || "-") + " / " + (data.semestre.ECTS?.total || "-"); + }*/ + this.shadow.querySelector("#ects_tot").innerHTML = "ECTS : " + (data.semestre.ECTS?.acquis ?? "-") + " / " + (data.semestre.ECTS?.total ?? "-"); } /*******************************/ @@ -254,13 +294,13 @@ class releveBUT extends HTMLElement { ${ue}${(dataUE.titre) ? " - " + dataUE.titre : ""}
-
Moyenne : ${dataUE.moyenne?.value || "-"}
+
Moyenne : ${dataUE.moyenne?.value ?? "-"}
Rang : ${dataUE.moyenne?.rang} / ${dataUE.moyenne?.total}
Bonus : ${dataUE.bonus || 0} - Malus : ${dataUE.malus || 0}  - - ECTS : ${dataUE.ECTS?.acquis || "-"} / ${dataUE.ECTS?.total || "-"} + ECTS : ${dataUE.ECTS?.acquis ?? "-"} / ${dataUE.ECTS?.total ?? "-"}
`; diff --git a/app/static/js/table_editor.js b/app/static/js/table_editor.js index 2b6af16e..5370594f 100644 --- a/app/static/js/table_editor.js +++ b/app/static/js/table_editor.js @@ -11,6 +11,7 @@ function build_table(data) { let output = ""; let sumsUE = {}; let sumsRessources = {}; + let value; data.forEach((cellule) => { output += ` @@ -31,13 +32,16 @@ function build_table(data) { --y:${cellule.y}; --nbX:${cellule.nbX || 1}; --nbY: ${cellule.nbY || 1}; - "> - ${cellule.data} -
`; - + ">${cellule.data}`; // ne pas mettre d'espace car c'est utilisé par :not(:empty) après + if (cellule.style.includes("champs")) { - sumsRessources[cellule.y] = (sumsRessources[cellule.y] ?? 0) + (parseFloat(cellule.data) || 0); - sumsUE[cellule.x] = (sumsUE[cellule.x] ?? 0) + (parseFloat(cellule.data) || 0); + if (cellule.editable == true && cellule.data) { + value = parseFloat(cellule.data) *100; + } else { + value = 0; + } + sumsRessources[cellule.y] = (sumsRessources[cellule.y] ?? 0) + value; + sumsUE[cellule.x] = (sumsUE[cellule.x] ?? 0) + value; } }) @@ -65,7 +69,7 @@ function showSums(sumsRessources, sumsUE) { --nbX:1; --nbY:1; "> - ${value} + ${value / 100} `; }) @@ -82,7 +86,7 @@ function showSums(sumsRessources, sumsUE) { --nbX:1; --nbY:1; "> - ${value} + ${value / 100} `; }) @@ -186,16 +190,16 @@ function keyCell(event) { function processSums() { let sum = 0; - document.querySelectorAll(`[data-editable="true"][data-x="${this.dataset.x}"]`).forEach(e => { - sum += parseFloat(e.innerText) || 0; + document.querySelectorAll(`[data-editable="true"][data-x="${this.dataset.x}"]:not(:empty)`).forEach(e => { + sum += parseFloat(e.innerText) * 100; }) - document.querySelector(`.sums[data-x="${this.dataset.x}"][data-y="${lastY}"]`).innerText = sum; + document.querySelector(`.sums[data-x="${this.dataset.x}"][data-y="${lastY}"]`).innerText = sum / 100; sum = 0; - document.querySelectorAll(`[data-editable="true"][data-y="${this.dataset.y}"]`).forEach(e => { - sum += parseFloat(e.innerText) || 0; + document.querySelectorAll(`[data-editable="true"][data-y="${this.dataset.y}"]:not(:empty)`).forEach(e => { + sum += parseFloat(e.innerText) * 100; }) - document.querySelector(`.sums[data-x="${lastX}"][data-y="${this.dataset.y}"]`).innerText = sum; + document.querySelector(`.sums[data-x="${lastX}"][data-y="${this.dataset.y}"]`).innerText = sum / 100; } /******************************/ diff --git a/app/static/js/table_recap.js b/app/static/js/table_recap.js index 805153fd..6102f485 100644 --- a/app/static/js/table_recap.js +++ b/app/static/js/table_recap.js @@ -133,7 +133,7 @@ $(function () { } }); } - $('table.table_recap').DataTable( + let table = $('table.table_recap').DataTable( { paging: false, searching: true, @@ -146,6 +146,7 @@ $(function () { orderCellsTop: true, // cellules ligne 1 pour tri aaSorting: [], // Prevent initial sorting colReorder: true, + stateSave: true, // enregistre état de la table (tris, ...) "columnDefs": [ { // cache les codes, le détail de l'identité, les groupes, les colonnes admission et les vides @@ -154,7 +155,7 @@ $(function () { }, { // Elimine les 0 à gauche pour les exports excel et les "copy" - targets: ["col_mod", "col_moy_gen", "col_ue", "col_res", "col_sae", "evaluation"], + targets: ["col_mod", "col_moy_gen", "col_ue", "col_res", "col_sae", "evaluation", "col_rcue"], render: function (data, type, row) { return type === 'export' ? data.replace(/0(\d\..*)/, '$1') : data; } @@ -192,11 +193,22 @@ $(function () { if (formsemestre_id) { localStorage.setItem(order_info_key, order_info); } + let etudids = []; + document.querySelectorAll("td.col_rcues_validables").forEach(e => { + etudids.push(e.dataset.etudid); + }); + let noms = []; + document.querySelectorAll("td.col_rcues_validables").forEach(e => { + noms.push(e.dataset.nomprenom); + }); + const etudids_key = JSON.stringify(["etudids", url.origin, formsemestre_id]); + localStorage.setItem(etudids_key, JSON.stringify(etudids)); + const noms_key = JSON.stringify(["noms", url.origin, formsemestre_id]); + localStorage.setItem(noms_key, JSON.stringify(noms)); }, "order": order_info, } ); - }); $('table.table_recap tbody').on('click', 'tr', function () { if ($(this).hasClass('selected')) { @@ -211,8 +223,8 @@ $(function () { $(function () { let row_selected = document.querySelector("#row_selected"); if (row_selected) { - row_selected.scrollIntoView(); - window.scrollBy(0, -50); + /*row_selected.scrollIntoView(); + window.scrollBy(0, -50);*/ row_selected.classList.add("selected"); } }); diff --git a/app/templates/but/documentation_codes_jury.html b/app/templates/but/documentation_codes_jury.html index 499dabb5..abd3a742 100644 --- a/app/templates/but/documentation_codes_jury.html +++ b/app/templates/but/documentation_codes_jury.html @@ -1,9 +1,12 @@

Ci-dessous la signification de chaque code est expliquée, - ainsi que la correspondance avec les codes préconisés par - l'AMUE pour Apogée dans un document informel qui a circulé début - 2022 (les éventuelles erreurs n'engagent personne). -

+ ainsi que la correspondance avec certains codes préconisés par + l'AMUE et l'ADIUT pour Apogée. + + On distingue les codes ScoDoc (utilisés ci-dessus et dans les différentes + tables générées par ScoDoc) et leur transcription vers Apogée lors des exports + (transcription paramétrable par votre administrateur ScoDoc). +

Codes d'année
@@ -63,6 +66,12 @@ + + + + + + @@ -124,6 +133,12 @@ + + + + + + @@ -180,6 +195,12 @@ + + + + + + @@ -212,4 +233,34 @@
ABAN ABANdon constaté (sans lettre de démission)
ATJ{{codes["ATJ"]}}ndNon validé pour une autre raison, voir règlement local
RAT {{codes["RAT"]}}AJ Attente pour problème de moyenne
ATJ{{codes["ATJ"]}}ndNon validé pour une autre raison, voir règlement local
RAT {{codes["RAT"]}}AJ Attente pour problème de moyenne
ATJ{{codes["ATJ"]}}ndNon validé pour une autre raison, voir règlement local
RAT {{codes["RAT"]}}
+ +
Rappels de l'arrêté BUT (extraits)
+
+
    +
  • Au sein de chaque regroupement cohérent d’UE, la compensation est intégrale. + Si une UE n’a pas été acquise en raison d’une moyenne inférieure à 10, + cette UE sera acquise par compensation si et seulement si l’étudiant + a obtenu la moyenne au regroupement cohérent auquel l’UE appartient.
  • +
  • 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 regroupement cohérent d’UE.
    • +
    +
  • +
  • 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 et 4.4, ou par décision de jury.
  • +
+ Textes de référence: + + +
\ No newline at end of file diff --git a/app/views/notes.py b/app/views/notes.py index ce3d6b88..c1715c92 100644 --- a/app/views/notes.py +++ b/app/views/notes.py @@ -31,7 +31,6 @@ Module notes: issu de ScoDoc7 / ZNotes.py Emmanuel Viennet, 2021 """ -import html from operator import itemgetter import time from xml.etree import ElementTree @@ -43,6 +42,8 @@ from flask_login import current_user from app.but import jury_but, jury_but_validation_auto from app.but.forms import jury_but_forms +from app.but import jury_but_pv +from app.but import jury_but_view from app.comp import res_sem from app.comp.res_but import ResultatsSemestreBUT from app.comp.res_compat import NotesTableCompat @@ -56,23 +57,21 @@ from app.models.ues import UniteEns from app import api from app import db from app import models -from app.models import ScolarNews +from app.models import ScolarNews, but_validations from app.auth.models import User -from app.but import apc_edit_ue, bulletin_but, jury_but_recap +from app.but import apc_edit_ue, jury_but_recap from app.decorators import ( scodoc, scodoc7func, permission_required, permission_required_compat_scodoc7, - admin_required, - login_required, ) from app.views import notes_bp as bp # --------------- -from app.scodoc import sco_utils as scu +from app.scodoc import sco_bulletins_json, sco_utils as scu from app.scodoc import notesdb as ndb from app import log, send_scodoc_alarm @@ -297,7 +296,7 @@ def formsemestre_bulletinetud( format = format or "html" if not isinstance(formsemestre_id, int): - abort(404, description="formsemestre_id must be an integer !") + raise ScoInvalidIdType("formsemestre_id must be an integer !") formsemestre = FormSemestre.query.get_or_404(formsemestre_id) if etudid: etud = models.Identite.query.get_or_404(etudid) @@ -2144,6 +2143,16 @@ def formsemestre_validation_etud_form( ): "Formulaire choix jury pour un étudiant" readonly = not sco_permissions_check.can_validate_sem(formsemestre_id) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if formsemestre.formation.is_apc(): + return redirect( + url_for( + "notes.formsemestre_validation_but", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + ) + ) return sco_formsemestre_validation.formsemestre_validation_etud_form( formsemestre_id, etudid=etudid, @@ -2217,22 +2226,24 @@ def formsemestre_validation_etud_manu( # --- Jurys BUT @bp.route( - "/formsemestre_validation_but//", + "/formsemestre_validation_but//", methods=["GET", "POST"], ) @scodoc @permission_required(Permission.ScoView) -def formsemestre_validation_but(formsemestre_id: int, etudid: int): +def formsemestre_validation_but( + formsemestre_id: int, + etudid: int, +): "Form. saisie décision jury semestre BUT" - if not sco_permissions_check.can_validate_sem(formsemestre_id): - return scu.confirm_dialog( - message=f"

Opération non autorisée pour {current_user}", - dest_url=url_for( - "notes.formsemestre_status", - scodoc_dept=g.scodoc_dept, - formsemestre_id=formsemestre_id, - ), - ) + # la route ne donne pas le type d'etudid pour pouvoir construire des URLs + # provisoires avec NEXT et PREV + try: + etudid = int(etudid) + except: + abort(404, "invalid etudid") + read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) + H = [ html_sco_header.sco_header( page_title="Validation BUT", @@ -2265,13 +2276,13 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int): + html_sco_header.sco_footer() ) - res: ResultatsSemestreBUT = res_sem.load_formsemestre_results(formsemestre) deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre) if len(deca.rcues_annee) == 0: raise ScoValueError("année incomplète: pas de jury BUT annuel possible") if request.method == "POST": - deca.record_form(request.form) - flash("codes enregistrés") + if not read_only: + deca.record_form(request.form) + flash("codes enregistrés") return flask.redirect( url_for( "notes.formsemestre_validation_but", @@ -2280,13 +2291,7 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int): etudid=etudid, ) ) - if deca.code_valide: - erase_span = f"""effacer décisions""" - else: - erase_span = "" + warning = "" if len(deca.niveaux_competences) != len(deca.decisions_rcue_by_niveau): warning += f"""

Attention: {len(deca.niveaux_competences)} @@ -2296,95 +2301,77 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int): H.append( f"""
-
Jury BUT{deca.annee_but} - - Parcours {(deca.parcour.libelle if deca.parcour else False) or "non spécifié"} - - {deca.annee_scolaire_str()}
-
{etud.nomprenom}
- {warning} +
+
+
Jury BUT{deca.annee_but} + - Parcours {(deca.parcour.libelle if deca.parcour else False) or "non spécifié"} + - {deca.annee_scolaire_str()}
+
{etud.nomprenom}
+
+ +
+ {warning}
-
-
- Décision de jury pour l'année : { - _gen_but_select("code_annee", deca.codes, deca.code_valide, - disabled=True, klass="manual") - } - ({'non ' if deca.code_valide is None else ''}enregistrée) - {erase_span} -
- {deca.explanation} -
-
Niveaux de compétences et unités d'enseignement :
-
-
-
S{1}
-
S{2}
-
RCUE
""" ) - for niveau in deca.niveaux_competences: - H.append( - f"""
-
{niveau.competence.titre}
-
""" - ) - dec_rcue = deca.decisions_rcue_by_niveau.get(niveau.id) - if dec_rcue is None: - break - # Semestre impair - H.append( - _gen_but_niveau_ue( - dec_rcue.rcue.ue_1, - dec_rcue.rcue.moy_ue_1, - deca.decisions_ues[dec_rcue.rcue.ue_1.id], - ) - ) - # Semestre pair - H.append( - _gen_but_niveau_ue( - dec_rcue.rcue.ue_2, - dec_rcue.rcue.moy_ue_2, - deca.decisions_ues[dec_rcue.rcue.ue_2.id], - ) - ) - # RCUE - H.append( - f"""
-
{scu.fmt_note(dec_rcue.rcue.moy_rcue)}
-
{ - _gen_but_select("code_rcue_"+str(niveau.id), - dec_rcue.codes, - dec_rcue.code_valide, - disabled=True, klass="manual" - ) - }
-
""" - ) - H.append("
") # but_annee + H.append(jury_but_view.show_etud(deca, read_only=read_only)) + if read_only: + H.append( + """
Vous n'avez pas la permission de modifier ces décisions. + Les champs entourés en vert sont enregistrés.
""" + ) + else: + H.append( + f"""
+ + permettre la saisie manuelles des codes d'année et de niveaux. + Dans ce cas, il vous revient de vous assurer de la cohérence entre + vos codes d'UE/RCUE/Année ! + +
+ +
+ +
+ """ + ) + # --- Navigation + prev = f"""{scu.EMO_PREV_ARROW} précédent + """ + next = f"""suivant {scu.EMO_NEXT_ARROW} + """ H.append( - f"""
- - permettre la saisie manuelles des codes d'année et de niveaux. - Dans ce cas, il vous revient de vous assurer de la cohérence entre - vos codes d'UE/RCUE/Année ! - + f""" +
+ - -
- - + retour à la liste + )}" class="stdlink">retour à la liste
- """ + +
+ """ ) - H.append("") # but_annee + H.append("") H.append( render_template( @@ -2399,48 +2386,6 @@ def formsemestre_validation_but(formsemestre_id: int, etudid: int): return "\n".join(H) + html_sco_header.sco_footer() -def _gen_but_select( - name: str, - codes: list[str], - code_valide: str, - disabled: bool = False, - klass: str = "", -) -> str: - "Le menu html select avec les codes" - h = "\n".join( - [ - f"""""" - for code in codes - ] - ) - return f""" - """ - - -def _gen_but_niveau_ue( - ue: UniteEns, moy_ue: float, dec_ue: jury_but.DecisionsProposeesUE -): - return f"""
-
{ue.acronyme}
-
{scu.fmt_note(moy_ue)}
-
{ - _gen_but_select("code_ue_"+str(ue.id), - dec_ue.codes, - dec_ue.code_valide - ) - }
-
""" - - @bp.route( "/formsemestre_validation_auto_but/", methods=["GET", "POST"] ) @@ -2580,56 +2525,75 @@ def do_formsemestre_validation_auto(formsemestre_id): def formsemestre_validation_suppress_etud( formsemestre_id, etudid, dialog_confirmed=False ): - """Suppression des decisions de jury pour un etudiant.""" + """Suppression des décisions de jury pour un étudiant.""" if not sco_permissions_check.can_validate_sem(formsemestre_id): return scu.confirm_dialog( message="

Opération non autorisée pour %s" % current_user, dest_url=scu.ScoURL(), ) + etud = Identite.query.get_or_404(etudid) + formsemestre = FormSemestre.query.get_or_404(formsemestre_id) + if formsemestre.formation.is_apc(): + next_url = url_for( + "scolar.ficheEtud", + scodoc_dept=g.scodoc_dept, + etudid=etudid, + ) + else: + next_url = url_for( + "notes.formsemestre_validation_etud_form", + scodoc_dept=g.scodoc_dept, + formsemestre_id=formsemestre_id, + etudid=etudid, + ) if not dialog_confirmed: - etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0] - formsemestre = FormSemestre.query.get_or_404(formsemestre_id) - sem = formsemestre.to_dict() - nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre) - decision_jury = nt.get_etud_decision_sem(etudid) - if decision_jury: - existing = ( - "

Décision existante: %(code)s du %(event_date)s

" % decision_jury - ) + d = sco_bulletins_json.dict_decision_jury( + etudid, formsemestre_id, with_decisions=True + ) + d.update(but_validations.dict_decision_jury(etud, formsemestre)) + + descr_ues = [f"{u['acronyme']}: {u['code']}" for u in d.get("decision_ue", [])] + dec_annee = d.get("decision_annee") + if dec_annee: + descr_annee = dec_annee.get("code", "-") else: - existing = "" + descr_annee = "-" + + existing = f""" +
    +
  • Semestre : {d.get("decision", {"code":"-"})['code'] or "-"}
  • +
  • Année BUT: {descr_annee}
  • +
  • UEs : {", ".join(descr_ues)}
  • +
  • RCUEs: {len(d.get("decision_rcue", []))} décisions
  • +
+ """ return scu.confirm_dialog( - """

Confirmer la suppression des décisions du semestre %s (%s - %s) pour %s ?

%s -

Cette opération est irréversible. -

- """ - % ( - sem["titre_num"], - sem["date_debut"], - sem["date_fin"], - etud["nomprenom"], - existing, - ), + f"""

Confirmer la suppression des décisions du semestre + {formsemestre.titre_mois()} pour {etud.nomprenom} +

+

Cette opération est irréversible.

+
+ {existing} +
+ """, OK="Supprimer", dest_url="", - cancel_url="formsemestre_validation_etud_form?formsemestre_id=%s&etudid=%s" - % (formsemestre_id, etudid), + cancel_url=next_url, parameters={"etudid": etudid, "formsemestre_id": formsemestre_id}, ) sco_formsemestre_validation.formsemestre_validation_suppress_etud( formsemestre_id, etudid ) - return flask.redirect( - scu.ScoURL() - + "/Notes/formsemestre_validation_etud_form?formsemestre_id=%s&etudid=%s&head_message=Décision%%20supprimée" - % (formsemestre_id, etudid) - ) + flash("Décisions supprimées") + return flask.redirect(next_url) # ------------- PV de JURY et archives sco_publish("/formsemestre_pvjury", sco_pvjury.formsemestre_pvjury, Permission.ScoView) +sco_publish("/pvjury_table_but", jury_but_pv.pvjury_table_but, Permission.ScoView) + @bp.route("/formsemestre_saisie_jury") @scodoc @@ -2640,18 +2604,18 @@ def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None): en semestres pairs de BUT, table spécifique avec l'année sinon, redirect vers page recap en mode jury """ - readonly = not sco_permissions_check.can_validate_sem(formsemestre_id) + read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id) if formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0: return jury_but_recap.formsemestre_saisie_jury_but( - formsemestre, readonly, selected_etudid=selected_etudid + formsemestre, read_only, selected_etudid=selected_etudid ) return redirect( url_for( "notes.formsemestre_recapcomplet", scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id, - modejury=1, + mode_jury=1, ) ) @@ -2662,14 +2626,14 @@ def formsemestre_saisie_jury(formsemestre_id: int, selected_etudid: int = None): @scodoc7func def formsemestre_jury_but_recap(formsemestre_id: int, selected_etudid: int = None): """Tableau affichage des codes""" - readonly = not sco_permissions_check.can_validate_sem(formsemestre_id) + read_only = not sco_permissions_check.can_validate_sem(formsemestre_id) formsemestre = FormSemestre.query.get_or_404(formsemestre_id) if not (formsemestre.formation.is_apc() and formsemestre.semestre_id % 2 == 0): raise ScoValueError( "formsemestre_jury_but_recap: réservé aux semestres pairs de BUT" ) return jury_but_recap.formsemestre_saisie_jury_but( - formsemestre, readonly=readonly, selected_etudid=selected_etudid, mode="recap" + formsemestre, read_only=read_only, selected_etudid=selected_etudid, mode="recap" ) diff --git a/bench.py b/bench.py old mode 100644 new mode 100755 diff --git a/pylintrc b/pylintrc old mode 100644 new mode 100755 diff --git a/sco_version.py b/sco_version.py old mode 100644 new mode 100755 index 67757d4b..256794b7 --- a/sco_version.py +++ b/sco_version.py @@ -1,7 +1,7 @@ # -*- mode: python -*- # -*- coding: utf-8 -*- -SCOVERSION = "9.3.5" +SCOVERSION = "9.3.16" SCONAME = "ScoDoc" diff --git a/tests/api/exemple-api-basic.py b/tests/api/exemple-api-basic.py index a581a878..50b4a11f 100644 --- a/tests/api/exemple-api-basic.py +++ b/tests/api/exemple-api-basic.py @@ -33,7 +33,7 @@ except NameError: load_dotenv(os.path.join(BASEDIR, ".env")) CHK_CERT = bool(int(os.environ.get("CHECK_CERTIFICATE", False))) -SCODOC_URL = os.environ["SCODOC_URL"] or "http://localhost:5000" +SCODOC_URL = os.environ.get("SCODOC_URL") or "http://localhost:5000" API_URL = SCODOC_URL + "/ScoDoc/api" SCODOC_USER = os.environ["SCODOC_USER"] SCODOC_PASSWORD = os.environ["SCODOC_PASSWORD"] @@ -85,13 +85,13 @@ if r.status_code != 200: print(f"{len(r.json())} étudiants courants") # Bulletin d'un BUT -formsemestre_id = 1052 # A adapter -etudid = 16400 +formsemestre_id = 1063 # A adapter +etudid = 16450 bul = GET(f"/etudiant/etudid/{etudid}/formsemestre/{formsemestre_id}/bulletin") # d'un DUT -formsemestre_id = 1028 # A adapter -etudid = 14721 +formsemestre_id = 1062 # A adapter +etudid = 16309 bul_dut = GET(f"/etudiant/etudid/{etudid}/formsemestre/{formsemestre_id}/bulletin") diff --git a/tests/api/test_api_formsemestre.py b/tests/api/test_api_formsemestre.py index 5853268e..533455c5 100644 --- a/tests/api/test_api_formsemestre.py +++ b/tests/api/test_api_formsemestre.py @@ -21,14 +21,36 @@ import requests from app.api.formsemestres import formsemestre from tests.api.setup_test_api import API_URL, CHECK_CERTIFICATE, api_headers + from tests.api.tools_test_api import ( - MODIMPL_FIELDS, verify_fields, + MODIMPL_FIELDS, EVAL_FIELDS, SAISIE_NOTES_FIELDS, FORMSEMESTRE_ETUS_FIELDS, + FSEM_FIELDS, + FSEM_FIELDS, + UE_FIELDS, + MODULE_FIELDS, + FORMSEMESTRE_BULLETINS_FIELDS, + FORMSEMESTRE_BULLETINS_ETU_FIELDS, + FORMSEMESTRE_BULLETINS_FORMATION_FIELDS, + FORMSEMESTRE_BULLETINS_OPT_FIELDS, + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_FIELDS, + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_FIELDS, + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_POIDS_FIELDS, + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_NOTE_FIELDS, + BULLETIN_UES_UE_FIELDS, + BULLETIN_UES_UE_MOYENNE_FIELDS, + BULLETIN_UES_UE_RESSOURCES_RESSOURCE_FIELDS, + BULLETIN_UES_UE_SAES_SAE_FIELDS, + BULLETIN_UES_UE_ECTS_FIELDS, + BULLETIN_SEMESTRE_FIELDS, + BULLETIN_SEMESTRE_ABSENCES_FIELDS, + BULLETIN_SEMESTRE_ECTS_FIELDS, + BULLETIN_SEMESTRE_NOTES_FIELDS, + BULLETIN_SEMESTRE_RANG_FIELDS, ) -from tests.api.tools_test_api import FSEM_FIELDS, UE_FIELDS, MODULE_FIELDS # Etudiant pour les tests ETUDID = 1 @@ -143,26 +165,318 @@ def test_formsemestre_apo(api_headers): assert isinstance(formsemestre["titre"], str) ### ERROR ### - etape_apo_inexistante = "aoefiaozidaoẑidjnoaiznjd" - r_error = requests.get( - f"{API_URL}/formsemestre/apo/{etape_apo_inexistante}", - headers=api_headers, - verify=CHECK_CERTIFICATE, - ) - assert r_error.status_code == 404 + # etape_apo_inexistante = "aoefiaozidaoẑidjnoaiznjd" + # r_error = requests.get( + # f"{API_URL}/formsemestre/apo/{etape_apo_inexistante}", + # headers=api_headers, + # verify=CHECK_CERTIFICATE, + # ) + # assert r_error.status_code == 404 def test_bulletins(api_headers): """ Route: /formsemestre//bulletins """ + formsemestre_id = 1 r = requests.get( - API_URL + "/formsemestre/1/bulletins", + f"{API_URL}/formsemestre/{formsemestre_id}/bulletins", headers=api_headers, verify=CHECK_CERTIFICATE, ) assert r.status_code == 200 + bulletins = r.json() + + assert isinstance(bulletins, list) + for bul in bulletins: + assert verify_fields(bul, FORMSEMESTRE_BULLETINS_FIELDS) is True + assert isinstance(bul["version"], str) + assert isinstance(bul["type"], str) + assert isinstance(bul["date"], str) + assert isinstance(bul["publie"], bool) + assert isinstance(bul["etudiant"], dict) + assert isinstance(bul["formation"], dict) + assert isinstance(bul["formsemestre_id"], int) + assert isinstance(bul["etat_inscription"], str) + assert isinstance(bul["options"], dict) + assert isinstance(bul["ressources"], dict) + assert isinstance(bul["saes"], dict) + assert isinstance(bul["ues"], dict) + assert isinstance(bul["semestre"], dict) + + formsemestre_id_bul = bul["formsemestre_id"] + assert formsemestre_id == formsemestre_id_bul + + etudiant = bul["etudiant"] + assert verify_fields(etudiant, FORMSEMESTRE_BULLETINS_ETU_FIELDS) is True + assert isinstance(etudiant["civilite"], str) + assert isinstance(etudiant["code_ine"], str) + assert isinstance(etudiant["code_nip"], str) + assert isinstance(etudiant["date_naissance"], str) + assert isinstance(etudiant["dept_id"], int) + assert isinstance(etudiant["dept_acronym"], str) + assert isinstance(etudiant["email"], str) + assert isinstance(etudiant["emailperso"], str) + assert isinstance(etudiant["etudid"], int) + assert isinstance(etudiant["nom"], str) + assert isinstance(etudiant["prenom"], str) + assert isinstance(etudiant["nomprenom"], str) + assert isinstance(etudiant["lieu_naissance"], str) + assert isinstance(etudiant["dept_naissance"], str) + assert isinstance(etudiant["nationalite"], str) + assert isinstance(etudiant["boursier"], str) + assert isinstance(etudiant["fiche_url"], str) + assert isinstance(etudiant["photo_url"], str) + assert isinstance(etudiant["id"], int) + assert isinstance(etudiant["codepostaldomicile"], str) + assert isinstance(etudiant["paysdomicile"], str) + assert isinstance(etudiant["telephonemobile"], str) + assert isinstance(etudiant["typeadresse"], str) + assert isinstance(etudiant["domicile"], str) + assert isinstance(etudiant["villedomicile"], str) + assert isinstance(etudiant["telephone"], str) + assert isinstance(etudiant["fax"], str) + assert isinstance(etudiant["description"], str) + + formation = bul["formation"] + assert verify_fields(formation, FORMSEMESTRE_BULLETINS_FORMATION_FIELDS) is True + assert isinstance(formation["id"], int) + assert isinstance(formation["acronyme"], str) + assert isinstance(formation["titre_officiel"], str) + assert isinstance(formation["titre"], str) + + options = bul["options"] + assert verify_fields(options, FORMSEMESTRE_BULLETINS_OPT_FIELDS) is True + assert isinstance(options["show_abs"], bool) + assert isinstance(options["show_abs_modules"], bool) + assert isinstance(options["show_ects"], bool) + assert isinstance(options["show_codemodules"], bool) + assert isinstance(options["show_matieres"], bool) + assert isinstance(options["show_rangs"], bool) + assert isinstance(options["show_ue_rangs"], bool) + assert isinstance(options["show_mod_rangs"], bool) + assert isinstance(options["show_moypromo"], bool) + assert isinstance(options["show_minmax"], bool) + assert isinstance(options["show_minmax_mod"], bool) + assert isinstance(options["show_minmax_eval"], bool) + assert isinstance(options["show_coef"], bool) + assert isinstance(options["show_ue_cap_details"], bool) + assert isinstance(options["show_ue_cap_current"], bool) + assert isinstance(options["show_temporary"], bool) + assert isinstance(options["temporary_txt"], str) + assert isinstance(options["show_uevalid"], bool) + assert isinstance(options["show_date_inscr"], bool) + + bulletin_ressources = bul["ressources"] + assert isinstance(bulletin_ressources, dict) + + for ressource in bulletin_ressources.values(): + assert ( + verify_fields( + ressource, BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_FIELDS + ) + is True + ) + assert isinstance(ressource, dict) + assert isinstance(ressource["evaluations"], list) + for evaluation in ressource["evaluations"]: + assert ( + verify_fields( + evaluation, + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_FIELDS, + ) + is True + ) + assert isinstance(evaluation["id"], int) + assert evaluation["description"] is None or isinstance( + evaluation["description"], str + ) + assert evaluation["date"] is None or isinstance(evaluation["date"], str) + assert isinstance(evaluation["heure_debut"], str) + assert isinstance(evaluation["heure_fin"], str) + assert isinstance(evaluation["coef"], str) + assert isinstance(evaluation["poids"], dict) + assert isinstance(evaluation["note"], dict) + assert isinstance(evaluation["url"], str) + + assert ( + verify_fields( + evaluation["poids"], + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_POIDS_FIELDS, + ) + is True + ) + assert isinstance(evaluation["poids"]["RT1.1"], float) + assert isinstance(evaluation["poids"]["RT2.1"], float) + assert isinstance(evaluation["poids"]["RT3.1"], float) + + assert ( + verify_fields( + evaluation["note"], + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_NOTE_FIELDS, + ) + is True + ) + assert isinstance(evaluation["note"]["value"], str) + assert isinstance(evaluation["note"]["min"], str) + assert isinstance(evaluation["note"]["max"], str) + assert isinstance(evaluation["note"]["moy"], str) + + bulletin_saes = bul["saes"] + assert isinstance(bulletin_saes, dict) + + for sae in bulletin_saes.values(): + assert ( + verify_fields(sae, BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_FIELDS) + is True + ) + assert isinstance(sae, dict) + assert isinstance(sae["evaluations"], list) + for evaluation in sae["evaluations"]: + assert ( + verify_fields( + evaluation, + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_FIELDS, + ) + is True + ) + assert isinstance(evaluation["id"], int) + assert evaluation["description"] is None or isinstance( + evaluation["description"], str + ) + assert evaluation["date"] is None or isinstance(evaluation["date"], str) + assert isinstance(evaluation["heure_debut"], str) + assert isinstance(evaluation["heure_fin"], str) + assert isinstance(evaluation["coef"], str) + assert isinstance(evaluation["poids"], dict) + assert isinstance(evaluation["note"], dict) + assert isinstance(evaluation["url"], str) + + assert ( + verify_fields( + evaluation["poids"], + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_POIDS_FIELDS, + ) + is True + ) + assert isinstance(evaluation["poids"]["RT1.1"], float) + assert isinstance(evaluation["poids"]["RT2.1"], float) + assert isinstance(evaluation["poids"]["RT3.1"], float) + + assert ( + verify_fields( + evaluation["note"], + BULLETIN_RESSOURCES_ET_SAES_RESSOURCE_ET_SAE_EVALUATION_NOTE_FIELDS, + ) + is True + ) + assert isinstance(evaluation["note"]["value"], str) + assert isinstance(evaluation["note"]["min"], str) + assert isinstance(evaluation["note"]["max"], str) + assert isinstance(evaluation["note"]["moy"], str) + + bulletin_ues = bul["ues"] + assert isinstance(bulletin_ues, dict) + + for (key_ue, value_ue) in bulletin_ues.items(): + assert verify_fields(value_ue, BULLETIN_UES_UE_FIELDS) is True + assert isinstance(value_ue["id"], int) + assert isinstance(value_ue["titre"], str) + assert isinstance(value_ue["numero"], int) + assert isinstance(value_ue["type"], int) + assert isinstance(value_ue["color"], str) + assert value_ue["competence"] is None or isinstance( + value_ue["competence"], str + ) + assert isinstance(value_ue["moyenne"], dict) + assert isinstance(value_ue["bonus"], str) + assert isinstance(value_ue["malus"], str) + assert value_ue["capitalise"] is None or isinstance( + value_ue["capitalise"], str + ) + assert isinstance(value_ue["ressources"], dict) + assert isinstance(value_ue["saes"], dict) + assert isinstance(value_ue["ECTS"], dict) + + assert ( + verify_fields(value_ue["moyenne"], BULLETIN_UES_UE_MOYENNE_FIELDS) + is True + ) + assert isinstance(value_ue["moyenne"]["value"], str) + assert isinstance(value_ue["moyenne"]["min"], str) + assert isinstance(value_ue["moyenne"]["max"], str) + assert isinstance(value_ue["moyenne"]["moy"], str) + assert isinstance(value_ue["moyenne"]["rang"], str) + assert isinstance(value_ue["moyenne"]["total"], int) + + for ressource in value_ue["ressources"].values(): + assert ( + verify_fields( + ressource, BULLETIN_UES_UE_RESSOURCES_RESSOURCE_FIELDS + ) + is True + ) + assert isinstance(ressource["id"], int) + assert isinstance(ressource["coef"], float) + assert isinstance(ressource["moyenne"], str) + + for sae in value_ue["saes"].values(): + assert verify_fields(sae, BULLETIN_UES_UE_SAES_SAE_FIELDS) is True + assert isinstance(sae["id"], int) + assert isinstance(sae["coef"], float) + assert isinstance(sae["moyenne"], str) + + assert verify_fields(value_ue["ECTS"], BULLETIN_UES_UE_ECTS_FIELDS) is True + assert isinstance(value_ue["ECTS"]["acquis"], float) + assert isinstance(value_ue["ECTS"]["total"], float) + + bulletin_semestre = bul["semestre"] + assert verify_fields(bulletin_semestre, BULLETIN_SEMESTRE_FIELDS) is True + assert isinstance(bulletin_semestre["etapes"], list) + assert isinstance(bulletin_semestre["date_debut"], str) + assert isinstance(bulletin_semestre["date_fin"], str) + assert isinstance(bulletin_semestre["annee_universitaire"], str) + assert isinstance(bulletin_semestre["numero"], int) + assert isinstance(bulletin_semestre["inscription"], str) + assert isinstance(bulletin_semestre["groupes"], list) + assert isinstance(bulletin_semestre["absences"], dict) + assert isinstance(bulletin_semestre["ECTS"], dict) + assert isinstance(bulletin_semestre["notes"], dict) + assert isinstance(bulletin_semestre["rang"], dict) + + assert ( + verify_fields( + bulletin_semestre["absences"], BULLETIN_SEMESTRE_ABSENCES_FIELDS + ) + is True + ) + assert isinstance(bulletin_semestre["absences"]["injustifie"], int) + assert isinstance(bulletin_semestre["absences"]["total"], int) + + assert ( + verify_fields(bulletin_semestre["ECTS"], BULLETIN_SEMESTRE_ECTS_FIELDS) + is True + ) + assert isinstance(bulletin_semestre["ECTS"]["acquis"], int) + assert isinstance(bulletin_semestre["ECTS"]["total"], float) + + assert ( + verify_fields(bulletin_semestre["notes"], BULLETIN_SEMESTRE_NOTES_FIELDS) + is True + ) + assert isinstance(bulletin_semestre["notes"]["value"], str) + assert isinstance(bulletin_semestre["notes"]["min"], str) + assert isinstance(bulletin_semestre["notes"]["max"], str) + assert isinstance(bulletin_semestre["notes"]["moy"], str) + + assert ( + verify_fields(bulletin_semestre["rang"], BULLETIN_SEMESTRE_RANG_FIELDS) + is True + ) + assert isinstance(bulletin_semestre["rang"]["value"], str) + assert isinstance(bulletin_semestre["rang"]["total"], int) + # # jury # def test_jury(): diff --git a/tests/api/tools_test_api.py b/tests/api/tools_test_api.py index 438c1db5..bf50cbae 100644 --- a/tests/api/tools_test_api.py +++ b/tests/api/tools_test_api.py @@ -641,3 +641,79 @@ PARTITIONS_GROUPS_ETU_FIELDS = { "ne", "email_default", } + +FORMSEMESTRE_BULLETINS_FIELDS = { + "version", + "type", + "date", + "publie", + "etudiant", + "formation", + "formsemestre_id", + "etat_inscription", + "options", + "ressources", + "saes", + "ues", + "semestre", +} + +FORMSEMESTRE_BULLETINS_ETU_FIELDS = { + "civilite", + "code_ine", + "code_nip", + "date_naissance", + "dept_id", + "dept_acronym", + "email", + "emailperso", + "etudid", + "nom", + "prenom", + "nomprenom", + "lieu_naissance", + "dept_naissance", + "nationalite", + "boursier", + "fiche_url", + "photo_url", + "id", + "codepostaldomicile", + "paysdomicile", + "telephonemobile", + "typeadresse", + "domicile", + "villedomicile", + "telephone", + "fax", + "description", +} + +FORMSEMESTRE_BULLETINS_FORMATION_FIELDS = { + "id", + "acronyme", + "titre_officiel", + "titre", +} + +FORMSEMESTRE_BULLETINS_OPT_FIELDS = { + "show_abs", + "show_abs_modules", + "show_ects", + "show_codemodules", + "show_matieres", + "show_rangs", + "show_ue_rangs", + "show_mod_rangs", + "show_moypromo", + "show_minmax", + "show_minmax_mod", + "show_minmax_eval", + "show_coef", + "show_ue_cap_details", + "show_ue_cap_current", + "show_temporary", + "temporary_txt", + "show_uevalid", + "show_date_inscr", +}