From 8a49d99292a9dd80e56838da3d65defd57be9a5b Mon Sep 17 00:00:00 2001
From: Emmanuel Viennet <emmanuel.viennet@gmail.com>
Date: Tue, 18 Jun 2024 01:21:33 +0200
Subject: [PATCH] =?UTF-8?q?Clonage=20semestre:=20am=C3=A9liore=20code=20+?=
 =?UTF-8?q?=20qq=20modifs=20cosm=C3=A9tiques?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 app/models/formsemestre.py            | 73 ++++++++++++++++++++--
 app/models/groups.py                  |  4 ++
 app/scodoc/sco_edit_ue.py             |  4 +-
 app/scodoc/sco_formsemestre.py        |  5 +-
 app/scodoc/sco_formsemestre_edit.py   | 90 ++++++++++++++++-----------
 app/scodoc/sco_formsemestre_status.py |  4 +-
 app/scodoc/sco_utils.py               |  6 +-
 app/scodoc/sco_vdi.py                 |  4 +-
 8 files changed, 141 insertions(+), 49 deletions(-)

diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 738a47442..eb10f57b7 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -36,6 +36,7 @@ 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 ScolarNews
 from app.models.formations import Formation
 from app.models.groups import GroupDescr, Partition
 from app.models.moduleimpls import (
@@ -207,6 +208,70 @@ class FormSemestre(models.ScoDocModel):
             ).first_or_404()
         return cls.query.filter_by(id=formsemestre_id).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["etapes"]:
+            formsemestre.add_etape(etape)
+        db.session.commit()
+        for u in args["responsables"]:
+            formsemestre.responsables.append(u)
+        # 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_debut"])
+        if "etat" in args:
+            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"
+        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)"""
@@ -729,7 +794,7 @@ class FormSemestre(models.ScoDocModel):
             FormSemestre.titre,
         )
 
-    def etapes_apo_vdi(self) -> list[ApoEtapeVDI]:
+    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]
@@ -742,9 +807,9 @@ class FormSemestre(models.ScoDocModel):
             return ""
         return ", ".join(sorted([etape.etape_apo for etape in self.etapes if etape]))
 
-    def add_etape(self, etape_apo: str):
+    def add_etape(self, etape_apo: str | ApoEtapeVDI):
         "Ajoute une étape"
-        etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=etape_apo)
+        etape = FormSemestreEtape(formsemestre_id=self.id, etape_apo=str(etape_apo))
         db.session.add(etape)
 
     def regroupements_coherents_etud(self) -> list[tuple[UniteEns, UniteEns]]:
@@ -1271,7 +1336,7 @@ class FormSemestreEtape(db.Model):
     def __str__(self):
         return self.etape_apo or ""
 
-    def as_apovdi(self) -> ApoEtapeVDI:
+    def as_apovdi(self) -> "ApoEtapeVDI":
         return ApoEtapeVDI(self.etape_apo)
 
 
diff --git a/app/models/groups.py b/app/models/groups.py
index 7250f1e67..68c7156b8 100644
--- a/app/models/groups.py
+++ b/app/models/groups.py
@@ -93,6 +93,10 @@ class Partition(ScoDocModel):
         ):
             group.remove_etud(etud)
 
+    def is_default(self) -> bool:
+        "vrai si partition par défault (tous les étudiants)"
+        return not self.partition_name
+
     def is_parcours(self) -> bool:
         "Vrai s'il s'agit de la partition de parcours"
         return self.partition_name == scu.PARTITION_PARCOURS
diff --git a/app/scodoc/sco_edit_ue.py b/app/scodoc/sco_edit_ue.py
index 152103aef..9d8df2d44 100644
--- a/app/scodoc/sco_edit_ue.py
+++ b/app/scodoc/sco_edit_ue.py
@@ -1056,10 +1056,10 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
     if current_user.has_permission(Permission.EditFormSemestre):
         H.append(
             f"""<ul>
