Skip to content
Snippets Groups Projects
Select Git revision
  • 26b59ee54792d1156ee8d1559fe74017c6d8b56a
  • master default protected
2 results

jury_validations.py

Blame
  • Forked from Jean-Marie Place / SCODOC_R6A06
    440 commits behind the upstream repository.
    jury_validations.py 33.30 KiB
    ##############################################################################
    #
    # ScoDoc
    #
    # Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
    #
    # This program is free software; you can redistribute it and/or modify
    # it under the terms of the GNU General Public License as published by
    # the Free Software Foundation; either version 2 of the License, or
    # (at your option) any later version.
    #
    # This program is distributed in the hope that it will be useful,
    # but WITHOUT ANY WARRANTY; without even the implied warranty of
    # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    # GNU General Public License for more details.
    #
    # You should have received a copy of the GNU General Public License
    # along with this program; if not, write to the Free Software
    # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
    #
    #   Emmanuel Viennet      emmanuel.viennet@viennet.net
    #
    ##############################################################################
    
    """
    Vues sur les jurys et validations
    
    Emmanuel Viennet, 2024
    """
    from collections import defaultdict
    import datetime
    import flask
    from flask import flash, g, redirect, render_template, request, url_for
    from flask_login import current_user
    
    from app import log
    from app.but import (
        cursus_but,
        jury_edit_manual,
        jury_but,
        jury_but_validation_auto,
        jury_but_view,
    )
    from app.but.forms import jury_but_forms
    from app.comp import jury
    from app.decorators import (
        scodoc,
        scodoc7func,
        permission_required,
    )
    from app.models import (
        Evaluation,
        Formation,
        FormSemestre,
        FormSemestreInscription,
        Identite,
        ScolarAutorisationInscription,
        ScolarFormSemestreValidation,
        ScolarNews,
        ScoDocSiteConfig,
    )
    from app.scodoc import (
        html_sco_header,
        sco_bulletins_json,
        sco_cache,
        sco_formsemestre_exterieurs,
        sco_formsemestre_validation,
        sco_preferences,
    )
    from app.scodoc.codes_cursus import CODES_UE_VALIDES
    from app.scodoc import sco_utils as scu
    from app.scodoc.sco_exceptions import (
        ScoPermissionDenied,
        ScoValueError,
    )
    from app.scodoc.sco_permissions import Permission
    from app.scodoc.sco_pv_dict import descr_autorisations
    
    # from app.scodoc.TrivialFormulator import TrivialFormulator
    from app.views import notes_bp as bp
    from app.views import ScoData
    
    # --- FORMULAIRE POUR VALIDATION DES UE ET SEMESTRES
    
    
    @bp.route("/formsemestre_validation_etud_form")
    @scodoc
    @permission_required(Permission.ScoView)
    @scodoc7func
    def formsemestre_validation_etud_form(
        formsemestre_id,
        etudid=None,
        etud_index=None,
        check=0,
        desturl="",
        sortcol=None,
    ):
        "Formulaire choix jury pour un étudiant"
        formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
        read_only = not formsemestre.can_edit_jury()
        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,
            etud_index=etud_index,
            check=check,
            read_only=read_only,
            dest_url=desturl,
            sortcol=sortcol,
        )
    
    
    @bp.route("/formsemestre_validation_etud")
    @scodoc
    @permission_required(Permission.ScoView)
    @scodoc7func
    def formsemestre_validation_etud(
        formsemestre_id,
        etudid=None,
        codechoice=None,
        desturl="",
        sortcol=None,
    ):
        "Enregistre choix jury pour un étudiant"
        formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
        if not formsemestre.can_edit_jury():
            raise ScoPermissionDenied(
                dest_url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                )
            )
    
        return sco_formsemestre_validation.formsemestre_validation_etud(
            formsemestre_id,
            etudid=etudid,
            codechoice=codechoice,
            desturl=desturl,
            sortcol=sortcol,
        )
    
    
    @bp.route("/formsemestre_validation_etud_manu")
    @scodoc
    @permission_required(Permission.ScoView)
    @scodoc7func
    def formsemestre_validation_etud_manu(
        formsemestre_id,
        etudid=None,
        code_etat="",
        new_code_prev="",
        devenir="",
        assidu=False,
        desturl="",
        sortcol=None,
    ):
        "Enregistre choix jury pour un étudiant"
        formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
        if not formsemestre.can_edit_jury():
            raise ScoPermissionDenied(
                dest_url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                )
            )
    
        return sco_formsemestre_validation.formsemestre_validation_etud_manu(
            formsemestre_id,
            etudid=etudid,
            code_etat=code_etat,
            new_code_prev=new_code_prev,
            devenir=devenir,
            assidu=assidu,
            desturl=desturl,
            sortcol=sortcol,
        )
    
    
    # --- Jurys BUT
    @bp.route(
        "/formsemestre_validation_but/<int:formsemestre_id>/<etudid>",
        methods=["GET", "POST"],
    )
    @scodoc
    @permission_required(Permission.ScoView)
    def formsemestre_validation_but(
        formsemestre_id: int,
        etudid: int,
    ):
        "Form. saisie décision jury semestre BUT"
        formsemestre: FormSemestre = FormSemestre.query.filter_by(
            id=formsemestre_id, dept_id=g.scodoc_dept_id
        ).first_or_404()
        # la route ne donne pas le type d'etudid pour pouvoir construire des URLs
        # provisoires avec NEXT et PREV
        try:
            etudid = int(etudid)
        except ValueError as exc:
            raise ScoValueError("adresse invalide") from exc
        etud = Identite.get_etud(etudid)
        nb_etuds = formsemestre.etuds.count()
        read_only = not formsemestre.can_edit_jury()
        can_erase = current_user.has_permission(Permission.EtudInscrit)
        # --- Navigation
        prev_lnk = (
            f"""{scu.EMO_PREV_ARROW}&nbsp;<a href="{url_for(
                    "notes.formsemestre_validation_but", scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id, etudid="PREV"
                )}" class="stdlink"">précédent</a>
        """
            if nb_etuds > 1
            else ""
        )
        next_lnk = (
            f"""<a href="{url_for(
                    "notes.formsemestre_validation_but", scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id, etudid="NEXT"
                )}" class="stdlink"">suivant</a>&nbsp;{scu.EMO_NEXT_ARROW}
        """
            if nb_etuds > 1
            else ""
        )
        navigation_div = f"""
        <div class="but_navigation">
            <div class="prev">
               {prev_lnk}
            </div>
            <div class="back_list">
                <a href="{
                    url_for(
                        "notes.formsemestre_recapcomplet",
                        scodoc_dept=g.scodoc_dept,
                        formsemestre_id=formsemestre_id,
                        mode_jury=1,
                        selected_etudid=etud.id
                )}" class="stdlink">retour à la liste</a>
            </div>
            <div class="next">
                {next_lnk}
            </div>
        </div>
        """
    
        H = [
            html_sco_header.sco_header(
                page_title=f"Validation BUT S{formsemestre.semestre_id}",
                formsemestre_id=formsemestre_id,
                etudid=etudid,
                cssstyles=[
                    "css/jury_but.css",
                    "css/cursus_but.css",
                ],
                javascripts=("js/jury_but.js",),
            ),
            """<div class="jury_but">
            """,
        ]
        inscription = formsemestre.etuds_inscriptions.get(etudid)
        if not inscription:
            raise ScoValueError("étudiant non inscrit au semestre")
        if inscription.etat != scu.INSCRIT:
            return (
                "\n".join(H)
                + f"""
                <div>
                    <div class="bull_head">
                    <div>
                        <div class="titre_parcours">Jury BUT</div>
                        <div class="nom_etud">{etud.html_link_fiche()}</div>
                    </div>
                    <div class="bull_photo"><a href="{
                        etud.url_fiche()
                        }">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
                    </div>
                    </div>
                    <div class="warning">Impossible de statuer sur cet étudiant:
                            il est démissionnaire ou défaillant (voir <a class="stdlink" href="{
                        etud.url_fiche()}">sa fiche</a>)
                    </div>
                </div>
                {navigation_div}
                </div>
            """
                + html_sco_header.sco_footer()
            )
    
        deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
        has_notes_en_attente = deca.has_notes_en_attente()
        evaluations_a_debloquer = Evaluation.get_evaluations_blocked_for_etud(
            formsemestre, etud
        )
        if has_notes_en_attente or evaluations_a_debloquer:
            read_only = True
        if request.method == "POST":
            if not read_only:
                deca.record_form(request.form)
                ScolarNews.add(
                    typ=ScolarNews.NEWS_JURY,
                    obj=formsemestre.id,
                    text=f"""Saisie décision jury dans {formsemestre.html_link_status()}""",
                    url=url_for(
                        "notes.formsemestre_status",
                        scodoc_dept=g.scodoc_dept,
                        formsemestre_id=formsemestre.id,
                    ),
                )
                flash("codes enregistrés")
            return flask.redirect(
                url_for(
                    "notes.formsemestre_validation_but",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                    etudid=etudid,
                )
            )
    
        warning = ""
        if len(deca.niveaux_competences) != len(deca.decisions_rcue_by_niveau):
            warning += f"""<div class="warning">Attention: {len(deca.niveaux_competences)}
                    niveaux mais {len(deca.decisions_rcue_by_niveau)} regroupements RCUE.</div>"""
        if (deca.parcour is None) and len(formsemestre.parcours) > 0:
            warning += (
                """<div class="warning">L'étudiant n'est pas inscrit à un parcours.</div>"""
            )
        if formsemestre.date_fin - datetime.date.today() > datetime.timedelta(days=12):
            # encore loin de la fin du semestre de départ de ce jury ?
            warning += f"""<div class="warning">Le semestre S{formsemestre.semestre_id}
                terminera le {formsemestre.date_fin.strftime(scu.DATE_FMT)}&nbsp;:
                êtes-vous certain de vouloir enregistrer une décision de jury&nbsp;?
                </div>"""
    
        if deca.formsemestre_impair:
            inscription = deca.formsemestre_impair.etuds_inscriptions.get(etud.id)
            if (not inscription) or inscription.etat != scu.INSCRIT:
                etat_ins = scu.ETATS_INSCRIPTION.get(inscription.etat, "inconnu?")
                warning += f"""<div class="warning">{etat_ins}
                    en S{deca.formsemestre_impair.semestre_id}</div>"""
    
        if deca.formsemestre_pair:
            inscription = deca.formsemestre_pair.etuds_inscriptions.get(etud.id)
            if (not inscription) or inscription.etat != scu.INSCRIT:
                etat_ins = scu.ETATS_INSCRIPTION.get(inscription.etat, "inconnu?")
                warning += f"""<div class="warning">{etat_ins}
                    en S{deca.formsemestre_pair.semestre_id}</div>"""
    
        if has_notes_en_attente:
            warning += f"""<div class="warning-bloquant">{etud.html_link_fiche()
                } a des notes en ATTente dans les modules suivants.
                Vous devez régler cela avant de statuer en jury !
                <ul class="modimpls_att">
                """
            for modimpl in deca.get_modimpls_attente():
                warning += f"""<li><a href="{
                        url_for("notes.moduleimpl_status", scodoc_dept=g.scodoc_dept, moduleimpl_id=modimpl.id)
                    }" class="stdlink">{modimpl.module.code} {modimpl.module.titre_str()}</a></li>"""
            warning += "</ul></div>"
        if evaluations_a_debloquer:
            links_evals = [
                f"""<a class="stdlink" href="{url_for(
                        'notes.evaluation_listenotes', scodoc_dept=g.scodoc_dept, evaluation_id=e.id
                    )}">{e.description} en {e.moduleimpl.module.code}</a>"""
                for e in evaluations_a_debloquer
            ]
            warning += f"""<div class="warning-bloquant">Impossible de statuer sur cet étudiant:
                    il a des notes dans des évaluations qui seront débloquées plus tard:
                    voir {", ".join(links_evals)}
                    """
    
        if warning:
            warning = f"""<div class="jury_but_warning jury_but_box">{warning}</div>"""
        H.append(
            f"""
        <div>
            <div class="bull_head">
            <div>
                <div class="titre_parcours">Jury BUT{deca.annee_but}
                - Parcours {(deca.parcour.libelle if deca.parcour else False) or "non spécifié"}
                - {deca.annee_scolaire_str()}</div>
                <div class="nom_etud">{etud.html_link_fiche()}</div>
            </div>
            <div class="bull_photo"><a href="{
                etud.url_fiche()}">{etud.photo_html(title="fiche de " + etud.nomprenom)}</a>
            </div>
            </div>
            {warning}
        </div>
    
        <form method="post" class="jury_but_box" id="jury_but">
        """
        )
    
        H.append(jury_but_view.show_etud(deca, read_only=read_only))
    
        autorisations_idx = deca.get_autorisations_passage()
        div_autorisations_passage = (
            f"""
            <div class="but_autorisations_passage">
                <span>Autorisé à passer en&nbsp;:</span>
                { ", ".join( ["S" + str(i) for i in autorisations_idx ] )}
            </div>
        """
            if autorisations_idx
            else """<div class="but_autorisations_passage but_explanation">
                pas d'autorisations de passage enregistrées.
                </div>
                """
        )
        H.append(div_autorisations_passage)
    
        if read_only:
            H.append(
                f"""
                <div class="but_explanation">
                {"Vous n'avez pas la permission de modifier ces décisions."
                if formsemestre.etat
                else "Semestre verrouillé."}
                Les champs entourés en vert sont enregistrés.
                </div>"""
            )
        else:
            erase_span = f"""
                <a style="margin-left: 16px;" class="stdlink {'' if can_erase else 'link_unauthorized'}"
                title="{'' if can_erase else 'réservé au responsable'}"
                href="{
                    url_for("notes.erase_decisions_annee_formation",
                    scodoc_dept=g.scodoc_dept, formation_id=deca.formsemestre.formation.id,
                    etudid=deca.etud.id, annee=deca.annee_but, formsemestre_id=formsemestre_id)
                    if can_erase else ''
                    }"
                >effacer des décisions de jury</a>
    
                <a style="margin-left: 16px;" class="stdlink"
                href="{
                    url_for("notes.formsemestre_validate_previous_ue",
                    scodoc_dept=g.scodoc_dept,
                    etudid=deca.etud.id, formsemestre_id=formsemestre_id)}"
                >enregistrer des UEs antérieures</a>
    
                <a style="margin-left: 16px;" class="stdlink"
                href="{
                    url_for("notes.validate_dut120_etud",
                    scodoc_dept=g.scodoc_dept,
                    etudid=deca.etud.id, formsemestre_id=formsemestre_id)}"
                >décerner le DUT "120ECTS"</a>
                """
            H.append(
                f"""<div class="but_settings">
                <input type="checkbox" onchange="enable_manual_codes(this)">
                    <em>permettre la saisie manuelles des codes
                    {"d'année et " if deca.jury_annuel else ""}
                    de niveaux.
                    Dans ce cas, assurez-vous de la cohérence entre les codes d'UE/RCUE/Année !
                    </em>
                </input>
                </div>
    
                <div class="but_buttons">
                    <span><input type="submit" value="Enregistrer ces décisions"></span>
                    <span>{erase_span}</span>
                </div>
                """
            )
        H.append(navigation_div)
        H.append("</form>")
    
        # Affichage cursus BUT
        but_cursus = cursus_but.EtudCursusBUT(etud, deca.formsemestre.formation)
        H += [
            """<div class="jury_but_box">
            <div class="jury_but_box_title"><b>Niveaux de compétences enregistrés :</b></div>
            """,
            render_template(
                "but/cursus_etud.j2",
                cursus=but_cursus,
                scu=scu,
            ),
            "</div>",
        ]
        H.append(
            render_template(
                "but/documentation_codes_jury.j2",
                nom_univ=f"""Export {sco_preferences.get_preference("InstituteName")
                or sco_preferences.get_preference("UnivName")
                or "Apogée"}""",
                codes=ScoDocSiteConfig.get_codes_apo_dict(),
            )
        )
        H.append(
            f"""<div class="but_doc_codes but_warning_rcue_cap">
        {scu.EMO_WARNING} Rappel: pour les redoublants, seules les UE <b>capitalisées</b> (note > 10)
        lors d'une année précédente peuvent être prise en compte pour former
        un RCUE (associé à un niveau de compétence du BUT).
        </div>
        """
        )
        return "\n".join(H) + html_sco_header.sco_footer()
    
    
    @bp.route(
        "/formsemestre_validation_auto_but/<int:formsemestre_id>", methods=["GET", "POST"]
    )
    @scodoc
    @permission_required(Permission.ScoView)
    def formsemestre_validation_auto_but(formsemestre_id: int = None):
        "Saisie automatique des décisions de jury BUT"
        formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
        if not formsemestre.can_edit_jury():
            raise ScoPermissionDenied(
                dest_url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                )
            )
        if not formsemestre.formation.is_apc():
            raise ScoValueError(
                "formsemestre_validation_auto_but est réservé aux formations APC"
            )
    
        formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
        form = jury_but_forms.FormSemestreValidationAutoBUTForm()
        if request.method == "POST":
            if not form.cancel.data:
                nb_etud_modif, _ = (
                    jury_but_validation_auto.formsemestre_validation_auto_but(formsemestre)
                )
                flash(f"Décisions enregistrées ({nb_etud_modif} étudiants modifiés)")
            return redirect(
                url_for(
                    "notes.formsemestre_recapcomplet",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                    mode_jury=1,
                )
            )
        # Avertissement si formsemestre impair
        formsemestres_suspects = {}
        if formsemestre.semestre_id % 2:
            _, decas = jury_but_validation_auto.formsemestre_validation_auto_but(
                formsemestre, dry_run=True
            )
            # regarde si il y a des semestres pairs postérieurs qui ne soient pas bloqués
            formsemestres_suspects = {
                deca.formsemestre_pair.id: deca.formsemestre_pair
                for deca in decas
                if deca.formsemestre_pair
                and deca.formsemestre_pair.date_debut > formsemestre.date_debut
                and not deca.formsemestre_pair.block_moyennes
            }
    
        return render_template(
            "but/formsemestre_validation_auto_but.j2",
            form=form,
            formsemestres_suspects=formsemestres_suspects,
            sco=ScoData(formsemestre=formsemestre),
            title="Calcul automatique jury BUT",
        )
    
    
    @bp.route(
        "/formsemestre_validate_previous_ue/<int:formsemestre_id>/<int:etudid>",
        methods=["GET", "POST"],
    )
    @scodoc
    @permission_required(Permission.ScoView)
    def formsemestre_validate_previous_ue(formsemestre_id, etudid=None):
        "Form. saisie UE validée hors ScoDoc"
        formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
        if not formsemestre.can_edit_jury():
            raise ScoPermissionDenied(
                dest_url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                )
            )
        etud: Identite = (
            Identite.query.filter_by(id=etudid)
            .join(FormSemestreInscription)
            .filter_by(formsemestre_id=formsemestre_id)
            .first_or_404()
        )
    
        return sco_formsemestre_validation.formsemestre_validate_previous_ue(
            formsemestre, etud
        )
    
    
    @bp.route("/formsemestre_ext_edit_ue_validations", methods=["GET", "POST"])
    @scodoc
    @permission_required(Permission.ScoView)
    @scodoc7func
    def formsemestre_ext_edit_ue_validations(formsemestre_id, etudid=None):
        "Form. edition UE semestre extérieur"
        formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
        if not formsemestre.can_edit_jury():
            raise ScoPermissionDenied(
                dest_url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                )
            )
    
        return sco_formsemestre_exterieurs.formsemestre_ext_edit_ue_validations(
            formsemestre_id, etudid
        )
    
    
    @bp.route("/formsemestre_validation_auto")
    @scodoc
    @permission_required(Permission.ScoView)
    @scodoc7func
    def formsemestre_validation_auto(formsemestre_id):
        "Formulaire saisie automatisee des decisions d'un semestre"
        formsemestre: FormSemestre = FormSemestre.query.filter_by(
            id=formsemestre_id, dept_id=g.scodoc_dept_id
        ).first_or_404()
        if not formsemestre.can_edit_jury():
            raise ScoPermissionDenied(
                dest_url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                )
            )
    
        if formsemestre.formation.is_apc():
            return redirect(
                url_for(
                    "notes.formsemestre_validation_auto_but",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre.id,
                )
            )
        return sco_formsemestre_validation.formsemestre_validation_auto(formsemestre_id)
    
    
    @bp.route("/do_formsemestre_validation_auto")
    @scodoc
    @permission_required(Permission.ScoView)
    @scodoc7func
    def do_formsemestre_validation_auto(formsemestre_id):
        "Formulaire saisie automatisee des decisions d'un semestre"
        formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
        if not formsemestre.can_edit_jury():
            raise ScoPermissionDenied(
                dest_url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                )
            )
    
        return sco_formsemestre_validation.do_formsemestre_validation_auto(formsemestre_id)
    
    
    @bp.route("/formsemestre_validation_suppress_etud", methods=["GET", "POST"])
    @scodoc
    @permission_required(Permission.ScoView)
    @scodoc7func
    def formsemestre_validation_suppress_etud(
        formsemestre_id, etudid, dialog_confirmed=False
    ):
        """Suppression des décisions de jury pour un étudiant."""
        formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
        if not formsemestre.can_edit_jury():
            raise ScoPermissionDenied(
                dest_url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                )
            )
        etud = Identite.get_etud(etudid)
        if formsemestre.formation.is_apc():
            next_url = url_for(
                "scolar.fiche_etud",
                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:
            d = sco_bulletins_json.dict_decision_jury(
                etud, formsemestre, with_decisions=True
            )
    
            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:
                descr_annee = "-"
    
            existing = f"""
            <ul>
            <li>Semestre : {d.get("decision", {"code":"-"})['code'] or "-"}</li>
            <li>Année BUT: {descr_annee}</li>
            <li>UEs : {", ".join(descr_ues)}</li>
            <li>RCUEs: {len(d.get("decision_rcue", []))} décisions</li>
            <li>Autorisations: {descr_autorisations(ScolarAutorisationInscription.query.filter_by(origin_formsemestre_id=formsemestre_id,
                etudid=etudid))}
            </ul>
            """
            return scu.confirm_dialog(
                f"""<h2>Confirmer la suppression des décisions du semestre
                {formsemestre.titre_mois()} pour {etud.nomprenom}
                </h2>
                <p>Cette opération est irréversible.</p>
                <div>
                {existing}
                </div>
                """,
                OK="Supprimer",
                dest_url="",
                cancel_url=next_url,
                parameters={"etudid": etudid, "formsemestre_id": formsemestre_id},
            )
    
        sco_formsemestre_validation.formsemestre_validation_suppress_etud(
            formsemestre_id, etudid
        )
        flash("Décisions supprimées")
        return flask.redirect(next_url)
    
    
    @bp.route(
        "/formsemestre_jury_erase/<int:formsemestre_id>",
        methods=["GET", "POST"],
        defaults={"etudid": None},
    )
    @bp.route(
        "/formsemestre_jury_erase/<int:formsemestre_id>/<int:etudid>",
        methods=["GET", "POST"],
    )
    @scodoc
    @permission_required(Permission.ScoView)
    def formsemestre_jury_erase(formsemestre_id: int, etudid: int = None):
        """Supprime toutes les décisions de jury (classique ou BUT) pour cette année.
        Si l'étudiant n'est pas spécifié, efface les décisions de tous les inscrits.
        En BUT, si only_one_sem n'efface que pour le formsemestre indiqué, pas les deux de l'année.
        En classique, n'affecte que les décisions issues de ce formsemestre.
        """
        only_one_sem = int(request.args.get("only_one_sem") or False)
        formsemestre: FormSemestre = FormSemestre.query.filter_by(
            id=formsemestre_id, dept_id=g.scodoc_dept_id
        ).first_or_404()
        if not formsemestre.can_edit_jury():
            raise ScoPermissionDenied(
                dest_url=url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre_id,
                )
            )
        is_apc = formsemestre.formation.is_apc()
        if etudid is None:
            etud = None
            etuds = formsemestre.get_inscrits(include_demdef=True)
            dest_url = url_for(
                "notes.formsemestre_recapcomplet",
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                mode_jury=1,
            )
        else:
            etud = Identite.get_etud(etudid)
            etuds = [etud]
            endpoint = (
                "notes.formsemestre_validation_but"
                if is_apc
                else "notes.formsemestre_validation_etud_form"
            )
            dest_url = url_for(
                endpoint,
                scodoc_dept=g.scodoc_dept,
                formsemestre_id=formsemestre_id,
                etudid=etudid,
            )
        if request.method == "POST":
            with sco_cache.DeferredSemCacheManager():
                for etud in etuds:
                    if is_apc:
                        deca = jury_but.DecisionsProposeesAnnee(etud, formsemestre)
                        deca.erase(only_one_sem=only_one_sem)
                    else:
                        sco_formsemestre_validation.formsemestre_validation_suppress_etud(
                            formsemestre.id, etud.id
                        )
                    log(f"formsemestre_jury_erase({formsemestre_id}, {etud.id})")
            flash(
                (
                    "décisions de jury du semestre effacées"
                    if (only_one_sem or is_apc)
                    else "décisions de jury des semestres de l'année BUT effacées"
                )
                + f" pour {len(etuds)} étudiant{'s' if len(etuds) > 1 else ''}"
            )
            return redirect(dest_url)
    
        return render_template(
            "confirm_dialog.j2",
            title=f"""Effacer les validations de jury {
                ("de " + etud.nomprenom)
                if etud
                else ("des " + str(len(etuds)) + " étudiants inscrits dans ce semestre")
                } ?""",
            explanation=(
                (
                    f"""Les validations d'UE et autorisations de passage
                du semestre S{formsemestre.semestre_id} seront effacées."""
                    if (only_one_sem or is_apc)
                    else """Les validations de toutes les UE, RCUE (compétences) et année
            issues de cette année scolaire seront effacées.
            """
                )
                + """
            <p>Les décisions des années scolaires précédentes ne seront pas modifiées.</p>
            """
                + """
            <p>Efface aussi toutes les validations concernant l'année BUT de ce semestre,
            même si elles ont été acquises ailleurs, ainsi que les validations de DUT en 120 ECTS
            obtenues après BUT1/BUT2.
            </p>
            """
                if is_apc
                else ""
                + """
            <div class="warning">Cette opération est irréversible !
            A n'utiliser que dans des cas exceptionnels, vérifiez bien tous les étudiants ensuite.
            </div>
            """
            ),
            cancel_url=dest_url,
        )
    
    
    @bp.route(
        "/erase_decisions_annee_formation/<int:etudid>/<int:formation_id>/<int:annee>",
        methods=["GET", "POST"],
    )
    @scodoc
    @permission_required(Permission.EtudInscrit)
    def erase_decisions_annee_formation(etudid: int, formation_id: int, annee: int):
        """Efface toute les décisions d'une année pour cet étudiant"""
        etud: Identite = Identite.query.get_or_404(etudid)
        formation: Formation = Formation.query.filter_by(
            id=formation_id, dept_id=g.scodoc_dept_id
        ).first_or_404()
        if request.method == "POST":
            jury.erase_decisions_annee_formation(etud, formation, annee, delete=True)
            flash("Décisions de jury effacées")
            return redirect(
                url_for(
                    "scolar.fiche_etud",
                    scodoc_dept=g.scodoc_dept,
                    etudid=etud.id,
                )
            )
        validations = jury.erase_decisions_annee_formation(etud, formation, annee)
        formsemestre_origine_id = request.args.get("formsemestre_id")
        formsemestre_origine = (
            FormSemestre.query.get_or_404(formsemestre_origine_id)
            if formsemestre_origine_id
            else None
        )
        return render_template(
            "jury/erase_decisions_annee_formation.j2",
            annee=annee,
            cancel_url=url_for(
                "scolar.fiche_etud", scodoc_dept=g.scodoc_dept, etudid=etud.id
            ),
            etud=etud,
            formation=formation,
            formsemestre_origine=formsemestre_origine,
            validations=validations,
            sco=ScoData(),
            title=f"Effacer décisions de jury {etud.nom} - année {annee}",
        )
    
    
    @bp.route(
        "/jury_delete_manual/<int:etudid>",
        methods=["GET", "POST"],
    )
    @scodoc
    @permission_required(Permission.ScoView)
    def jury_delete_manual(etudid: int):
        """Efface toute les décisions d'une année pour cet étudiant"""
        etud = Identite.get_etud(etudid)
        return jury_edit_manual.jury_delete_manual(etud)
    
    
    @bp.route("/etud_bilan_ects/<int:etudid>")
    @scodoc
    @permission_required(Permission.ScoView)
    def etud_bilan_ects(etudid: int):
        """Page bilan de tous els ECTS acquis par un étudiant.
        Plusieurs formations (eg DUT, LP) peuvent être concernées.
        """
        etud = Identite.get_etud(etudid)
        # Cherche les formations différentes (au sens des ECTS)
        # suivies par l'étudiant: regroupe ses formsemestres
        # diplome est la clé: en classique le code formation, en BUT le referentiel_competence_id
        formsemestre_by_diplome = defaultdict(list)
        for formsemestre in etud.get_formsemestres(recent_first=True):
            diplome = (
                formsemestre.formation.referentiel_competence.id
                if (
                    formsemestre.formation.is_apc()
                    and formsemestre.formation.referentiel_competence
                )
                else formsemestre.formation.formation_code
            )
            formsemestre_by_diplome[diplome].append(formsemestre)
    
        # Pour chaque liste de formsemestres d'un même "diplôme"
        # liste les UE validées avec leurs ECTS
        ects_by_diplome = {}
        titre_by_diplome = {}  # { diplome : titre }
        validations_by_diplome = {}  # { diplome : query validations UEs }
        for diplome, formsemestres in formsemestre_by_diplome.items():
            formsemestre = formsemestres[0]
            titre_by_diplome[diplome] = formsemestre.formation.get_titre_version()
            if formsemestre.formation.is_apc():
                validations = cursus_but.but_validations_ues(etud, diplome)
            else:
                validations = ScolarFormSemestreValidation.validations_ues(
                    etud, formsemestre.formation.formation_code
                )
            validations_by_diplome[diplome] = [
                validation
                for validation in validations
                if validation.code in CODES_UE_VALIDES
            ]
            ects_by_diplome[diplome] = sum(
                (validation.ue.ects or 0.0)
                for validation in validations_by_diplome[diplome]
            )
    
        return render_template(
            "jury/etud_bilan_ects.j2",
            etud=etud,
            ects_by_diplome=ects_by_diplome,
            formsemestre_by_diplome=formsemestre_by_diplome,
            titre_by_diplome=titre_by_diplome,
            validations_by_diplome=validations_by_diplome,
        )