Skip to content
Snippets Groups Projects
Select Git revision
  • master
1 result

README.md

Blame
  • Forked from Giuseppe Lipari / ISI-TP2-injection
    Source project has a limited visibility.
    formsemestre.py 65.29 KiB
    # -*- coding: UTF-8 -*
    ##############################################################################
    # ScoDoc
    # Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
    # See LICENSE
    ##############################################################################
    
    # pylint génère trop de faux positifs avec les colonnes date:
    # pylint: disable=no-member,not-an-iterable
    
    """ScoDoc models: formsemestre
    """
    from collections import defaultdict
    import datetime
    from functools import cached_property
    from itertools import chain
    from operator import attrgetter
    
    from flask_login import current_user
    
    from flask import abort, flash, g, url_for
    from flask_sqlalchemy.query import Query
    from sqlalchemy.sql import text
    from sqlalchemy import func
    
    import app.scodoc.sco_utils as scu
    from app import db, email, log
    from app.auth.models import User
    from app import models
    from app.models import APO_CODE_STR_LEN, CODE_STR_LEN, SHORT_STR_LEN
    from app.models.but_refcomp import (
        ApcParcours,
        ApcReferentielCompetences,
        parcours_formsemestre,
    )
    from app.models.config import ScoDocSiteConfig
    from app.models.departements import Departement
    from app.models.etudiants import Identite
    from app.models.evaluations import Evaluation
    from app.models.events import Scolog, ScolarNews
    from app.models.formations import Formation
    from app.models.groups import GroupDescr, Partition
    from app.models.moduleimpls import (
        ModuleImpl,
        ModuleImplInscription,
        notes_modules_enseignants,
    )
    from app.models.modules import Module
    from app.models.scolar_event import ScolarEvent
    from app.models.ues import UniteEns
    from app.models.validations import ScolarFormSemestreValidation
    from app.scodoc import codes_cursus, sco_cache, sco_preferences
    from app.scodoc.sco_exceptions import ScoValueError
    from app.scodoc.sco_permissions import Permission
    from app.scodoc.sco_utils import MONTH_NAMES_ABBREV, translate_assiduites_metric
    from app.scodoc.sco_vdi import ApoEtapeVDI
    
    GROUPS_AUTO_ASSIGNMENT_DATA_MAX = 1024 * 1024  # bytes
    
    
    class FormSemestre(models.ScoDocModel):
        """Mise en oeuvre d'un semestre de formation"""
    
        __tablename__ = "notes_formsemestre"
    
        id = db.Column(db.Integer, primary_key=True)
        formsemestre_id = db.synonym("id")
        # dept_id est aussi dans la formation, ajouté ici pour
        # simplifier et accélérer les selects dans notesdb
        dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
        formation_id = db.Column(db.Integer, db.ForeignKey("notes_formations.id"))
        semestre_id = db.Column(db.Integer, nullable=False, default=1, server_default="1")
        titre = db.Column(db.Text(), nullable=False)
        # nb max d'inscriptions (non DEM), null si illimité:
        capacite_accueil = db.Column(db.Integer, nullable=True)
        date_debut = db.Column(db.Date(), nullable=False)
        date_fin = db.Column(db.Date(), nullable=False)  # jour inclus
        edt_id: str | None = db.Column(db.Text(), index=True, nullable=True)
        "identifiant emplois du temps (unicité non imposée)"
        etat = db.Column(db.Boolean(), nullable=False, default=True, server_default="true")
        "False si verrouillé"
        modalite = db.Column(
            db.String(SHORT_STR_LEN), db.ForeignKey("notes_form_modalites.modalite")
        )
        "Modalité de formation: 'FI', 'FAP', 'FC', ..."
        gestion_compensation = db.Column(
            db.Boolean(), nullable=False, default=False, server_default="false"
        )
        "gestion compensation sem DUT (inutilisé en APC)"
        bul_hide_xml = db.Column(
            db.Boolean(), nullable=False, default=False, server_default="false"
        )
        "ne publie pas le bulletin sur l'API"
        block_moyennes = db.Column(
            db.Boolean(), nullable=False, default=False, server_default="false"
        )
        "Bloque le calcul des moyennes (générale et d'UE)"
        block_moyenne_generale = db.Column(
            db.Boolean(), nullable=False, default=False, server_default="false"
        )
        "Si vrai, la moyenne générale indicative BUT n'est pas calculée"
        mode_calcul_moyennes = db.Column(
            db.Integer, nullable=False, default=0, server_default="0"
        )
        "pour usage futur"
        gestion_semestrielle = db.Column(
            db.Boolean(), nullable=False, default=False, server_default="false"
        )
        "Semestres décalés (pour gestion jurys DUT, pas implémenté ou utile en BUT)"
        bul_bgcolor = db.Column(
            db.String(SHORT_STR_LEN),
            default="white",
            server_default="white",
            nullable=False,
        )
        "couleur fond bulletins HTML"
        resp_can_edit = db.Column(
            db.Boolean(), nullable=False, default=False, server_default="false"
        )
        "autorise resp. à modifier le formsemestre"
        resp_can_change_ens = db.Column(
            db.Boolean(), nullable=False, default=True, server_default="true"
        )
        "autorise resp. a modifier slt les enseignants"
        ens_can_edit_eval = db.Column(
            db.Boolean(), nullable=False, default=False, server_default="False"
        )
        "autorise les enseignants à créer des évals dans leurs modimpls"
        elt_sem_apo = db.Column(db.Text())  # peut être fort long !
        "code element semestre Apogée, eg 'VRTW1' ou 'V2INCS4,V2INLS4,...'"
        elt_annee_apo = db.Column(db.Text())
        "code element annee Apogee, eg 'VRT1A' ou 'V2INLA,V2INCA,...'"
        elt_passage_apo = db.Column(db.Text())
        "code element passage Apogée"
    
        # Data pour groups_auto_assignment
        # (ce champ est utilisé uniquement via l'API par le front js)
        groups_auto_assignment_data = db.Column(db.LargeBinary(), nullable=True)
    
        # Relations:
        etapes = db.relationship(
            "FormSemestreEtape", cascade="all,delete-orphan", backref="formsemestre"
        )
        modimpls = db.relationship(
            "ModuleImpl",
            backref="formsemestre",
            lazy="dynamic",
            cascade="all, delete-orphan",
        )
        description = db.relationship(
            "FormSemestreDescription",
            back_populates="formsemestre",
            cascade="all, delete-orphan",
            uselist=False,
        )
        etuds = db.relationship(
            "Identite",
            secondary="notes_formsemestre_inscription",
            viewonly=True,
            lazy="dynamic",
        )
        responsables = db.relationship(
            "User",
            secondary="notes_formsemestre_responsables",
            lazy=True,
            backref=db.backref("formsemestres", lazy=True),
            order_by=func.upper(User.nom),
        )
        partitions = db.relationship(
            "Partition",
            backref=db.backref("formsemestre", lazy=True),
            lazy="dynamic",
            order_by="Partition.numero",
        )
        # Ancien id ScoDoc7 pour les migrations de bases anciennes
        # ne pas utiliser après migrate_scodoc7_dept_archives
        scodoc7_id = db.Column(db.Text(), nullable=True)
    
        # BUT
        parcours = db.relationship(
            "ApcParcours",
            secondary=parcours_formsemestre,
            lazy="subquery",
            backref=db.backref("formsemestres", lazy=True),
            order_by=(ApcParcours.numero, ApcParcours.code),
        )
    
        def __init__(self, **kwargs):
            super(FormSemestre, self).__init__(**kwargs)
            if self.modalite is None:
                self.modalite = FormationModalite.DEFAULT_MODALITE
    
        def __repr__(self):
            return f"<{self.__class__.__name__} {self.id} {self.titre_annee()}>"
    
        def html_link_status(self, label=None, title=None) -> str:
            "html link to status page"
            return f"""<a class="stdlink" href="{
                url_for("notes.formsemestre_status", scodoc_dept=self.departement.acronym,
                    formsemestre_id=self.id,)
            }" title="{title or ''}">{label or self.titre_mois()}</a>
            """
    
        @classmethod
        def get_formsemestre(
            cls, formsemestre_id: int | str, dept_id: int = None, accept_none=False
        ) -> "FormSemestre | None":
            """FormSemestre ou 404 (ou None si accept_none), cherche uniquement dans
            le département spécifié ou le courant (g.scodoc_dept).
            Si accept_none, return None si l'id est invalide ou ne correspond
            pas à un formsemestre.
            """
            if not isinstance(formsemestre_id, int):
                try:
                    formsemestre_id = int(formsemestre_id)
                except (TypeError, ValueError):
                    if accept_none:
                        return None
                    abort(404, "formsemestre_id invalide")
    
            dept_id = (
                dept_id
                if dept_id is not None
                else (g.scodoc_dept_id if g.scodoc_dept else None)
            )
    
            query = (
                cls.query.filter_by(id=formsemestre_id)
                if dept_id is None
                else cls.query.filter_by(id=formsemestre_id, dept_id=dept_id)
            )
            return query.first() if accept_none else query.first_or_404()
    
        @classmethod
        def create_formsemestre(cls, args: dict, silent=False) -> "FormSemestre":
            """Création d'un formsemestre, avec toutes les valeurs par défaut
            et notification (sauf si silent).
            Crée la partition par défaut.
            """
            # was sco_formsemestre.do_formsemestre_create
            if "dept_id" not in args:
                args["dept_id"] = g.scodoc_dept_id
            formsemestre: "FormSemestre" = cls.create_from_dict(args)
            db.session.flush()
            for etape in args.get("etapes") or []:
                formsemestre.add_etape(etape)
            db.session.commit()
            # create default partition
            partition = Partition(
                formsemestre=formsemestre, partition_name=None, numero=1000000
            )
            db.session.add(partition)
            partition.create_group(default=True)
            db.session.commit()
    
            if not silent:
                url = url_for(
                    "notes.formsemestre_status",
                    scodoc_dept=formsemestre.departement.acronym,
                    formsemestre_id=formsemestre.id,
                )
                ScolarNews.add(
                    typ=ScolarNews.NEWS_SEM,
                    text=f"""Création du semestre <a href="{url}">{formsemestre.titre}</a>""",
                    url=url,
                    max_frequency=0,
                )
    
            return formsemestre
    
        @classmethod
        def convert_dict_fields(cls, args: dict) -> dict:
            """Convert fields in the given dict.
            args: dict with args in application.
            returns: dict to store in model's db.
            """
            if "date_debut" in args:
                args["date_debut"] = scu.convert_fr_date(args["date_debut"])
            if "date_fin" in args:
                args["date_fin"] = scu.convert_fr_date(args["date_fin"])
            if "etat" in args:
                if args["etat"] is None:
                    del args["etat"]
                else:
                    args["etat"] = bool(args["etat"])
            if "bul_bgcolor" in args:
                args["bul_bgcolor"] = args.get("bul_bgcolor") or "white"
            if "titre" in args:
                args["titre"] = args.get("titre") or "sans titre"
            if "capacite_accueil" in args:  # peut être un nombre, "" ou None
                try:
                    args["capacite_accueil"] = (
                        int(args["capacite_accueil"])
                        if args["capacite_accueil"] not in ("", None)
                        else None
                    )
                except ValueError as exc:
                    raise ScoValueError("capacite_accueil invalide") from exc
            if "responsables" in args:  # peut être liste d'uid ou de user_name ou de User
                resp_users = [User.get_user(u) for u in args["responsables"]]
                args["responsables"] = [u for u in resp_users if u is not None]
            return args
    
        @classmethod
        def filter_model_attributes(cls, data: dict, excluded: set[str] = None) -> dict:
            """Returns a copy of dict with only the keys belonging to the Model and not in excluded.
            Add 'etapes' to excluded."""
            # on ne peut pas affecter directement etapes
            return super().filter_model_attributes(data, (excluded or set()) | {"etapes"})
    
        def sort_key(self) -> tuple:
            """clé pour tris par ordre de date_debut, le plus ancien en tête
            (pour avoir le plus récent d'abord, sort avec reverse=True)"""
            return (self.date_debut, self.semestre_id)
    
        def to_dict(self, convert_objects=False) -> dict:
            """dict (compatible ScoDoc7).
            If convert_objects, convert all attributes to native types
            (suitable jor json encoding).
            """
            d = dict(self.__dict__)
            d.pop("_sa_instance_state", None)
            d.pop("groups_auto_assignment_data", None)
            # ScoDoc7 output_formators: (backward compat)
            d["formsemestre_id"] = self.id
            d["titre_num"] = self.titre_num()
            if self.date_debut:
                d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
                d["date_debut_iso"] = self.date_debut.isoformat()
            else:
                d["date_debut"] = d["date_debut_iso"] = ""
            if self.date_fin:
                d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
                d["date_fin_iso"] = self.date_fin.isoformat()
            else:
                d["date_fin"] = d["date_fin_iso"] = ""
            d["responsables"] = [u.id for u in self.responsables]
            d["titre_formation"] = self.titre_formation()
            if convert_objects:  # pour API
                d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
                d["departement"] = self.departement.to_dict()
                d["formation"] = self.formation.to_dict()
                d["etape_apo"] = self.etapes_apo_str()
            else:
                # Converti les étapes Apogee sous forme d'ApoEtapeVDI (compat scodoc7)
                d["etapes"] = [e.as_apovdi() for e in self.etapes]
            return d
    
        def to_dict_api(self):
            """
            Un dict avec les informations sur le semestre destinées à l'api
            """
            d = dict(self.__dict__)
            d.pop("_sa_instance_state", None)
            d.pop("groups_auto_assignment_data", None)
            d["annee_scolaire"] = self.annee_scolaire()
            d["bul_hide_xml"] = self.bul_hide_xml
            if self.date_debut:
                d["date_debut"] = self.date_debut.strftime(scu.DATE_FMT)
                d["date_debut_iso"] = self.date_debut.isoformat()
            else:
                d["date_debut"] = d["date_debut_iso"] = ""
            if self.date_fin:
                d["date_fin"] = self.date_fin.strftime(scu.DATE_FMT)
                d["date_fin_iso"] = self.date_fin.isoformat()
            else:
                d["date_fin"] = d["date_fin_iso"] = ""
            d["departement"] = self.departement.to_dict()
            d["etape_apo"] = self.etapes_apo_str()
            d["formsemestre_id"] = self.id
            d["formation"] = self.formation.to_dict()
            d["parcours"] = [p.to_dict() for p in self.get_parcours_apc()]
            d["responsables"] = [u.id for u in self.responsables]
            d["titre_court"] = self.formation.acronyme
            d["titre_formation"] = self.titre_formation()
            d["titre_num"] = self.titre_num()
            d["session_id"] = self.session_id()
            return d
    
        def get_default_group(self) -> GroupDescr:
            """default ('tous') group.
            Le groupe par défaut contient tous les étudiants et existe toujours.
            C'est l'unique groupe de la partition sans nom.
            """
            default_partition = self.partitions.filter_by(partition_name=None).first()
            if default_partition:
                return default_partition.groups.first()
            raise ScoValueError("Le semestre n'a pas de groupe par défaut")
    
        def get_edt_ids(self) -> list[str]:
            """Les ids pour l'emploi du temps: à défaut, les codes étape Apogée.
            Les edt_id de formsemestres ne sont pas normalisés afin de contrôler
            précisément l'accès au fichier ics.
            """
            return (
                scu.split_id(self.edt_id)
                or [e.etape_apo.strip() for e in self.etapes if e.etape_apo]
                or []
            )
    
        def get_infos_dict(self) -> dict:
            """Un dict avec des informations sur le semestre
            pour les bulletins et autres templates
            (contenu compatible scodoc7 / anciens templates)
            """
            d = self.to_dict()
            d["anneescolaire"] = self.annee_scolaire_str()
            d["annee_debut"] = str(self.date_debut.year)
            d["annee"] = d["annee_debut"]
            d["annee_fin"] = str(self.date_fin.year)
            if d["annee_fin"] != d["annee_debut"]:
                d["annee"] += "-" + str(d["annee_fin"])
            d["mois_debut_ord"] = self.date_debut.month
            d["mois_fin_ord"] = self.date_fin.month
            # La période: considère comme "S1" (ou S3) les débuts en aout-sept-octobre
            # devrait sans doute pouvoir etre changé... XXX PIVOT
            d["periode"] = self.periode()
            if self.date_debut.month >= 8 and self.date_debut.month <= 10:
                d["periode"] = 1  # typiquement, début en septembre: S1, S3...
            else:
                d["periode"] = 2  # typiquement, début en février: S2, S4...
            d["titreannee"] = self.titre_annee()
            d["mois_debut"] = self.mois_debut()
            d["mois_fin"] = self.mois_fin()
            d["titremois"] = "%s %s  (%s - %s)" % (
                d["titre_num"],
                self.modalite or "",
                d["mois_debut"],
                d["mois_fin"],
            )
            d["session_id"] = self.session_id()
            d["etapes"] = self.etapes_apo_vdi()
            d["etapes_apo_str"] = self.etapes_apo_str()
            return d
    
        def flip_lock(self):
            """Flip etat (lock)"""
            self.etat = not self.etat
            db.session.add(self)
    
        def get_parcours_apc(self) -> list[ApcParcours]:
            """Liste des parcours proposés par ce semestre.
            Si aucun n'est coché et qu'il y a un référentiel, tous ceux du référentiel.
            """
            r = self.parcours or (
                self.formation.referentiel_competence
                and self.formation.referentiel_competence.parcours
            )
            return r or []
    
        def get_ues(self, with_sport=False) -> list[UniteEns]:
            """UE des modules de ce semestre, triées par numéro.
            - Formations classiques: les UEs auxquelles appartiennent
              les modules mis en place dans ce semestre.
            - Formations APC / BUT: les UEs de la formation qui
                - ont le même numéro de semestre que ce formsemestre;
                - et sont associées à l'un des parcours de ce formsemestre
                     (ou à aucun, donc tronc commun).
            """
            # per-request caching
            key = (self.id, with_sport)
            _cache = getattr(g, "_formsemestre_get_ues_cache", None)
            if _cache:
                result = _cache.get(key, False)
                if result is not False:
                    return result
            else:
                g._formsemestre_get_ues_cache = {}
                _cache = g._formsemestre_get_ues_cache
    
            formation: Formation = self.formation
            if formation.is_apc():
                # UEs de tronc commun (sans parcours indiqué)
                sem_ues = {
                    ue.id: ue
                    for ue in formation.query_ues_parcour(
                        None, with_sport=with_sport
                    ).filter(UniteEns.semestre_idx == self.semestre_id)
                }
                # Ajoute les UE de parcours
                for parcour in self.parcours:
                    sem_ues.update(
                        {
                            ue.id: ue
                            for ue in formation.query_ues_parcour(
                                parcour, with_sport=with_sport
                            ).filter(UniteEns.semestre_idx == self.semestre_id)
                        }
                    )
                ues = sorted(sem_ues.values(), key=attrgetter("numero", "acronyme"))
            else:
                sem_ues = db.session.query(UniteEns).filter(
                    ModuleImpl.formsemestre_id == self.id,
                    Module.id == ModuleImpl.module_id,
                    UniteEns.id == Module.ue_id,
                )
                if not with_sport:
                    sem_ues = sem_ues.filter(UniteEns.type != codes_cursus.UE_SPORT)
                ues = sem_ues.order_by(UniteEns.numero, UniteEns.acronyme).all()
            _cache[key] = ues
            return ues
    
        @classmethod
        def get_user_formsemestres_annee_by_dept(
            cls, user: User
        ) -> tuple[
            defaultdict[int, list["FormSemestre"]], defaultdict[int, list[ModuleImpl]]
        ]:
            """Liste des formsemestres de l'année scolaire
            dans lesquels user intervient (comme resp., resp. de module ou enseignant),
            ainsi que la liste des modimpls concernés dans chaque formsemestre
            Attention: les semestres et modimpls peuvent être de différents départements !
            Résultat:
                { dept_id : [ formsemestre, ... ] },
                { formsemestre_id : [ modimpl, ... ]}
            """
            debut_annee_scolaire = scu.date_debut_annee_scolaire()
            fin_annee_scolaire = scu.date_fin_annee_scolaire()
    
            query = FormSemestre.query.filter(
                FormSemestre.date_fin >= debut_annee_scolaire,
                FormSemestre.date_debut < fin_annee_scolaire,
            )
            # responsable ?
            formsemestres_resp = query.join(notes_formsemestre_responsables).filter_by(
                responsable_id=user.id
            )
            # Responsable d'un modimpl ?
            modimpls_resp = (
                ModuleImpl.query.filter_by(responsable_id=user.id)
                .join(FormSemestre)
                .filter(
                    FormSemestre.date_fin >= debut_annee_scolaire,
                    FormSemestre.date_debut < fin_annee_scolaire,
                )
            )
            # Enseignant dans un modimpl ?
            modimpls_ens = (
                ModuleImpl.query.join(notes_modules_enseignants)
                .filter_by(ens_id=user.id)
                .join(FormSemestre)
                .filter(
                    FormSemestre.date_fin >= debut_annee_scolaire,
                    FormSemestre.date_debut < fin_annee_scolaire,
                )
            )
            # Liste les modimpls, uniques
            modimpls = modimpls_resp.all()
            ids = {modimpl.id for modimpl in modimpls}
            for modimpl in modimpls_ens:
                if modimpl.id not in ids:
                    modimpls.append(modimpl)
                    ids.add(modimpl.id)
            # Liste les formsemestres et modimpls associés
            modimpls_by_formsemestre = defaultdict(lambda: [])
            formsemestres = formsemestres_resp.all()
            ids = {formsemestre.id for formsemestre in formsemestres}
            for modimpl in chain(modimpls_resp, modimpls_ens):
                if modimpl.formsemestre_id not in ids:
                    formsemestres.append(modimpl.formsemestre)
                    ids.add(modimpl.formsemestre_id)
                modimpls_by_formsemestre[modimpl.formsemestre_id].append(modimpl)
            # Tris et organisation par département
            formsemestres_by_dept = defaultdict(lambda: [])
            formsemestres.sort(key=lambda x: (x.departement.acronym,) + x.sort_key())
            for formsemestre in formsemestres:
                formsemestres_by_dept[formsemestre.dept_id].append(formsemestre)
                modimpls = modimpls_by_formsemestre[formsemestre.id]
                if formsemestre.formation.is_apc():
                    key = lambda x: x.module.sort_key_apc()
                else:
                    key = lambda x: x.module.sort_key()
                modimpls.sort(key=key)
    
            return formsemestres_by_dept, modimpls_by_formsemestre
    
        def get_evaluations(self) -> list[Evaluation]:
            "Liste de toutes les évaluations du semestre, triées par module/numero"
            return (
                Evaluation.query.join(ModuleImpl)
                .filter_by(formsemestre_id=self.id)
                .join(Module)
                .order_by(
                    Module.numero,
                    Module.code,
                    Evaluation.numero,
                    Evaluation.date_debut,
                )
                .all()
            )
    
        @cached_property
        def modimpls_sorted(self) -> list[ModuleImpl]:
            """Liste des modimpls du semestre (y compris bonus)
            - triée par type/numéro/code en APC
            - triée par numéros d'UE/matières/modules pour les formations standard.
            Hors APC, élimine les modules de type ressources et SAEs.
            """
            modimpls = self.modimpls.all()
            if self.formation.is_apc():
                modimpls.sort(
                    key=lambda m: (
                        m.module.module_type or 0,  # ressources (2) avant SAEs (3)
                        m.module.numero or 0,
                        m.module.code or 0,
                    )
                )
            else:
                modimpls = [
                    mi
                    for mi in modimpls
                    if (
                        mi.module.module_type
                        not in (scu.ModuleType.RESSOURCE, scu.ModuleType.SAE)
                    )
                ]
                modimpls.sort(
                    key=lambda m: (
                        m.module.ue.numero or 0,
                        m.module.matiere.numero or 0,
                        m.module.numero or 0,
                        m.module.code or "",
                    )
                )
            return modimpls
    
        def modimpls_parcours(self, parcours: ApcParcours) -> list[ModuleImpl]:
            """Liste des modimpls du semestre (sans les bonus (?)) dans le parcours donné.
            - triée par type/numéro/code ??
            """
            cursor = db.session.execute(
                text(
                    """
                SELECT modimpl.id
                FROM notes_moduleimpl modimpl, notes_modules mod,
                parcours_modules pm, parcours_formsemestre pf
                WHERE modimpl.formsemestre_id = :formsemestre_id
                AND modimpl.module_id = mod.id
                AND pm.module_id = mod.id
                AND pm.parcours_id = pf.parcours_id
                AND pf.parcours_id = :parcours_id
                AND pf.formsemestre_id = :formsemestre_id
                """
                ),
                {"formsemestre_id": self.id, "parcours_id": parcours.id},
            )
            return [db.session.get(ModuleImpl, modimpl_id) for modimpl_id in cursor]
    
        def can_be_edited_by(self, user):
            """Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
            if not user.has_permission(Permission.EditFormSemestre):  # pas chef
                if not self.resp_can_edit or user.id not in [
                    resp.id for resp in self.responsables
                ]:
                    return False
    
            return True
    
        def est_courant(self) -> bool:
            """Vrai si la date actuelle (now) est dans le semestre
            (les dates de début et fin sont incluses)
            """
            today = datetime.date.today()
            return self.date_debut <= today <= self.date_fin
    
        def contient_periode(self, date_debut, date_fin) -> bool:
            """Vrai si l'intervalle [date_debut, date_fin] est
            inclus dans le semestre.
            (les dates de début et fin sont incluses)
            """
            return (self.date_debut <= date_debut) and (date_fin <= self.date_fin)
    
        def est_sur_une_annee(self):
            """Test si sem est entièrement sur la même année scolaire.
            (ce n'est pas obligatoire mais si ce n'est pas le
            cas les exports Apogée risquent de mal fonctionner)
            Pivot au 1er août par défaut.
            """
            if self.date_debut > self.date_fin:
                flash(f"Dates début/fin inversées pour le semestre {self.titre_annee()}")
                log(f"Warning: semestre {self.id} begins after ending !")
            annee_debut = self.date_debut.year
            month_debut_annee = ScoDocSiteConfig.get_month_debut_annee_scolaire()
            if self.date_debut.month < month_debut_annee:
                # début sur l'année scolaire précédente (juillet inclus par défaut)
                annee_debut -= 1
            annee_fin = self.date_fin.year
            if self.date_fin.month < (month_debut_annee + 1):
                # 9 (sept) pour autoriser un début en sept et une fin en août
                annee_fin -= 1
            return annee_debut == annee_fin
    
        def est_decale(self):
            """Vrai si semestre "décalé"
            c'est à dire semestres impairs commençant (par défaut)
            entre janvier et juin et les pairs entre juillet et décembre.
            """
            if self.semestre_id <= 0:
                return False  # formations sans semestres
            return (
                # impair
                (
                    self.semestre_id % 2
                    and self.date_debut.month < scu.MONTH_DEBUT_ANNEE_SCOLAIRE
                )
                or
                # pair
                (
                    (not self.semestre_id % 2)
                    and self.date_debut.month >= scu.MONTH_DEBUT_ANNEE_SCOLAIRE
                )
            )
    
        @classmethod
        def est_in_semestre_scolaire(
            cls,
            date_debut: datetime.date,
            year=False,
            periode=None,
            mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
            mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
        ) -> bool:
            """Vrai si la date_debut est dans la période indiquée (1,2,0)
            du semestre `periode` de l'année scolaire indiquée
            (ou, à défaut, de celle en cours).
    
            La période utilise les même conventions que semset["sem_id"];
            * 1 : première période
            * 2 : deuxième période
            * 0 ou période non précisée: annualisé (donc inclut toutes les périodes)
            )
            """
            if not year:
                year = scu.annee_scolaire()
            # n'utilise pas le jour pivot
            jour_pivot_annee = jour_pivot_periode = 1
            # calcule l'année universitaire et la période
            sem_annee, sem_periode = cls.comp_periode(
                date_debut,
                mois_pivot_annee,
                mois_pivot_periode,
                jour_pivot_annee,
                jour_pivot_periode,
            )
            if periode is None or periode == 0:
                return sem_annee == year
            return sem_annee == year and sem_periode == periode
    
        def est_terminal(self) -> bool:
            "Vrai si dernier semestre de son cursus (ou formation mono-semestre)"
            return (self.semestre_id < 0) or (
                self.semestre_id == self.formation.get_cursus().NB_SEM
            )
    
        @classmethod
        def comp_periode(
            cls,
            date_debut: datetime,
            mois_pivot_annee=scu.MONTH_DEBUT_ANNEE_SCOLAIRE,
            mois_pivot_periode=scu.MONTH_DEBUT_PERIODE2,
            jour_pivot_annee=1,
            jour_pivot_periode=1,
        ) -> tuple[int, int]:
            """Calcule la session associée à un formsemestre commençant en date_debut
            sous la forme (année, période)
                année: première année de l'année scolaire
                période = 1 (première période de l'année scolaire, souvent automne)
                       ou 2 (deuxième période de l'année scolaire, souvent printemps)
            Les quatre derniers paramètres forment les dates pivots pour l'année
            (1er août par défaut) et pour la période (1er décembre par défaut).
    
            Les calculs se font à partir de la date de début indiquée.
            Exemples dans tests/unit/test_periode
    
            Implémentation:
            Cas à considérer pour le calcul de la période
    
            pa < pp       -----------------|-------------------|---------------->
                            (A-1,  P:2)   pa    (A, P:1)      pp    (A, P:2)
            pp < pa       -----------------|-------------------|---------------->
                            (A-1,  P:1)   pp     (A-1, P:2)   pa    (A, P:1)
            """
            pivot_annee = 100 * mois_pivot_annee + jour_pivot_annee
            pivot_periode = 100 * mois_pivot_periode + jour_pivot_periode
            pivot_sem = 100 * date_debut.month + date_debut.day
            if pivot_sem < pivot_annee:
                annee = date_debut.year - 1
            else:
                annee = date_debut.year
            if pivot_annee < pivot_periode:
                if pivot_sem < pivot_annee or pivot_sem >= pivot_periode:
                    periode = 2
                else:
                    periode = 1
            else:
                if pivot_sem < pivot_periode or pivot_sem >= pivot_annee:
                    periode = 1
                else:
                    periode = 2
            return annee, periode
    
        def periode(self) -> int:
            """La période:
            * 1 : première période: automne à Paris
            * 2 : deuxième période, printemps à Paris
            """
            return FormSemestre.comp_periode(
                self.date_debut,
                mois_pivot_annee=ScoDocSiteConfig.get_month_debut_annee_scolaire(),
                mois_pivot_periode=ScoDocSiteConfig.get_month_debut_periode2(),
            )
    
        @classmethod
        def get_dept_formsemestres_courants(
            cls, dept: Departement, date_courante: datetime.datetime | None = None
        ) -> Query:
            """Liste (query) ordonnée des formsemestres courants, c'est
            à dire contenant la date courant (si None, la date actuelle)"""
            date_courante = date_courante or db.func.current_date()
            # Les semestres en cours de ce département
            formsemestres = FormSemestre.query.filter(
                FormSemestre.dept_id == dept.id,
                FormSemestre.date_debut <= date_courante,
                FormSemestre.date_fin >= date_courante,
            )
            return formsemestres.order_by(
                FormSemestre.date_debut.desc(),
                FormSemestre.modalite,
                FormSemestre.semestre_id,
                FormSemestre.titre,
            )
    
        def etapes_apo_vdi(self) -> list["ApoEtapeVDI"]:
            "Liste des vdis"
            # was read_formsemestre_etapes
            return [e.as_apovdi() for e in self.etapes if e.etape_apo]
    
        def etapes_apo_str(self) -> str:
            """Chaine décrivant les étapes de ce semestre
            ex: "V1RT, V1RT3, V1RT4"
            """
            if not self.etapes:
                return ""
            return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
    
        def add_etape(self, etape_apo: str | ApoEtapeVDI):
            "Ajoute une étape"
            etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=str(etape_apo))
            db.session.add(etape)
    
        def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
            """Calcule la liste des regroupements cohérents d'UE impliquant ce
            formsemestre.
            Pour une année donnée: l'étudiant est inscrit dans ScoDoc soit dans le semestre
            impair, soit pair, soit les deux (il est rare mais pas impossible d'avoir une
            inscription seulement en semestre pair, par exemple suite à un transfert ou un
            arrêt temporaire du cursus).
    
            1. Déterminer l'*autre* formsemestre: semestre précédent ou suivant de la même
                année, formation compatible (même référentiel de compétence) dans lequel
                l'étudiant est inscrit.
    
            2. Construire les couples d'UE (regroupements cohérents): apparier les UE qui
                ont le même `ApcParcoursNiveauCompetence`.
            """
            if not self.formation.is_apc():
                return []
            raise NotImplementedError()  # XXX
    
        def responsables_str(self, abbrev_prenom=True) -> str:
            """chaîne "J. Dupond, X. Martin"
            ou  "Jacques Dupond, Xavier Martin"
            """
            # was "nomcomplet"
            if not self.responsables:
                return ""
            if abbrev_prenom:
                return ", ".join([u.get_prenomnom() for u in self.responsables])
            else:
                return ", ".join([u.get_nomcomplet() for u in self.responsables])
    
        def est_responsable(self, user: User) -> bool:
            "True si l'user est l'un des responsables du semestre"
            return user.id in [u.id for u in self.responsables]
    
        def est_chef_or_diretud(self, user: User | None = None) -> bool:
            "Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
            user = user or current_user
            return user.has_permission(Permission.EditFormSemestre) or self.est_responsable(
                user
            )
    
        def can_change_groups(self, user: User = None) -> bool:
            """Vrai si l'utilisateur (par def. current) peut changer les groupes dans
            ce semestre: vérifie permission et verrouillage (mais pas si la partition est éditable).
            """
            if not self.etat:
                return False  # semestre verrouillé
            user = user or current_user
            if user.has_permission(Permission.EtudChangeGroups):
                return True  # typiquement admin, chef dept
            return self.est_responsable(user)
    
        def can_edit_jury(self, user: User | None = None):
            """Vrai si utilisateur (par def. current) peut saisir decision de jury
            dans ce semestre: vérifie permission et verrouillage.
            """
            user = user or current_user
            return self.etat and self.est_chef_or_diretud(user)
    
        def can_edit_pv(self, user: User = None):
            "Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre"
            user = user or current_user
            # Autorise les secrétariats, repérés via la permission EtudChangeAdr
            return self.est_chef_or_diretud(user) or user.has_permission(
                Permission.EtudChangeAdr
            )
    
        def annee_scolaire(self) -> int:
            """L'année de début de l'année scolaire.
            Par exemple, 2022 si le semestre va de septembre 2022 à février 2023."""
            return scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
    
        def annee_scolaire_str(self):
            "2021 - 2022"
            return scu.annee_scolaire_repr(self.date_debut.year, self.date_debut.month)
    
        def mois_debut(self) -> str:
            "Oct  2021"
            return f"{MONTH_NAMES_ABBREV[self.date_debut.month - 1]} {self.date_debut.year}"
    
        def mois_fin(self) -> str:
            "Jul  2022"
            return f"{MONTH_NAMES_ABBREV[self.date_fin.month - 1]} {self.date_fin.year}"
    
        def session_id(self) -> str:
            """identifiant externe de semestre de formation
            Exemple:  RT-DUT-FI-S1-ANNEE
    
            DEPT-TYPE-MODALITE+-S?|SPECIALITE
    
            TYPE=DUT|LP*|M*
            MODALITE=FC|FI|FA (si plusieurs, en inverse alpha)
    
            SPECIALITE=[A-Z]+   EON,ASSUR, ... (si pas Sn ou SnD)
    
            ANNEE=annee universitaire de debut (exemple: un S2 de 2013-2014 sera S2-2013)
            """
            prefs = sco_preferences.SemPreferences(dept_id=self.dept_id)
            imputation_dept = prefs["ImputationDept"]
            if not imputation_dept:
                imputation_dept = prefs["DeptName"]
            imputation_dept = imputation_dept.upper()
            cursus_name = self.formation.get_cursus().NAME
            modalite = self.modalite
            # exception pour code Apprentissage:
            modalite = (modalite or "").replace("FAP", "FA").replace("APP", "FA")
            if self.semestre_id > 0:
                decale = "D" if self.est_decale() else ""
                semestre_id = f"S{self.semestre_id}{decale}"
            else:
                semestre_id = self.formation.code_specialite or ""
            annee_sco = str(
                scu.annee_scolaire_debut(self.date_debut.year, self.date_debut.month)
            )
            return scu.sanitize_string(
                f"{imputation_dept}-{cursus_name}-{modalite}-{semestre_id}-{annee_sco}"
            )
    
        def titre_annee(self) -> str:
            """Le titre avec l'année
            'DUT Réseaux et Télécommunications semestre 3 FAP  2020-2021'
            """
            titre_annee = (
                f"{self.titre_num()} {self.modalite or ''}  {self.date_debut.year}"
            )
            if self.date_fin.year != self.date_debut.year:
                titre_annee += "-" + str(self.date_fin.year)
            return titre_annee
    
        def titre_formation(self, with_sem_idx=False):
            """Titre avec formation, court, pour passerelle: "BUT R&T"
            (méthode de formsemestre car on pourrait ajouter le semestre, ou d'autres infos, à voir)
            """
            if with_sem_idx and self.semestre_id > 0:
                return f"{self.formation.acronyme} S{self.semestre_id}"
            return self.formation.acronyme
    
        def titre_mois(self) -> str:
            """Le titre et les dates du semestre, pour affichage dans des listes
            Ex: "BUT QLIO (PN 2022) semestre 1 FI (Sept 2022 - Jan 2023)"
            """
            return f"""{self.titre_num()} {self.modalite or ''} ({
                scu.MONTH_NAMES_ABBREV[self.date_debut.month-1]} {
                    self.date_debut.year} - {
                scu.MONTH_NAMES_ABBREV[self.date_fin.month -1]} {
                    self.date_fin.year})"""
    
        def titre_num(self) -> str:
            """Le titre et le semestre, ex ""DUT Informatique semestre 2"" """
            if self.semestre_id == codes_cursus.NO_SEMESTRE_ID:
                return self.titre
            return f"{self.titre} {self.formation.get_cursus().SESSION_NAME} {self.semestre_id}"
    
        def sem_modalite(self) -> str:
            """Le semestre et la modalité, ex "S2 FI" ou "S3 APP" """
            if self.semestre_id > 0:
                descr_sem = f"S{self.semestre_id}"
            else:
                descr_sem = ""
            if self.modalite:
                descr_sem += " " + self.modalite
            return descr_sem
    
        def get_abs_count(self, etudid) -> tuple[int, int, int]:
            """Les comptes d'absences de cet étudiant dans ce semestre:
            tuple (nb abs non just, nb abs justifiées, nb abs total)
            Utilise un cache.
            """
            from app.scodoc import sco_assiduites
    
            metrique = sco_preferences.get_preference("assi_metrique", self.id)
            return sco_assiduites.get_assiduites_count_in_interval(
                etudid,
                self.date_debut.isoformat(),
                self.date_fin.isoformat(),
                translate_assiduites_metric(metrique),
            )
    
        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
                "passage": code passage
            """
            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}
            if (category is None or category == "passage") and self.elt_passage_apo:
                codes |= {x.strip() for x in self.elt_passage_apo.split(",") if x}
            return codes
    
        def get_inscrits(
            self, include_demdef=False, order=False, etats: set | None = None
        ) -> list[Identite]:
            """Liste des étudiants inscrits à ce semestre
            Si include_demdef, tous les étudiants, avec les démissionnaires
            et défaillants.
            Si etats, seuls les étudiants dans l'un des états indiqués.
            Si order, tri par clé sort_key
            """
            if include_demdef:
                etuds = [ins.etud for ins in self.inscriptions]
            elif not etats:
                etuds = [ins.etud for ins in self.inscriptions if ins.etat == scu.INSCRIT]
            else:
                etuds = [ins.etud for ins in self.inscriptions if ins.etat in etats]
            if order:
                etuds.sort(key=lambda e: e.sort_key)
            return etuds
    
        def inscrit_etudiant(
            self,
            etud: "Identite",
            etat: str = scu.INSCRIT,
            etape: str | None = None,
            method: str | None = None,
        ) -> "FormSemestreInscription":
            """Inscrit l'étudiant au semestre, ou renvoie son inscription s'il l'est déjà.
            Vérifie la capacité d'accueil si indiquée (non null): si le semestre est plein,
            lève une exception. Génère un évènement et un log étudiant.
            method: indique origine de l'inscription pour le log étudiant.
            """
            # remplace ancien do_formsemestre_inscription_create()
            if not self.etat:  # check lock
                raise ScoValueError("inscrit_etudiant: semestre verrouille")
            inscr = FormSemestreInscription.query.filter_by(
                formsemestre_id=self.id, etudid=etud.id
            ).first()
            if inscr is not None:
                return inscr
    
            if self.capacite_accueil is not None:
                # tous sauf démissionnaires:
                inscriptions = self.get_inscrits(etats={scu.INSCRIT, scu.DEF})
                if len(inscriptions) >= self.capacite_accueil:
                    raise ScoValueError(
                        f"Semestre {self.titre} complet : {len(self.inscriptions)} inscrits",
                        dest_url=url_for(
                            "notes.formsemestre_status",
                            scodoc_dept=g.scodoc_dept,
                            formsemestre_id=self.id,
                        ),
                    )
    
            inscr = FormSemestreInscription(
                formsemestre_id=self.id, etudid=etud.id, etat=etat, etape=etape
            )
            db.session.add(inscr)
            # Évènement
            event = ScolarEvent(
                etudid=etud.id,
                formsemestre_id=self.id,
                event_type="INSCRIPTION",
            )
            db.session.add(event)
            # Log etudiant
            Scolog.logdb(
                method=method,
                etudid=etud.id,
                msg=f"inscription en semestre {self.titre_annee()}",
                commit=True,
            )
            log(
                f"inscrit_etudiant: {etud.nomprenom} ({etud.id}) au semestre {self.titre_annee()}"
            )
            # Notification mail
            self._notify_inscription(etud)
            sco_cache.invalidate_formsemestre(formsemestre_id=self.id)
            return inscr
    
        def desinscrit_etudiant(self, etud: Identite):
            "Désinscrit l'étudiant du semestre (et notifie le cas échéant)"
            inscr_sem = FormSemestreInscription.query.filter_by(
                etudid=etud.id, formsemestre_id=self.id
            ).first()
            if not inscr_sem:
                raise ScoValueError(
                    f"{etud.nomprenom} ({etud.id}) n'est pas inscrit au semestre !"
                )
            db.session.delete(inscr_sem)
            Scolog.logdb(
                method="desinscrit_etudiant",
                etudid=etud.id,
                msg=f"désinscription semestre {self.titre_annee()}",
                commit=True,
            )
            log(
                f"desinscrit_etudiant: {etud.nomprenom} ({etud.id}) au semestre {self.titre_annee()}"
            )
            self._notify_inscription(etud, action="désinscrit")
            sco_cache.invalidate_formsemestre(formsemestre_id=self.id)
    
        def _notify_inscription(self, etud: Identite, action="inscrit") -> None:
            "Notifie inscription d'un étudiant: envoie un mail selon paramétrage"
            destinations = (
                sco_preferences.get_preference("emails_notifications_inscriptions", self.id)
                or ""
            )
            destinations = [x.strip() for x in destinations.split(",")]
            destinations = [x for x in destinations if x]
            if not destinations:
                return
            txt = f"""{etud.nom_prenom()}
            s'est {action}{etud.e}
            en {self.titre_annee()}"""
            subject = f"""Inscription de {etud.nom_prenom()} en {self.titre_annee()}"""
            # build mail
            log(f"_notify_inscription: sending notification to {destinations}")
            log(f"_notify_inscription: subject: {subject}")
            log(txt)
            email.send_email(
                "[ScoDoc] " + subject, email.get_from_addr(), destinations, txt
            )
    
        def get_partitions_list(
            self, with_default=True, only_listed=False
        ) -> list[Partition]:
            """Liste des partitions pour ce semestre (list of dicts),
            triées par numéro, avec la partition par défaut en fin de liste.
            Si only_listed, seulement les partitions indiquées "à lister" (show_in_lists).
            """
            if only_listed:
                partitions = [
                    p
                    for p in self.partitions
                    if p.partition_name is not None and p.show_in_lists
                ]
            else:
                partitions = [p for p in self.partitions if p.partition_name is not None]
            if with_default:
                partitions += [p for p in self.partitions if p.partition_name is None]
            return partitions
    
        def etudids_actifs(self) -> tuple[list[int], set[int]]:
            """Liste les etudids inscrits (incluant DEM et DEF),
            qui sera l'index des dataframes de notes
            et donne l'ensemble des inscrits non DEM ni DEF.
            """
            return [inscr.etudid for inscr in self.inscriptions], {
                ins.etudid for ins in self.inscriptions if ins.etat == scu.INSCRIT
            }
    
        @property
        def etuds_inscriptions(self) -> dict:
            """Map { etudid : inscription } (incluant DEM et DEF)"""
            return {ins.etud.id: ins for ins in self.inscriptions}
    
        def setup_parcours_groups(self) -> None:
            """Vérifie et créee si besoin la partition et les groupes de parcours BUT."""
            if not self.formation.is_apc():
                return
            partition = Partition.query.filter_by(
                formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
            ).first()
            if partition is None:
                # Création de la partition de parcours
                partition = Partition(
                    formsemestre_id=self.id,
                    partition_name=scu.PARTITION_PARCOURS,
                    numero=-1,
                    groups_editable=False,
                )
                db.session.add(partition)
                db.session.flush()  # pour avoir un id
                flash("Partition Parcours créée.")
            elif partition.groups_editable:
                # Il ne faut jamais laisser éditer cette partition de parcours
                partition.groups_editable = False
                db.session.add(partition)
    
            for parcour in self.get_parcours_apc():
                if parcour.code:
                    group = GroupDescr.query.filter_by(
                        partition_id=partition.id, group_name=parcour.code
                    ).first()
                    if not group:
                        partition.groups.append(GroupDescr(group_name=parcour.code))
            db.session.flush()
            # S'il reste des groupes de parcours qui ne sont plus dans le semestre
            #  - s'ils n'ont pas d'inscrits, supprime-les.
            #  - s'ils ont des inscrits: avertissement
            for group in GroupDescr.query.filter_by(partition_id=partition.id):
                if group.group_name not in (p.code for p in self.get_parcours_apc()):
                    if (
                        len(
                            [
                                inscr
                                for inscr in self.inscriptions
                                if (inscr.parcour is not None)
                                and inscr.parcour.code == group.group_name
                            ]
                        )
                        == 0
                    ):
                        flash(f"Suppression du groupe de parcours vide {group.group_name}")
                        db.session.delete(group)
                    else:
                        flash(
                            f"""Attention: groupe de parcours {group.group_name} non vide:
                            réaffectez ses étudiants dans des parcours du semestre"""
                        )
    
            db.session.commit()
    
        def update_inscriptions_parcours_from_groups(self, etudid: int = None) -> None:
            """Met à jour les inscriptions dans les parcours du semestres en
            fonction des groupes de parcours.
    
            Les groupes de parcours sont ceux de la partition scu.PARTITION_PARCOURS
            et leur nom est le code du parcours (eg "Cyber").
    
            Si etudid est spécifié, n'affecte que cet étudiant,
            sinon traite tous les inscrits du semestre.
            """
            if self.formation.referentiel_competence_id is None:
                return  # safety net
            partition = Partition.query.filter_by(
                formsemestre_id=self.id, partition_name=scu.PARTITION_PARCOURS
            ).first()
            if partition is None:  # pas de partition de parcours
                return
    
            # Efface les inscriptions aux parcours:
            if etudid:
                db.session.execute(
                    text(
                        """UPDATE notes_formsemestre_inscription
                            SET parcour_id=NULL
                            WHERE formsemestre_id=:formsemestre_id
                            AND etudid=:etudid
                        """
                    ),
                    {
                        "formsemestre_id": self.id,
                        "etudid": etudid,
                    },
                )
            else:
                db.session.execute(
                    text(
                        """UPDATE notes_formsemestre_inscription
                            SET parcour_id=NULL
                            WHERE formsemestre_id=:formsemestre_id
                        """
                    ),
                    {
                        "formsemestre_id": self.id,
                    },
                )
            # Inscrit les étudiants des groupes de parcours:
            for group in partition.groups:
                query = (
                    ApcParcours.query.filter_by(code=group.group_name)
                    .join(ApcReferentielCompetences)
                    .filter_by(
                        dept_id=g.scodoc_dept_id,
                        id=self.formation.referentiel_competence_id,
                    )
                )
                if query.count() != 1:
                    log(
                        f"""update_inscriptions_parcours_from_groups: {
                            query.count()} parcours with code {group.group_name}"""
                    )
                    continue
                parcour = query.first()
                if etudid:
                    db.session.execute(
                        text(
                            """UPDATE notes_formsemestre_inscription ins
                            SET parcour_id=:parcour_id
                            FROM group_membership gm
                            WHERE formsemestre_id=:formsemestre_id
                            AND ins.etudid = :etudid
                            AND gm.etudid = :etudid
                            AND gm.group_id = :group_id
                            """
                        ),
                        {
                            "etudid": etudid,
                            "formsemestre_id": self.id,
                            "parcour_id": parcour.id,
                            "group_id": group.id,
                        },
                    )
                else:
                    db.session.execute(
                        text(
                            """UPDATE notes_formsemestre_inscription ins
                            SET parcour_id=:parcour_id
                            FROM group_membership gm
                            WHERE formsemestre_id=:formsemestre_id
                            AND gm.etudid = ins.etudid
                            AND gm.group_id = :group_id
                            """
                        ),
                        {
                            "formsemestre_id": self.id,
                            "parcour_id": parcour.id,
                            "group_id": group.id,
                        },
                    )
            db.session.commit()
            sco_cache.invalidate_formsemestre(formsemestre_id=self.id)
    
        def etud_validations_description_html(self, etudid: int) -> str:
            """Description textuelle des validations de jury de cet étudiant dans ce semestre"""
            from app.models.but_validations import ApcValidationAnnee, ApcValidationRCUE
    
            vals_sem = ScolarFormSemestreValidation.query.filter_by(
                etudid=etudid, formsemestre_id=self.id, ue_id=None
            ).all()
            vals_ues = (
                ScolarFormSemestreValidation.query.filter_by(
                    etudid=etudid, formsemestre_id=self.id
                )
                .join(UniteEns)
                .order_by(UniteEns.numero, UniteEns.acronyme)
                .all()
            )
            # Validations BUT:
            vals_rcues = (
                ApcValidationRCUE.query.filter_by(etudid=etudid, formsemestre_id=self.id)
                .join(UniteEns, ApcValidationRCUE.ue1)
                .order_by(UniteEns.numero, UniteEns.acronyme)
                .all()
            )
            vals_annee = (  # issues de cette année scolaire seulement
                ApcValidationAnnee.query.filter_by(
                    etudid=etudid,
                    annee_scolaire=self.annee_scolaire(),
                    referentiel_competence_id=self.formation.referentiel_competence_id,
                ).all()
            )
            H = []
            for vals in (vals_sem, vals_ues, vals_rcues, vals_annee):
                if vals:
                    H.append(
                        f"""<ul><li>{"</li><li>".join(str(x) for x in vals)}</li></ul>"""
                    )
            return "\n".join(H)
    
        def etud_set_all_missing_notes(self, etud: Identite, value=None) -> int:
            """Met toutes les notes manquantes de cet étudiant dans ce semestre
            (ie dans toutes les évaluations des modules auxquels il est inscrit et n'a pas de note)
            à la valeur donnée par value, qui est en général "ATT", "ABS", "EXC".
            """
            from app.scodoc import sco_saisie_notes
    
            inscriptions = (
                ModuleImplInscription.query.filter_by(etudid=etud.id)
                .join(ModuleImpl)
                .filter_by(formsemestre_id=self.id)
            )
            nb_recorded = 0
            for inscription in inscriptions:
                for evaluation in inscription.modimpl.evaluations:
                    if evaluation.get_etud_note(etud) is None:
                        if not sco_saisie_notes.do_evaluation_set_etud_note(
                            evaluation, etud, value
                        ):
                            raise ScoValueError(
                                "erreur lors de l'enregistrement de la note"
                            )
                        nb_recorded += 1
            return nb_recorded
    
        def change_formation(self, formation_dest: Formation):
            """Associe ce formsemestre à une autre formation.
            Ce n'est possible que si la formation destination possède des modules de
            même code que ceux utilisés dans la formation d'origine du formsemestre.
            S'il manque un module, l'opération est annulée.
            Commit (or rollback) session.
            """
            ok = True
            for mi in self.modimpls:
                dest_modules = formation_dest.modules.filter_by(code=mi.module.code).all()
                match len(dest_modules):
                    case 1:
                        mi.module = dest_modules[0]
                        db.session.add(mi)
                    case 0:
                        print(f"Argh ! no module found with code={mi.module.code}")
                        ok = False
                    case _:
                        print(f"Arg ! several modules found with code={mi.module.code}")
                        ok = False
    
            if ok:
                self.formation_id = formation_dest.id
                db.session.commit()
            else:
                db.session.rollback()
    
    
    # Association id des utilisateurs responsables (aka directeurs des etudes) du semestre
    notes_formsemestre_responsables = db.Table(
        "notes_formsemestre_responsables",
        db.Column(
            "formsemestre_id",
            db.Integer,
            db.ForeignKey("notes_formsemestre.id"),
        ),
        db.Column("responsable_id", db.Integer, db.ForeignKey("user.id")),
    )
    
    
    class FormSemestreEtape(models.ScoDocModel):
        """Étape Apogée associée au semestre"""
    
        __tablename__ = "notes_formsemestre_etapes"
        id = db.Column(db.Integer, primary_key=True)
        formsemestre_id = db.Column(
            db.Integer,
            db.ForeignKey("notes_formsemestre.id"),
        )
        # etape_apo aurait du etre not null, mais oublié
        etape_apo = db.Column(db.String(APO_CODE_STR_LEN), index=True)
    
        @classmethod
        def create_from_apovdi(
            cls, formsemestre_id: int, apovdi: ApoEtapeVDI
        ) -> "FormSemestreEtape":
            "Crée une instance à partir d'un objet ApoEtapeVDI. Ajoute à la session."
            etape = cls(formsemestre_id=formsemestre_id, etape_apo=str(apovdi))
            db.session.add(etape)
            return etape
    
        def __bool__(self):
            "Etape False if code empty"
            return self.etape_apo is not None and (len(self.etape_apo) > 0)
    
        def __eq__(self, other):
            if isinstance(other, ApoEtapeVDI):
                return self.as_apovdi() == other
            return str(self) == str(other)
    
        def __repr__(self):
            return f"<Etape {self.id} apo={self.etape_apo!r}>"
    
        def __str__(self):
            return self.etape_apo or ""
    
        def as_apovdi(self) -> "ApoEtapeVDI":
            return ApoEtapeVDI(self.etape_apo)
    
    
    class FormationModalite(models.ScoDocModel):
        """Modalités de formation, utilisées pour la présentation
        (grouper les semestres, générer des codes, etc.)
        """
    
        __tablename__ = "notes_form_modalites"
    
        DEFAULT_MODALITE = "FI"
    
        id = db.Column(db.Integer, primary_key=True)
        modalite = db.Column(
            db.String(SHORT_STR_LEN),
            unique=True,
            index=True,
            default=DEFAULT_MODALITE,
            server_default=DEFAULT_MODALITE,
        )  # code
        titre = db.Column(db.Text())  # texte explicatif
        # numero = ordre de presentation)
        numero = db.Column(db.Integer, nullable=False, default=0)
    
        @staticmethod
        def insert_modalites():
            """Create default modalities"""
            numero = 0
            try:
                for code, titre in (
                    (FormationModalite.DEFAULT_MODALITE, "Formation Initiale"),
                    ("FAP", "Apprentissage"),
                    ("FC", "Formation Continue"),
                    ("DEC", "Formation Décalées"),
                    ("LIC", "Licence"),
                    ("CPRO", "Contrats de Professionnalisation"),
                    ("DIST", "À distance"),
                    ("ETR", "À l'étranger"),
                    ("EXT", "Extérieur"),
                    ("OTHER", "Autres formations"),
                ):
                    modalite = FormationModalite.query.filter_by(modalite=code).first()
                    if modalite is None:
                        modalite = FormationModalite(
                            modalite=code, titre=titre, numero=numero
                        )
                        db.session.add(modalite)
                        numero += 1
                db.session.commit()
            except:
                db.session.rollback()
                raise
    
    
    class FormSemestreUECoef(models.ScoDocModel):
        """Coef des UE capitalisees arrivant dans ce semestre"""
    
        __tablename__ = "notes_formsemestre_uecoef"
        __table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id"),)
    
        id = db.Column(db.Integer, primary_key=True)
        formsemestre_uecoef_id = db.synonym("id")
        formsemestre_id = db.Column(
            db.Integer,
            db.ForeignKey("notes_formsemestre.id"),
            index=True,
        )
        ue_id = db.Column(
            db.Integer,
            db.ForeignKey("notes_ue.id"),
            index=True,
        )
        coefficient = db.Column(db.Float, nullable=False)
    
    
    class FormSemestreUEComputationExpr(db.Model):
        """Formules utilisateurs pour calcul moyenne UE (désactivées en 9.2+)."""
    
        __tablename__ = "notes_formsemestre_ue_computation_expr"
        __table_args__ = (db.UniqueConstraint("formsemestre_id", "ue_id"),)
    
        id = db.Column(db.Integer, primary_key=True)
        notes_formsemestre_ue_computation_expr_id = db.synonym("id")
        formsemestre_id = db.Column(
            db.Integer,
            db.ForeignKey("notes_formsemestre.id"),
        )
        ue_id = db.Column(
            db.Integer,
            db.ForeignKey("notes_ue.id"),
        )
        # formule de calcul moyenne
        computation_expr = db.Column(db.Text())
    
    
    class FormSemestreCustomMenu(models.ScoDocModel):
        """Menu custom associe au semestre"""
    
        __tablename__ = "notes_formsemestre_custommenu"
    
        id = db.Column(db.Integer, primary_key=True)
        custommenu_id = db.synonym("id")
        formsemestre_id = db.Column(
            db.Integer,
            db.ForeignKey("notes_formsemestre.id"),
        )
        title = db.Column(db.Text())
        url = db.Column(db.Text())
        idx = db.Column(db.Integer, default=0, server_default="0")  #  rang dans le menu
    
    
    class FormSemestreInscription(models.ScoDocModel):
        """Inscription à un semestre de formation"""
    
        __tablename__ = "notes_formsemestre_inscription"
        __table_args__ = (db.UniqueConstraint("formsemestre_id", "etudid"),)
    
        id = db.Column(db.Integer, primary_key=True)
        formsemestre_inscription_id = db.synonym("id")
    
        etudid = db.Column(
            db.Integer, db.ForeignKey("identite.id", ondelete="CASCADE"), index=True
        )
        formsemestre_id = db.Column(
            db.Integer,
            db.ForeignKey("notes_formsemestre.id"),
            index=True,
        )
        etud = db.relationship(
            Identite,
            backref=db.backref("formsemestre_inscriptions", cascade="all, delete-orphan"),
        )
        formsemestre = db.relationship(
            FormSemestre,
            backref=db.backref(
                "inscriptions",
                cascade="all, delete-orphan",
                order_by="FormSemestreInscription.etudid",
            ),
        )
        # I inscrit, D demission en cours de semestre, DEF si "defaillant"
        etat = db.Column(db.String(CODE_STR_LEN), index=True)
        # Etape Apogée d'inscription (ajout 2020)
        etape = db.Column(db.String(APO_CODE_STR_LEN))
        # Parcours (pour les BUT)
        parcour_id = db.Column(
            db.Integer, db.ForeignKey("apc_parcours.id", ondelete="SET NULL"), index=True
        )
        parcour = db.relationship(ApcParcours)
    
        def __repr__(self):
            return f"""<{self.__class__.__name__} {self.id} etudid={self.etudid} sem={
                self.formsemestre_id} (S{self.formsemestre.semestre_id}) etat={self.etat} {
                ('parcours="'+str(self.parcour.code)+'"') if self.parcour else ''
                } {('etape="'+self.etape+'"') if self.etape else ''}>"""
    
    
    class NotesSemSet(models.ScoDocModel):
        """semsets: ensemble de formsemestres pour exports Apogée"""
    
        __tablename__ = "notes_semset"
    
        id = db.Column(db.Integer, primary_key=True)
        semset_id = db.synonym("id")
        dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"))
    
        title = db.Column(db.Text)
        annee_scolaire = db.Column(db.Integer, nullable=True, default=None)
        sem_id = db.Column(db.Integer, nullable=False, default=0)
        "période: 0 (année), 1 (Simpair), 2 (Spair)"
    
        def set_periode(self, periode: int):
            """Modifie la période 0 (année), 1 (Simpair), 2 (Spair)"""
            if periode not in {0, 1, 2}:
                raise ValueError("periode invalide")
            self.sem_id = periode
            log(f"semset.set_periode({self.id}, {periode})")
            db.session.add(self)
            db.session.commit()
    
    
    # Association: many to many
    notes_semset_formsemestre = db.Table(
        "notes_semset_formsemestre",
        db.Column("formsemestre_id", db.Integer, db.ForeignKey("notes_formsemestre.id")),
        db.Column(
            "semset_id",
            db.Integer,
            db.ForeignKey("notes_semset.id", ondelete="CASCADE"),
            nullable=False,
        ),
        db.UniqueConstraint("formsemestre_id", "semset_id"),
    )