-        <li><a class="stdlink" href="{
+        <li><b><a class="stdlink" href="{
             url_for('notes.formsemestre_createwithmodules', scodoc_dept=g.scodoc_dept,
             formation_id=formation_id, semestre_id=1)
-        }">Mettre en place un nouveau semestre de formation {formation.acronyme}</a>
+        }">Mettre en place un nouveau semestre de formation {formation.acronyme}</a></b>
         </li>
         </ul>"""
         )
diff --git a/app/scodoc/sco_formsemestre.py b/app/scodoc/sco_formsemestre.py
index 0a9264ea8..16daef1a7 100644
--- a/app/scodoc/sco_formsemestre.py
+++ b/app/scodoc/sco_formsemestre.py
@@ -229,11 +229,14 @@ def etapes_apo_str(etapes):
     return ", ".join([str(x) for x in etapes])
 
 
-def do_formsemestre_create(args, silent=False):
+def do_formsemestre_create(  # DEPRECATED, use FormSemestre.create_formsemestre()
+    args, silent=False
+):
     "create a formsemestre"
     from app.models import ScolarNews
     from app.scodoc import sco_groups
 
+    log("Warning: do_formsemestre_create is deprecated")
     cnx = ndb.GetDBConnexion()
     formsemestre_id = _formsemestreEditor.create(cnx, args)
     if args["etapes"]:
diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
index 07717f591..70237d20b 100644
--- a/app/scodoc/sco_formsemestre_edit.py
+++ b/app/scodoc/sco_formsemestre_edit.py
@@ -37,16 +37,17 @@ from app import db
 from app.auth.models import User
 from app.models import APO_CODE_STR_LEN, SHORT_STR_LEN
 from app.models import (
+    ApcValidationAnnee,
+    ApcValidationRCUE,
+    Evaluation,
+    FormSemestreUECoef,
     Module,
     ModuleImpl,
-    Evaluation,
-    UniteEns,
     ScoDocSiteConfig,
-    ScolarFormSemestreValidation,
     ScolarAutorisationInscription,
-    ApcValidationAnnee,
-    ApcValidationRCUE,
+    ScolarFormSemestreValidation,
     ScolarNews,
+    UniteEns,
 )
 from app.models.formations import Formation
 from app.models.formsemestre import FormSemestre
@@ -439,12 +440,13 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
             {
                 "size": 32,
                 "title": "Element(s) Apogée sem.:",
-                "explanation": "associé(s) au résultat du semestre (ex: VRTW1). Inutile en BUT. Séparés par des virgules.",
-                "allow_null": not sco_preferences.get_preference(
-                    "always_require_apo_sem_codes"
-                )
-                or (formsemestre and formsemestre.modalite == "EXT")
-                or (formsemestre.formation.is_apc()),
+                "explanation": """associé(s) au résultat du semestre (ex: VRTW1).
+                    Inutile en BUT. Séparés par des virgules.""",
+                "allow_null": (
+                    not sco_preferences.get_preference("always_require_apo_sem_codes")
+                    or (formsemestre and formsemestre.modalite == "EXT")
+                    or (formsemestre and formsemestre.formation.is_apc())
+                ),
             },
         )
     )
@@ -1250,7 +1252,7 @@ def formsemestre_clone(formsemestre_id):
             raise ScoValueError("id responsable invalide")
         new_formsemestre_id = do_formsemestre_clone(
             formsemestre_id,
-            resp.id,
+            resp,
             tf[2]["date_debut"],
             tf[2]["date_fin"],
             clone_evaluations=tf[2]["clone_evaluations"],
@@ -1268,7 +1270,7 @@ def formsemestre_clone(formsemestre_id):
 
 def do_formsemestre_clone(
     orig_formsemestre_id,
-    responsable_id,  # new resp.
+    responsable: User,  # new resp.
     date_debut,
     date_fin,  # 'dd/mm/yyyy'
     clone_evaluations=False,
@@ -1281,49 +1283,63 @@ def do_formsemestre_clone(
     formsemestre_orig: FormSemestre = FormSemestre.query.get_or_404(
         orig_formsemestre_id
     )
-    orig_sem = sco_formsemestre.get_formsemestre(orig_formsemestre_id)
-    cnx = ndb.GetDBConnexion()
     # 1- create sem
-    args = orig_sem.copy()
+    args = formsemestre_orig.to_dict()
     del args["formsemestre_id"]
-    args["responsables"] = [responsable_id]
+    del args["id"]
+    del args["parcours"]  # copiés ensuite
+    args["responsables"] = [responsable]
     args["date_debut"] = date_debut
     args["date_fin"] = date_fin
     args["etat"] = 1  # non verrouillé
-    formsemestre_id = sco_formsemestre.do_formsemestre_create(args)
-    log(f"created formsemestre {formsemestre_id}")
-    formsemestre: FormSemestre = db.session.get(FormSemestre, formsemestre_id)
+
+    formsemestre = FormSemestre.create_formsemestre(args)
+    log(f"created formsemestre {formsemestre}")
     # 2- create moduleimpls
     modimpl_orig: ModuleImpl
     for modimpl_orig in formsemestre_orig.modimpls:
+        assert isinstance(modimpl_orig, ModuleImpl)
+        assert isinstance(modimpl_orig.id, int)
+        log(f"cloning {modimpl_orig}")
         args = modimpl_orig.to_dict(with_module=False)
-        args["formsemestre_id"] = formsemestre_id
+        args["formsemestre_id"] = formsemestre.id
         modimpl_new = ModuleImpl.create_from_dict(args)
+        log(f"created ModuleImpl from {args}")
         db.session.flush()
         # copy enseignants
         for ens in modimpl_orig.enseignants:
             modimpl_new.enseignants.append(ens)
         db.session.add(modimpl_new)
+        db.session.flush()
+        log(f"new moduleimpl.id = {modimpl_new.id}")
         # optionally, copy evaluations
         if clone_evaluations:
+            e: Evaluation
             for e in Evaluation.query.filter_by(moduleimpl_id=modimpl_orig.id):
+                log(f"cloning evaluation {e.id}")
                 # copie en enlevant la date
-                new_eval = e.clone(
-                    not_copying=("date_debut", "date_fin", "moduleimpl_id")
-                )
-                new_eval.moduleimpl_id = modimpl_new.id
+                args = dict(e.__dict__)
+                args.pop("_sa_instance_state")
+                args.pop("id")
+                args["moduleimpl_id"] = modimpl_new.id
+                new_eval = Evaluation(**args)
+                db.session.add(new_eval)
+                db.session.commit()
                 # Copie les poids APC de l'évaluation
                 new_eval.set_ue_poids_dict(e.get_ue_poids_dict())
     db.session.commit()
 
     # 3- copy uecoefs
-    objs = sco_formsemestre.formsemestre_uecoef_list(
-        cnx, args={"formsemestre_id": orig_formsemestre_id}
-    )
-    for obj in objs:
-        args = obj.copy()
-        args["formsemestre_id"] = formsemestre_id
-        _ = sco_formsemestre.formsemestre_uecoef_create(cnx, args)
+    for ue_coef in FormSemestreUECoef.query.filter_by(
+        formsemestre_id=formsemestre_orig.id
+    ):
+        new_ue_coef = FormSemestreUECoef(
+            formsemestre_id=formsemestre.id,
+            ue_id=ue_coef.ue_id,
+            coefficient=ue_coef.coefficient,
+        )
+        db.session.add(new_ue_coef)
+    db.session.flush()
 
     # NB: don't copy notes_formsemestre_custommenu (usually specific)
 
@@ -1335,11 +1351,11 @@ def do_formsemestre_clone(
             if not prefs.is_global(pname):
                 pvalue = prefs[pname]
                 try:
-                    prefs.base_prefs.set(formsemestre_id, pname, pvalue)
+                    prefs.base_prefs.set(formsemestre.id, pname, pvalue)
                 except ValueError:
                     log(
-                        "do_formsemestre_clone: ignoring old preference %s=%s for %s"
-                        % (pname, pvalue, formsemestre_id)
+                        f"""do_formsemestre_clone: ignoring old preference {
+                            pname}={pvalue} for {formsemestre}"""
                     )
 
     # 5- Copie les parcours
@@ -1350,10 +1366,10 @@ def do_formsemestre_clone(
     # 6- Copy partitions and groups
     if clone_partitions:
         sco_groups_copy.clone_partitions_and_groups(
-            orig_formsemestre_id, formsemestre_id
+            orig_formsemestre_id, formsemestre.id
         )
 
-    return formsemestre_id
+    return formsemestre.id
 
 
 def formsemestre_delete(formsemestre_id: int) -> str | flask.Response:
diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py
index fc7df7311..8c3ac414a 100755
--- a/app/scodoc/sco_formsemestre_status.py
+++ b/app/scodoc/sco_formsemestre_status.py
@@ -794,7 +794,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
                 <div class="sem-groups-partition-titre">{
                     'Groupes de ' + partition.partition_name
                     if partition.partition_name else
-                    'Tous les étudiants'}
+                    ('aucun étudiant inscrit' if partition_is_empty else 'Tous les étudiants')}
                 </div>
                 <div class="sem-groups-partition-titre">{
                     "Assiduité" if not partition_is_empty else ""
@@ -885,7 +885,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
                 )
 
                 H.append("</div>")  # /sem-groups-assi
-        if partition_is_empty:
+        if partition_is_empty and not partition.is_default():
             H.append(
                 '<div class="help sem-groups-none">Aucun groupe peuplé dans cette partition'
             )
diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py
index 07aaacae8..bc5943e23 100644
--- a/app/scodoc/sco_utils.py
+++ b/app/scodoc/sco_utils.py
@@ -109,13 +109,17 @@ ETATS_INSCRIPTION = {
 }
 
 
-def convert_fr_date(date_str: str, allow_iso=True) -> datetime.datetime:
+def convert_fr_date(
+    date_str: str | datetime.datetime, allow_iso=True
+) -> datetime.datetime:
     """Converti une date saisie par un humain français avant 2070
     en un objet datetime.
     12/2/1972 => 1972-02-12, 12/2/72 => 1972-02-12, mais 12/2/24 => 2024-02-12
     Le pivot est 70.
     ScoValueError si date invalide.
     """
+    if isinstance(date_str, datetime.datetime):
+        return date_str
     try:
         return datetime.datetime.strptime(date_str, DATE_FMT)
     except ValueError:
diff --git a/app/scodoc/sco_vdi.py b/app/scodoc/sco_vdi.py
index 09d1a90a2..0ceca257a 100644
--- a/app/scodoc/sco_vdi.py
+++ b/app/scodoc/sco_vdi.py
@@ -30,7 +30,7 @@
 from app.scodoc.sco_exceptions import ScoValueError
 
 
-class ApoEtapeVDI(object):
+class ApoEtapeVDI:
     """Classe stockant le VDI avec le code étape (noms de fichiers maquettes et code semestres)"""
 
     _ETAPE_VDI_SEP = "!"
@@ -118,7 +118,7 @@ class ApoEtapeVDI(object):
         else:
             return etape_vdi, ""
 
-    def concat_etape_vdi(self, etape, vdi=""):
+    def concat_etape_vdi(self, etape: str, vdi: str = "") -> str:
         if vdi:
             return self._ETAPE_VDI_SEP.join([etape, vdi])
         else:
-- 
GitLab