Skip to content
Snippets Groups Projects
Select Git revision
  • 1d9c36f8e2437b6f08f86f38b1f9b50f1c7744fe
  • master default protected
2 results

sco_inscr_passage.py

Blame
  • Forked from Jean-Marie Place / SCODOC_R6A06
    Source project has a limited visibility.
    sco_inscr_passage.py 29.96 KiB
    # -*- mode: python -*-
    # -*- coding: utf-8 -*-
    
    ##############################################################################
    #
    # Gestion scolarite IUT
    #
    # 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
    #
    ##############################################################################
    
    """Form. pour inscription rapide des etudiants d'un semestre dans un autre
       Utilise les autorisations d'inscription délivrées en jury.
    """
    import datetime
    from operator import itemgetter
    
    from flask import url_for, g, render_template, request
    
    import app.scodoc.notesdb as ndb
    import app.scodoc.sco_utils as scu
    from app import db, log
    from app.models import Formation, FormSemestre, GroupDescr, Identite
    from app.scodoc.gen_tables import GenTable
    from app.scodoc import sco_cache
    from app.scodoc import codes_cursus
    from app.scodoc import sco_etud
    from app.scodoc import sco_formsemestre_inscriptions
    from app.scodoc import sco_groups
    from app.scodoc import sco_preferences
    from app.scodoc import sco_pv_dict
    from app.scodoc.sco_exceptions import ScoValueError
    
    
    def _list_authorized_etuds_by_sem(
        formsemestre: FormSemestre, ignore_jury=False
    ) -> tuple[dict[int, dict], list[dict], dict[int, Identite]]:
        """Liste des etudiants autorisés à s'inscrire dans sem.
        delai = nb de jours max entre la date de l'autorisation et celle de debut du semestre cible.
        ignore_jury: si vrai, considère tous les étudiants comme autorisés, même
        s'ils n'ont pas de décision de jury.
        """
        src_sems = _list_source_sems(formsemestre)
        inscrits = list_inscrits(formsemestre.id)
        r = {}
        candidats = {}  # etudid : etud (tous les etudiants candidats)
        nb = 0  # debug
        src_formsemestre: FormSemestre
        for src_formsemestre in src_sems:
            if ignore_jury:
                # liste de tous les inscrits au semestre (sans dems)
                etud_list = list_inscrits(src_formsemestre.id).values()
            else:
                # liste des étudiants autorisés par le jury à s'inscrire ici
                etud_list = _list_etuds_from_sem(src_formsemestre, formsemestre)
            liste_filtree = []
            for e in etud_list:
                # Filtre ceux qui se sont déjà inscrit dans un semestre APRES le semestre src
                auth_used = False  # autorisation deja utilisée ?
                etud = Identite.get_etud(e["etudid"])
                for inscription in etud.inscriptions():
                    if inscription.formsemestre.date_debut >= src_formsemestre.date_fin:
                        auth_used = True
                if not auth_used:
                    candidats[e["etudid"]] = etud
                    liste_filtree.append(e)
                    nb += 1
            r[src_formsemestre.id] = {
                "etuds": liste_filtree,
                "infos": {
                    "id": src_formsemestre.id,
                    "title": src_formsemestre.titre_annee(),
                    "title_target": url_for(
                        "notes.formsemestre_status",
                        scodoc_dept=g.scodoc_dept,
                        formsemestre_id=src_formsemestre.id,
                    ),
                    "filename": "etud_autorises",
                },
            }
            # ajoute attribut inscrit qui indique si l'étudiant est déjà inscrit dans le semestre dest.
            for e in r[src_formsemestre.id]["etuds"]:
                e["inscrit"] = e["etudid"] in inscrits
    
        # Ajoute liste des etudiants actuellement inscrits
        for e in inscrits.values():
            e["inscrit"] = True
        r[formsemestre.id] = {
            "etuds": list(inscrits.values()),
            "infos": {
                "id": formsemestre.id,
                "title": "Semestre cible: " + formsemestre.titre_annee(),
                "title_target": url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre.id,
                ),
                "comment": " actuellement inscrits dans ce semestre",
                "help": "Ces étudiants sont actuellement inscrits dans ce semestre. Si vous les décochez, il seront désinscrits.",
                "filename": "etud_inscrits",
            },
        }
    
        return r, inscrits, candidats
    
    
    def list_inscrits(formsemestre_id: int, with_dems=False) -> list[dict]:
        """Étudiants déjà inscrits à ce semestre
        { etudid : etud }
        """
        if not with_dems:
            ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_listinscrits(
                formsemestre_id
            )  # optimized
        else:
            args = {"formsemestre_id": formsemestre_id}
            ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(args=args)
        inscr = {}
        for i in ins:
            etudid = i["etudid"]
            inscr[etudid] = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
        return inscr
    
    
    def _list_etuds_from_sem(src: FormSemestre, dst: FormSemestre) -> list[dict]:
        """Liste des étudiants du semestre src qui sont autorisés à passer dans le semestre dst."""
        target_semestre_id = dst.semestre_id
        dpv = sco_pv_dict.dict_pvjury(src.id)
        if not dpv:
            return []
        etuds = [
            x["identite"]
            for x in dpv["decisions"]
            if target_semestre_id in [a["semestre_id"] for a in x["autorisations"]]
        ]
        return etuds
    
    
    def list_inscrits_date(formsemestre: FormSemestre):
        """Liste les etudiants inscrits à la date de début de formsemestre
        dans n'importe quel semestre du même département
        SAUF formsemestre
        """
        cnx = ndb.GetDBConnexion()
        cursor = cnx.cursor(cursor_factory=ndb.ScoDocCursor)
        cursor.execute(
            """SELECT ins.etudid
            FROM
                notes_formsemestre_inscription ins,
                notes_formsemestre S
            WHERE ins.formsemestre_id = S.id
            AND S.id != %(formsemestre_id)s
            AND S.date_debut <= %(date_debut_iso)s
            AND S.date_fin >= %(date_debut_iso)s
            AND S.dept_id = %(dept_id)s
            """,
            {
                "formsemestre_id": formsemestre.id,
                "date_debut_iso": formsemestre.date_debut.isoformat(),
                "dept_id": formsemestre.dept_id,
            },
        )
        return [x[0] for x in cursor.fetchall()]
    
    
    def do_inscrit(
        formsemestre: FormSemestre, etudids, inscrit_groupes=False, inscrit_parcours=False
    ):
        """Inscrit ces etudiants dans ce semestre
        (la liste doit avoir été vérifiée au préalable)
        En option:
        - Si inscrit_groupes, inscrit aux mêmes groupes que dans le semestre origine
            (toutes partitions, y compris parcours)
        - Si inscrit_parcours, inscrit au même groupe de parcours (mais ignore les autres partitions)
        (si les deux sont vrais, inscrit_parcours n'a pas d'effet)
        """
        # TODO à ré-écrire pour utiliser les modèles, notamment GroupDescr
        formsemestre.setup_parcours_groups()
        log(f"do_inscrit (inscrit_groupes={inscrit_groupes}): {etudids}")
        for etudid in etudids:
            sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules(
                formsemestre.id,
                etudid,
                etat=scu.INSCRIT,
                method="formsemestre_inscr_passage",
            )
            if inscrit_groupes or inscrit_parcours:
                # Inscription dans les mêmes groupes que ceux du semestre  d'origine,
                # s'ils existent.
                # (mise en correspondance à partir du nom du groupe, sans tenir compte
                #  du nom de la partition: évidemment, cela ne marche pas si on a les
                #   même noms de groupes dans des partitions différentes)
                etud = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
    
                # recherche le semestre origine (il serait plus propre de l'avoir conservé!)
                if len(etud["sems"]) < 2:
                    continue
                prev_formsemestre = etud["sems"][1]
                sco_groups.etud_add_group_infos(
                    etud,
                    prev_formsemestre["formsemestre_id"] if prev_formsemestre else None,
                )
    
                cursem_groups_by_name = {
                    g["group_name"]: g
                    for g in sco_groups.get_sem_groups(formsemestre.id)
                    if g["group_name"]
                }
    
                # forme la liste des groupes présents dans les deux semestres:
                partition_groups = []  # [ partition+group ] (ds nouveau sem.)
                for partition_id in etud["partitions"]:
                    prev_group_name = etud["partitions"][partition_id]["group_name"]
                    if prev_group_name in cursem_groups_by_name:
                        new_group = cursem_groups_by_name[prev_group_name]
                        partition_groups.append(new_group)
    
                # Inscrit aux groupes
                for partition_group in partition_groups:
                    group: GroupDescr = db.session.get(
                        GroupDescr, partition_group["group_id"]
                    )
                    if inscrit_groupes or (
                        group.partition.partition_name == scu.PARTITION_PARCOURS
                        and inscrit_parcours
                    ):
                        sco_groups.change_etud_group_in_partition(etudid, group)
    
    
    def do_desinscrit(
        formsemestre: FormSemestre, etudids: list[int], check_has_dec_jury=True
    ):
        "désinscrit les étudiants indiqués du formsemestre"
        log(f"do_desinscrit: {etudids}")
        for etudid in etudids:
            sco_formsemestre_inscriptions.do_formsemestre_desinscription(
                etudid, formsemestre.id, check_has_dec_jury=check_has_dec_jury
            )
    
    
    def _list_source_sems(formsemestre: FormSemestre) -> list[FormSemestre]:
        """Liste des semestres sources
        formsemestre est le semestre destination
        """
        # liste des semestres du même type de cursus terminant
        # pas trop loin de la date de début du semestre destination
        date_fin_min = formsemestre.date_debut - datetime.timedelta(days=275)
        date_fin_max = formsemestre.date_debut + datetime.timedelta(days=45)
        return (
            FormSemestre.query.filter(
                FormSemestre.dept_id == formsemestre.dept_id,
                # saute le semestre destination:
                FormSemestre.id != formsemestre.id,
                # et les semestres de formations speciales (monosemestres):
                FormSemestre.semestre_id != codes_cursus.NO_SEMESTRE_ID,
                # semestre pas trop dans le futur
                FormSemestre.date_fin <= date_fin_max,
                # ni trop loin dans le passé
                FormSemestre.date_fin >= date_fin_min,
            )
            .join(Formation)
            .filter_by(type_parcours=formsemestre.formation.type_parcours)
        ).all()
    
    
    # view, GET, POST
    def formsemestre_inscr_passage(
        formsemestre_id,
        etuds: str | list[int] | list[str] | int | None = None,
        inscrit_groupes=False,
        inscrit_parcours=False,
        submitted=False,
        dialog_confirmed=False,
        ignore_jury=False,
    ) -> str:
        """Page Form. pour inscription des etudiants d'un semestre dans un autre
        (donné par formsemestre_id).
        Permet de selectionner parmi les etudiants autorisés à s'inscrire.
        Principe:
        - trouver liste d'etud, par semestre
        - afficher chaque semestre "boites" avec cases à cocher
        - si l'étudiant est déjà inscrit, le signaler (gras, nom de groupes): il peut être désinscrit
        - on peut choisir les groupes TD, TP, TA
        - seuls les étudiants non inscrits changent (de groupe)
        - les étudiants inscrit qui se trouvent décochés sont désinscrits
        - Confirmation: indiquer les étudiants inscrits et ceux désinscrits, le total courant.
    
        """
        formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
        inscrit_groupes = int(inscrit_groupes)
        inscrit_parcours = int(inscrit_parcours)
        ignore_jury = int(ignore_jury)
        # -- check lock
        if not formsemestre.etat:
            raise ScoValueError("opération impossible: semestre verrouille")
        H = []
        etuds = [] if etuds is None else etuds
        if isinstance(etuds, str):
            # string, vient du form de confirmation
            etuds = [int(x) for x in etuds.split(",") if x]
        elif isinstance(etuds, int):
            etuds = [etuds]
        elif etuds and isinstance(etuds[0], str):
            etuds = [int(x) for x in etuds]
    
        auth_etuds_by_sem, inscrits, candidats = _list_authorized_etuds_by_sem(
            formsemestre, ignore_jury=ignore_jury
        )
        etuds_set = set(etuds)
        candidats_set = set(candidats)
        inscrits_set = set(inscrits)
        candidats_non_inscrits = candidats_set - inscrits_set
        inscrits_ailleurs = set(list_inscrits_date(formsemestre))
    
        def set_to_sorted_etud_list(etudset) -> list[Identite]:
            etuds = [candidats[etudid] for etudid in etudset]
            etuds.sort(key=lambda e: e.sort_key)
            return etuds
    
        if submitted:
            a_inscrire = etuds_set.intersection(candidats_set) - inscrits_set
            a_desinscrire = inscrits_set - etuds_set
        else:
            a_inscrire = a_desinscrire = []
    
        if not submitted:
            H += _build_page(
                formsemestre,
                auth_etuds_by_sem,
                inscrits,
                candidats_non_inscrits,
                inscrits_ailleurs,
                inscrit_groupes=inscrit_groupes,
                inscrit_parcours=inscrit_parcours,
                ignore_jury=ignore_jury,
            )
        else:
            if not dialog_confirmed:
                # Confirmation
                if a_inscrire:
                    H.append("<h3>Étudiants à inscrire</h3><ol>")
                    for etud in set_to_sorted_etud_list(a_inscrire):
                        H.append(f"<li>{etud.nomprenom}</li>")
                    H.append("</ol>")
                a_inscrire_en_double = inscrits_ailleurs.intersection(a_inscrire)
                if a_inscrire_en_double:
                    H.append("<h3>dont étudiants déjà inscrits:</h3><ul>")
                    for etud in set_to_sorted_etud_list(a_inscrire_en_double):
                        H.append(f'<li class="inscrit-ailleurs">{etud.nomprenom}</li>')
                    H.append("</ul>")
                if a_desinscrire:
                    H.append("<h3>Étudiants à désinscrire</h3><ol>")
                    a_desinscrire_ident = sorted(
                        (db.session.get(Identite, eid) for eid in a_desinscrire),
                        key=lambda x: x.sort_key,
                    )
                    for etud in a_desinscrire_ident:
                        H.append(f'<li class="desinscription">{etud.nomprenom}</li>')
                    H.append("</ol>")
                todo = a_inscrire or a_desinscrire
                if not todo:
                    H.append("""<h3>Il n'y a rien à modifier !</h3>""")
                H.append(
                    scu.confirm_dialog(
                        dest_url=(
                            "formsemestre_inscr_passage" if todo else "formsemestre_status"
                        ),
                        message="<p>Confirmer ?</p>" if todo else "",
                        add_headers=False,
                        cancel_url="formsemestre_inscr_passage?formsemestre_id="
                        + str(formsemestre_id),
                        OK="Effectuer l'opération" if todo else "",
                        parameters={
                            "formsemestre_id": formsemestre_id,
                            "etuds": ",".join([str(x) for x in etuds]),
                            "inscrit_groupes": inscrit_groupes,
                            "inscrit_parcours": inscrit_parcours,
                            "ignore_jury": ignore_jury,
                            "submitted": 1,
                        },
                    )
                )
            else:
                # check decisions jury ici pour éviter de recontruire le cache
                # après chaque desinscription
                sco_formsemestre_inscriptions.check_if_has_decision_jury(
                    formsemestre, a_desinscrire
                )
                # check decisions jury ici pour éviter de recontruire le cache
                # après chaque desinscription
                sco_formsemestre_inscriptions.check_if_has_decision_jury(
                    formsemestre, a_desinscrire
                )
                with sco_cache.DeferredSemCacheManager():
                    # Inscription des étudiants au nouveau semestre:
                    do_inscrit(
                        formsemestre,
                        a_inscrire,
                        inscrit_groupes=inscrit_groupes,
                        inscrit_parcours=inscrit_parcours,
                    )
                    # Désinscriptions:
                    do_desinscrit(formsemestre, a_desinscrire, check_has_dec_jury=False)
    
                H.append(
                    f"""<h3>Opération effectuée</h3>
                    <ul>
                    <li><a class="stdlink" href="{
                        url_for("notes.formsemestre_inscr_passage",
                            scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
                    }">Continuer les inscriptions</a>
                    </li>
                    <li><a class="stdlink" href="{
                        url_for("notes.formsemestre_status",
                            scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
                    }">Tableau de bord du semestre</a>
                    </li>"""
                )
                partition = sco_groups.formsemestre_get_main_partition(formsemestre_id)
                if (
                    partition["partition_id"]
                    != sco_groups.formsemestre_get_main_partition(formsemestre_id)[
                        "partition_id"
                    ]
                ):  # il y a au moins une vraie partition
                    H.append(
                        f"""<li><a class="stdlink" href="{
                            url_for("scolar.partition_editor", scodoc_dept=g.scodoc_dept,
                                    formsemestre_id=formsemestre_id)
                        }">Répartir les groupes de {partition["partition_name"]}</a></li>
                        """
                    )
    
        #
        return render_template(
            "sco_page.j2", title="Passage des étudiants", content="\n".join(H)
        )
    
    
    def _build_page(
        formsemestre: FormSemestre,
        auth_etuds_by_sem,
        inscrits,
        candidats_non_inscrits,
        inscrits_ailleurs,
        inscrit_groupes=False,
        inscrit_parcours=False,
        ignore_jury=False,
    ):
        inscrit_groupes = int(inscrit_groupes)
        inscrit_parcours = int(inscrit_parcours)
        ignore_jury = int(ignore_jury)
        if inscrit_groupes:
            inscrit_groupes_checked = " checked"
        else:
            inscrit_groupes_checked = ""
        if inscrit_parcours:
            inscrit_parcours_checked = " checked"
        else:
            inscrit_parcours_checked = ""
        if ignore_jury:
            ignore_jury_checked = " checked"
        else:
            ignore_jury_checked = ""
        H = [
            f"""
            <h2 class="formsemestre">Passages dans le semestre</h2>
            <form name="f" method="post" action="{request.base_url}">
    
            <input type="hidden" name="formsemestre_id" value="{formsemestre.id}"/>
    
            <input type="submit" name="submitted" value="Appliquer les modifications"/>
            &nbsp;<a href="#help">aide</a>
    
            <input name="inscrit_groupes" type="checkbox" value="1"
                {inscrit_groupes_checked}>inscrire aux mêmes groupes (y compris parcours)</input>
    
            <input name="inscrit_parcours" type="checkbox" value="1"
                {inscrit_parcours_checked}>inscrire aux mêmes parcours</input>
    
            <input name="ignore_jury" type="checkbox" value="1" onchange="document.f.submit()"
                {ignore_jury_checked}>inclure tous les étudiants (même sans décision de jury)</input>
    
            <div class="pas_recap">Actuellement <span id="nbinscrits">{len(inscrits)}</span>
            inscrits et {len(candidats_non_inscrits)} candidats supplémentaires.
            </div>
    
            <div>{scu.EMO_WARNING}
            <em>Seuls les semestres dont la date de fin est proche de la date de début
            de ce semestre ({formsemestre.date_debut.strftime(scu.DATE_FMT)}) sont pris en
            compte.</em>
            </div>
            {etuds_select_boxes(auth_etuds_by_sem, inscrits_ailleurs)}
    
            <input type="submit" name="submitted" value="Appliquer les modifications"/>
    
            {formsemestre_inscr_passage_help(formsemestre)}
    
            </form>
            """,
        ]
    
        # Semestres sans étudiants autorisés
        empty_sems = []
        for formsemestre_id in auth_etuds_by_sem.keys():
            if not auth_etuds_by_sem[formsemestre_id]["etuds"]:
                empty_sems.append(auth_etuds_by_sem[formsemestre_id]["infos"])
        if empty_sems:
            H.append(
                """<div class="pas_empty_sems"><h3>Autres semestres sans candidats :</h3><ul>"""
            )
            for infos in empty_sems:
                H.append(
                    """<li><a class="stdlink" href="%(title_target)s">%(title)s</a></li>"""
                    % infos
                )
            H.append("""</ul></div>""")
    
        return H
    
    
    def formsemestre_inscr_passage_help(formsemestre: FormSemestre):
        "texte d'aide en bas  de la page passage des étudiants"
        return f"""<div class="pas_help"><h3><a name="help">Explications</a></h3>
        <p>Cette page permet d'inscrire des étudiants dans le semestre destination
        <a class="stdlink"
        href="{
            url_for("notes.formsemestre_status",
                scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id )
        }">{formsemestre.titre_annee()}</a>,
        et d'en désincrire si besoin.
        </p>
        <p>Les étudiants sont groupés par semestre d'origine. Ceux qui sont en caractères
        <span class="deja-inscrit">gras</span> sont déjà inscrits dans le semestre destination.
        Ceux qui sont en <span class="inscrit-ailleurs">gras et en rouge</span> sont inscrits
        dans un <em>autre</em> semestre.
        </p>
        <p>Au départ, les étudiants déjà inscrits sont sélectionnés; vous pouvez ajouter
        d'autres étudiants à inscrire dans le semestre destination.
        </p>
    
        <p>Si vous dé-selectionnez un étudiant déjà inscrit (en gras), il sera désinscrit.
        </p>
    
        <p>Le bouton <em>inscrire aux mêmes groupes</em> ne prend en compte que les groupes
        qui existent dans les deux semestres: pensez à créer les partitions et groupes que
        vous souhaitez conserver <b>avant</b> d'inscrire les étudiants.
        </p>
    
        <p>Les parcours de BUT sont gérés comme des groupes de la partition parcours: si on
        conserve les groupes, on conserve les parcours (là aussi, pensez à les cocher dans
        <a class="stdlink" href="{
            url_for("notes.formsemestre_editwithmodules", scodoc_dept=g.scodoc_dept,
                    formsemestre_id=formsemestre.id )
        }">modifier le semestre</a> avant de faire passer les étudiants).
        </a>
    
        <p class="help">Aucune action ne sera effectuée si vous n'appuyez pas sur le bouton
        "Appliquer les modifications" !
        </p>
        </div>
        """
    
    
    def etuds_select_boxes(
        auth_etuds_by_cat,
        inscrits_ailleurs: dict = None,
        sel_inscrits=True,
        show_empty_boxes=False,
        export_cat_xls=None,
        base_url="",
        read_only=False,
    ):
        """Boites pour selection étudiants par catégorie
        auth_etuds_by_cat = { category : { 'info' : {}, 'etuds' : ... }
        inscrits_ailleurs =
        sel_inscrits=
        export_cat_xls =
        """
        inscrits_ailleurs = inscrits_ailleurs or {}
        if export_cat_xls:
            return etuds_select_box_xls(auth_etuds_by_cat[export_cat_xls])
    
        H = [
            """<script type="text/javascript">
        function sem_select(formsemestre_id, state) {
        var elems = document.getElementById(formsemestre_id).getElementsByTagName("input");
        for (var i =0; i < elems.length; i++) { elems[i].checked=state; }
        }
        function sem_select_inscrits(formsemestre_id) {
        var elems = document.getElementById(formsemestre_id).getElementsByTagName("input");
        for (var i =0; i < elems.length; i++) {
          if (elems[i].parentNode.className.indexOf('inscrit') >= 0) {
             elems[i].checked=true;
          } else {
             elems[i].checked=false;
          }
        }
        }
        </script>
        <div class="etuds_select_boxes">"""
        ]  # "
        # Élimine les boites vides:
        auth_etuds_by_cat = {
            k: auth_etuds_by_cat[k]
            for k in auth_etuds_by_cat
            if auth_etuds_by_cat[k]["etuds"]
        }
        for src_cat in auth_etuds_by_cat.keys():
            infos = auth_etuds_by_cat[src_cat]["infos"]
            infos["comment"] = infos.get("comment", "")  # commentaire dans sous-titre boite
            help_txt = infos.get("help", "")
            etuds = auth_etuds_by_cat[src_cat]["etuds"]
            etuds.sort(key=itemgetter("nom"))
            with_checkbox = (not read_only) and auth_etuds_by_cat[src_cat]["infos"].get(
                "with_checkbox", True
            )
            checkbox_name = auth_etuds_by_cat[src_cat]["infos"].get(
                "checkbox_name", "etuds"
            )
            etud_key = auth_etuds_by_cat[src_cat]["infos"].get("etud_key", "etudid")
            if etuds or show_empty_boxes:
                infos["nbetuds"] = len(etuds)
                H.append(
                    """<div class="pas_sembox" id="%(id)s">
                    <div class="pas_sembox_title"><a href="%(title_target)s" """
                    % infos
                )
                if help_txt:  # bubble
                    H.append('title="%s"' % help_txt)
                H.append(
                    """>%(title)s</a></div>
                    <div class="pas_sembox_subtitle">(%(nbetuds)d étudiants%(comment)s)"""
                    % infos
                )
                if with_checkbox:
                    H.append(
                        """ (Select.
                    <a href="#" class="stdlink" onclick="sem_select('%(id)s', true);">tous</a>
                    <a href="#" class="stdlink" onclick="sem_select('%(id)s', false );">aucun</a>"""  # "
                        % infos
                    )
                if sel_inscrits:
                    H.append(
                        """<a href="#" class="stdlink" onclick="sem_select_inscrits('%(id)s');">inscrits</a>"""
                        % infos
                    )
                if with_checkbox or sel_inscrits:
                    H.append(")")
                if base_url and etuds:
                    url = scu.build_url_query(base_url, export_cat_xls=src_cat)
                    H.append(f'<a href="{url}">{scu.ICON_XLS}</a>&nbsp;')
                H.append("</div>")
                for etud in etuds:
                    if etud.get("inscrit", False):
                        c = " deja-inscrit"
                        checked = 'checked="checked"'
                    else:
                        checked = ""
                        if etud["etudid"] in inscrits_ailleurs:
                            c = " inscrit-ailleurs"
                        else:
                            c = ""
                    sco_etud.format_etud_ident(etud)
                    if etud["etudid"]:
                        elink = f"""<a id="{etud['etudid']}" class="discretelink etudinfo {c}"
                            href="{ url_for(
                                'scolar.fiche_etud',
                                scodoc_dept=g.scodoc_dept,
                                etudid=etud['etudid'],
                            )
                            }">{etud['nomprenom']}</a>
                        """
                    else:
                        # ce n'est pas un etudiant ScoDoc
                        elink = etud["nomprenom"]
    
                    if etud.get("datefinalisationinscription", None):
                        elink += (
                            '<span class="finalisationinscription">'
                            + " : inscription finalisée le "
                            + etud["datefinalisationinscription"].strftime(scu.DATE_FMT)
                            + "</span>"
                        )
    
                    if not etud.get("paiementinscription", True):
                        elink += '<span class="paspaye"> (non paiement)</span>'
                    if etud.get("datefinalisationinscription"):
                        elink += f"""<span class="finalise"> (inscr. finalisée le {
                            etud["datefinalisationinscription"].strftime(scu.DATE_FMT)
                        })</span>"""
    
                    H.append("""<div class="pas_etud%s">""" % c)
                    if "etape" in etud:
                        etape_str = etud["etape"] or ""
                    else:
                        etape_str = ""
                    H.append("""<span class="sp_etape">%s</span>""" % etape_str)
                    if with_checkbox:
                        H.append(
                            """<input type="checkbox" name="%s:list" value="%s" %s>"""
                            % (checkbox_name, etud[etud_key], checked)
                        )
                    H.append(elink)
                    if with_checkbox:
                        H.append("""</input>""")
                    H.append("</div>")
                H.append("</div>")
    
        H.append("</div>")
        return "\n".join(H)
    
    
    def etuds_select_box_xls(src_cat):
        "export a box to excel"
        etuds = src_cat["etuds"]
        columns_ids = [
            "etudid",
            "ine",
            "nip",
            "civilite_str",
            "nom",
            "prenom",
            "etape",
            "paiementinscription_str",
            "datefinalisationinscription",
        ]
        titles = {x: x for x in columns_ids} | {
            "paiementinscription_str": "Paiement inscr.",
            "datefinalisationinscription": "Finalisation inscr.",
        }
        for e in etuds:
            if not e.get("paiementinscription", True):
                e["paiementinscription_str"] = "NON"
            else:
                e["paiementinscription_str"] = "-"
            # si e est un étudiant Apo, on a nip et ine
            # mais si e est ScoDoc, on a code_nip et code_ine:
            e["nip"] = e.get("nip", e.get("code_nip"))
            e["ine"] = e.get("ine", e.get("code_ine"))
            # Pour excel, datefinalisationinscription doit être datetime
            dat = e.get("datefinalisationinscription")
            if isinstance(dat, datetime.date):
                e["datefinalisationinscription"] = datetime.datetime.combine(
                    dat, datetime.time.min
                )
        tab = GenTable(
            caption="%(title)s. %(help)s" % src_cat["infos"],
            columns_ids=columns_ids,
            preferences=sco_preferences.SemPreferences(),
            rows=etuds,
            table_id="etuds_select_box_xls",
            titles=titles,
        )
        return tab.excel()