diff --git a/app/auth/models.py b/app/auth/models.py
index 073f687e9a019907174e7cb7245e86d20792f3ba..3115119135f9d88a94ec991a8f1f0cb4d6f60bce 100644
--- a/app/auth/models.py
+++ b/app/auth/models.py
@@ -353,8 +353,8 @@ class User(UserMixin, db.Model):
         return mails
 
     # Permissions management:
-    def has_permission(self, perm: int, dept=False):
-        """Check if user has permission `perm` in given `dept`.
+    def has_permission(self, perm: int, dept: str = False):
+        """Check if user has permission `perm` in given `dept` (acronym).
         Similar to Zope ScoDoc7 `has_permission``
 
         Args:
diff --git a/app/models/assiduites.py b/app/models/assiduites.py
index 82b6d3dc3ee57f082538fd73e06419f68e29b8dc..0435281a368f39f4c95db5c4c6259561cba3ab09 100644
--- a/app/models/assiduites.py
+++ b/app/models/assiduites.py
@@ -70,16 +70,11 @@ class Assiduite(db.Model):
     def to_dict(self, format_api=True) -> dict:
         """Retourne la représentation json de l'assiduité"""
         etat = self.etat
-        username = self.user_id
+        user: User = None
         if format_api:
             etat = EtatAssiduite.inverse().get(self.etat).name
             if self.user_id is not None:
-                user: User = db.session.get(User, self.user_id)
-
-                if user is None:
-                    username = "Non renseigné"
-                else:
-                    username = user.get_prenomnom()
+                user = db.session.get(User, self.user_id)
         data = {
             "assiduite_id": self.id,
             "etudid": self.etudid,
@@ -90,7 +85,8 @@ class Assiduite(db.Model):
             "etat": etat,
             "desc": self.description,
             "entry_date": self.entry_date,
-            "user_id": username,
+            "user_id": None if user is None else user.id,  # l'uid
+            "user_name": None if user is None else user.user_name,  # le login
             "est_just": self.est_just,
             "external_data": self.external_data,
         }
diff --git a/app/models/departements.py b/app/models/departements.py
index 6f3f775989708296827824e1a2a54fa053508dda..d4005d24d833acd41d792b5538fbfbd76de0923a 100644
--- a/app/models/departements.py
+++ b/app/models/departements.py
@@ -80,8 +80,6 @@ class Departement(db.Model):
 
 def create_dept(acronym: str, visible=True) -> Departement:
     "Create new departement"
-    from app.models import ScoPreference
-
     if Departement.invalid_dept_acronym(acronym):
         raise ScoValueError("acronyme departement invalide")
     existing = Departement.query.filter_by(acronym=acronym).count()
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index a451526f7de1182b205e228453da234554fcbca0..6dc036782de2e05c043cfb5e865e878f9691b736 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -86,6 +86,50 @@ class Identite(db.Model):
             f"<Etud {self.id}/{self.departement.acronym} {self.nom!r} {self.prenom!r}>"
         )
 
+    def clone(self, not_copying=(), new_dept_id: int = None):
+        """Clone, not copying the given attrs
+        Clone aussi les adresses.
+        Si new_dept_id est None, le nouvel étudiant n'a pas de département.
+        Attention: la copie n'a pas d'id avant le prochain flush ou commit.
+        """
+        if new_dept_id == self.dept_id:
+            raise ScoValueError(
+                "clonage étudiant: le département destination est identique à celui de départ"
+            )
+        # Vérifie les contraintes d'unicité
+        # ("dept_id", "code_nip") et ("dept_id", "code_ine")
+        if (
+            self.code_nip is not None
+            and Identite.query.filter_by(
+                dept_id=new_dept_id, code_nip=self.code_nip
+            ).count()
+            > 0
+        ) or (
+            self.code_ine is not None
+            and Identite.query.filter_by(
+                dept_id=new_dept_id, code_ine=self.code_ine
+            ).count()
+            > 0
+        ):
+            raise ScoValueError(
+                """clonage étudiant: un étudiant de même code existe déjà 
+                dans le département destination"""
+            )
+        d = dict(self.__dict__)
+        d.pop("id", None)  # get rid of id
+        d.pop("_sa_instance_state", None)  # get rid of SQLAlchemy special attr
+        d.pop("departement", None)  # relationship
+        d["dept_id"] = new_dept_id
+        for k in not_copying:
+            d.pop(k, None)
+        copy = self.__class__(**d)
+        copy.adresses = [adr.clone() for adr in self.adresses]
+        db.session.add(copy)
+        log(
+            f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}"
+        )
+        return copy
+
     def html_link_fiche(self) -> str:
         "lien vers la fiche"
         return f"""<a class="stdlink" href="{
@@ -660,6 +704,19 @@ class Adresse(db.Model):
     )
     description = db.Column(db.Text)
 
+    def clone(self, not_copying=()):
+        """Clone, not copying the given attrs
+        Attention: la copie n'a pas d'id avant le prochain flush ou commit.
+        """
+        d = dict(self.__dict__)
+        d.pop("id", None)  # get rid of id
+        d.pop("_sa_instance_state", None)  # get rid of SQLAlchemy special attr
+        for k in not_copying:
+            d.pop(k, None)
+        copy = self.__class__(**d)
+        db.session.add(copy)
+        return copy
+
     def to_dict(self, convert_nulls_to_str=False):
         """Représentation dictionnaire,"""
         e = dict(self.__dict__)
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 3f8f61f13099993cddbd25686a3905ba44beaa49..a5b20b1248ee97d38347b72af354936456a992d3 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -177,11 +177,15 @@ class FormSemestre(db.Model):
         """
 
     @classmethod
-    def get_formsemestre(cls, formsemestre_id: int) -> "FormSemestre":
-        """ "FormSemestre ou 404, cherche uniquement dans le département courant"""
+    def get_formsemestre(
+        cls, formsemestre_id: int, dept_id: int = None
+    ) -> "FormSemestre":
+        """ "FormSemestre ou 404, cherche uniquement dans le département spécifié ou le courant"""
         if g.scodoc_dept:
+            dept_id = dept_id if dept_id is not None else g.scodoc_dept_id
+        if dept_id is not None:
             return cls.query.filter_by(
-                id=formsemestre_id, dept_id=g.scodoc_dept_id
+                id=formsemestre_id, dept_id=dept_id
             ).first_or_404()
         return cls.query.filter_by(id=formsemestre_id).first_or_404()
 
diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py
index b673a3eb2df8416604ae71f2902d754ceaca5295..b0334ae78c9b2377f68dc167361285ce0ebe5577 100644
--- a/app/scodoc/TrivialFormulator.py
+++ b/app/scodoc/TrivialFormulator.py
@@ -237,7 +237,7 @@ class TF(object):
 
     def setdefaultvalues(self):
         "set default values and convert numbers to strings"
-        for (field, descr) in self.formdescription:
+        for field, descr in self.formdescription:
             # special case for boolcheckbox
             if descr.get("input_type", None) == "boolcheckbox" and self.submitted():
                 if field not in self.values:
@@ -278,7 +278,7 @@ class TF(object):
         "check values. Store .result and returns msg"
         ok = 1
         msg = []
-        for (field, descr) in self.formdescription:
+        for field, descr in self.formdescription:
             val = self.values[field]
             # do not check "unckecked" items
             if descr.get("withcheckbox", False):
@@ -287,7 +287,7 @@ class TF(object):
             # null values
             allow_null = descr.get("allow_null", True)
             if not allow_null:
-                if val == "" or val == None:
+                if val is None or (isinstance(val, str) and not val.strip()):
                     msg.append(
                         "Le champ '%s' doit être renseigné" % descr.get("title", field)
                     )
@@ -871,7 +871,7 @@ var {field}_as = new bsn.AutoSuggest('{field}', {field}_opts);
     def _ReadOnlyVersion(self, formdescription):
         "Generate HTML for read-only view of the form"
         R = ['<table class="tf-ro">']
-        for (field, descr) in formdescription:
+        for field, descr in formdescription:
             R.append(self._ReadOnlyElement(field, descr))
         R.append("</table>")
         return R
diff --git a/app/scodoc/sco_archives_justificatifs.py b/app/scodoc/sco_archives_justificatifs.py
index c6dbd4472b057a3daba5e4583e79d0c3a628ddef..125569d0190ea131f59757c536043a18927d3b32 100644
--- a/app/scodoc/sco_archives_justificatifs.py
+++ b/app/scodoc/sco_archives_justificatifs.py
@@ -19,26 +19,35 @@ class Trace:
     """gestionnaire de la trace des fichiers justificatifs"""
 
     def __init__(self, path: str) -> None:
-        log(f"init Trace {path}")
         self.path: str = path + "/_trace.csv"
         self.content: dict[str, list[datetime, datetime, str]] = {}
         self.import_from_file()
 
     def import_from_file(self):
         """import trace from file"""
-        if os.path.isfile(self.path):
-            with open(self.path, "r", encoding="utf-8") as file:
+
+        def import_from_csv(path):
+            with open(path, "r", encoding="utf-8") as file:
                 for line in file.readlines():
                     csv = line.split(",")
                     if len(csv) < 4:
                         continue
                     fname: str = csv[0]
+                    if fname not in os.listdir(self.path.replace("/_trace.csv", "")):
+                        continue
                     entry_date: datetime = is_iso_formated(csv[1], True)
                     delete_date: datetime = is_iso_formated(csv[2], True)
                     user_id = csv[3]
-
                     self.content[fname] = [entry_date, delete_date, user_id]
 
+        if os.path.isfile(self.path):
+            import_from_csv(self.path)
+        else:
+            parent_dir: str = self.path[: self.path.rfind("/", 0, self.path.rfind("/"))]
+            if os.path.isfile(parent_dir + "/_trace.csv"):
+                import_from_csv(parent_dir + "/_trace.csv")
+                self.save_trace()
+
     def set_trace(self, *fnames: str, mode: str = "entry", current_user: str = None):
         """Ajoute une trace du fichier donné
         mode : entry / delete
@@ -57,9 +66,11 @@ class Trace:
             )
         self.save_trace()
 
-    def save_trace(self):
+    def save_trace(self, new_path: str = None):
         """Enregistre la trace dans le fichier _trace.csv"""
         lines: list[str] = []
+        if new_path is not None:
+            self.path = new_path
         for fname, traced in self.content.items():
             date_fin: datetime or None = traced[1].isoformat() if traced[1] else "None"
             if traced[0] is not None:
@@ -126,7 +137,6 @@ class JustificatifArchiver(BaseArchiver):
             )
 
         fname: str = self.store(archive_id, filename, data, dept_id=etud.dept_id)
-        log(f"obj_dir {self.get_obj_dir(etud.id, dept_id=etud.dept_id)} | {archive_id}")
         trace = Trace(archive_id)
         trace.set_trace(fname, mode="entry")
         if user_id is not None:
diff --git a/app/scodoc/sco_bulletins_pdf.py b/app/scodoc/sco_bulletins_pdf.py
index a3786c828cdf7b816e7c78a34ac25bd43d4e5866..61a44cf0061287805691ca0eceb0f33f55c6f2cb 100644
--- a/app/scodoc/sco_bulletins_pdf.py
+++ b/app/scodoc/sco_bulletins_pdf.py
@@ -136,7 +136,7 @@ class WrapDict(object):
         try:
             value = self.dict[key]
         except KeyError:
-            raise
+            return f"XXX {key} invalide XXX"
         if value is None:
             return self.none_value
         return value
diff --git a/app/scodoc/sco_evaluation_db.py b/app/scodoc/sco_evaluation_db.py
index 38110c6c8da3fc9817c215176377935a4a622d84..2d58b5773487f4e93db64d23a812dd211dd7b153 100644
--- a/app/scodoc/sco_evaluation_db.py
+++ b/app/scodoc/sco_evaluation_db.py
@@ -31,6 +31,7 @@
 import flask
 from flask import url_for, g
 from flask_login import current_user
+import sqlalchemy as sa
 
 from app import db, log
 
@@ -72,7 +73,7 @@ _evaluationEditor = ndb.EditableTable(
 )
 
 
-def get_evaluation_dict(args: dict) -> list[dict]:
+def get_evaluations_dict(args: dict) -> list[dict]:
     """Liste evaluations, triées numero (or most recent date first).
     Fonction de transition pour ancien code ScoDoc7.
 
@@ -83,7 +84,12 @@ def get_evaluation_dict(args: dict) -> list[dict]:
     'descrheure' : ' de 15h00 à 16h30'
     """
     # calcule duree (chaine de car.) de chaque evaluation et ajoute jour_iso, matin, apresmidi
-    return [e.to_dict() for e in Evaluation.query.filter_by(**args)]
+    return [
+        e.to_dict()
+        for e in Evaluation.query.filter_by(**args).order_by(
+            sa.desc(Evaluation.numero), sa.desc(Evaluation.date_debut)
+        )
+    ]
 
 
 def do_evaluation_list_in_formsemestre(formsemestre_id):
@@ -91,7 +97,7 @@ def do_evaluation_list_in_formsemestre(formsemestre_id):
     mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id)
     evals = []
     for modimpl in mods:
-        evals += get_evaluation_dict(args={"moduleimpl_id": modimpl["moduleimpl_id"]})
+        evals += get_evaluations_dict(args={"moduleimpl_id": modimpl["moduleimpl_id"]})
     return evals
 
 
@@ -161,7 +167,6 @@ def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1):
     (published)
     """
     evaluation: Evaluation = Evaluation.query.get_or_404(evaluation_id)
-    moduleimpl_id = evaluation.moduleimpl_id
     redirect = int(redirect)
     # access: can change eval ?
     if not evaluation.moduleimpl.can_edit_evaluation(current_user):
@@ -171,12 +176,12 @@ def moduleimpl_evaluation_move(evaluation_id: int, after=0, redirect=1):
     Evaluation.moduleimpl_evaluation_renumber(
         evaluation.moduleimpl, only_if_unumbered=True
     )
-    e = get_evaluation_dict(args={"evaluation_id": evaluation_id})[0]
+    e = get_evaluations_dict(args={"evaluation_id": evaluation_id})[0]
 
     after = int(after)  # 0: deplace avant, 1 deplace apres
     if after not in (0, 1):
         raise ValueError('invalid value for "after"')
-    mod_evals = get_evaluation_dict({"moduleimpl_id": e["moduleimpl_id"]})
+    mod_evals = get_evaluations_dict({"moduleimpl_id": e["moduleimpl_id"]})
     if len(mod_evals) > 1:
         idx = [p["evaluation_id"] for p in mod_evals].index(evaluation_id)
         neigh = None  # object to swap with
diff --git a/app/scodoc/sco_evaluations.py b/app/scodoc/sco_evaluations.py
index 7c5ffd4743ac37057f93043e151bf5969905890a..f6beeceb593328f826de5007a1e9bc984a7cd05e 100644
--- a/app/scodoc/sco_evaluations.py
+++ b/app/scodoc/sco_evaluations.py
@@ -133,7 +133,7 @@ def do_evaluation_etat(
     )  # { etudid : note }
 
     # ---- Liste des groupes complets et incomplets
-    E = sco_evaluation_db.get_evaluation_dict(args={"evaluation_id": evaluation_id})[0]
+    E = sco_evaluation_db.get_evaluations_dict(args={"evaluation_id": evaluation_id})[0]
     M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
     Mod = sco_edit_module.module_list(args={"module_id": M["module_id"]})[0]
     is_malus = Mod["module_type"] == ModuleType.MALUS  # True si module de malus
diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
index 7b1ceb41a95af0ef41e311fe7b405514a42ee9b6..bd0f6f42aa37c790cbd49ff3e6339ef98ec0c092 100644
--- a/app/scodoc/sco_formsemestre_edit.py
+++ b/app/scodoc/sco_formsemestre_edit.py
@@ -1445,7 +1445,7 @@ def do_formsemestre_delete(formsemestre_id):
     mods = sco_moduleimpl.moduleimpl_list(formsemestre_id=formsemestre_id)
     for mod in mods:
         # evaluations
-        evals = sco_evaluation_db.get_evaluation_dict(
+        evals = sco_evaluation_db.get_evaluations_dict(
             args={"moduleimpl_id": mod["moduleimpl_id"]}
         )
         for e in evals:
diff --git a/app/scodoc/sco_formsemestre_inscriptions.py b/app/scodoc/sco_formsemestre_inscriptions.py
index 282b51957bf79072f0f857c57c1d25bbbe1cd6d2..7837c299f2a9de3250c174034bb202deeaf83509 100644
--- a/app/scodoc/sco_formsemestre_inscriptions.py
+++ b/app/scodoc/sco_formsemestre_inscriptions.py
@@ -275,14 +275,16 @@ def do_formsemestre_inscription_with_modules(
     etat=scu.INSCRIT,
     etape=None,
     method="inscription_with_modules",
+    dept_id: int = None,
 ):
     """Inscrit cet etudiant à ce semestre et TOUS ses modules STANDARDS
     (donc sauf le sport)
+    Si dept_id est spécifié, utilise ce département au lieu du courant.
     """
     group_ids = group_ids or []
     if isinstance(group_ids, int):
         group_ids = [group_ids]
-    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id, dept_id=dept_id)
     # inscription au semestre
     args = {"formsemestre_id": formsemestre_id, "etudid": etudid}
     if etat is not None:
diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py
index 1a0163b4ba3f0ef314275b76a1885c8f8cdf85d7..520af7e8f4e06eca4629e793ddd470ef0504783c 100755
--- a/app/scodoc/sco_formsemestre_status.py
+++ b/app/scodoc/sco_formsemestre_status.py
@@ -490,7 +490,7 @@ def retreive_formsemestre_from_request() -> int:
         modimpl = modimpl[0]
         formsemestre_id = modimpl["formsemestre_id"]
     elif "evaluation_id" in args:
-        E = sco_evaluation_db.get_evaluation_dict(
+        E = sco_evaluation_db.get_evaluations_dict(
             {"evaluation_id": args["evaluation_id"]}
         )
         if not E:
@@ -884,7 +884,7 @@ def _make_listes_sem(formsemestre: FormSemestre) -> str:
                                 jour = datetime.date.today().isoformat(),
                                 group_ids=group.id,
                         )}">
-                        <button>Visualiser l'assiduité</button></a>
+                        <button>Visualiser</button></a>
                         </div>
                         <div>
                         <a class="btn" href="{
diff --git a/app/scodoc/sco_liste_notes.py b/app/scodoc/sco_liste_notes.py
index 6ac88dc51c0c28afad7008dbf8ade13b1d925f63..2f06599389c8d8064dcc6be63228a1745a8866d6 100644
--- a/app/scodoc/sco_liste_notes.py
+++ b/app/scodoc/sco_liste_notes.py
@@ -69,10 +69,10 @@ def do_evaluation_listenotes(
     mode = None
     if moduleimpl_id:
         mode = "module"
-        evals = sco_evaluation_db.get_evaluation_dict({"moduleimpl_id": moduleimpl_id})
+        evals = sco_evaluation_db.get_evaluations_dict({"moduleimpl_id": moduleimpl_id})
     elif evaluation_id:
         mode = "eval"
-        evals = sco_evaluation_db.get_evaluation_dict({"evaluation_id": evaluation_id})
+        evals = sco_evaluation_db.get_evaluations_dict({"evaluation_id": evaluation_id})
     else:
         raise ValueError("missing argument: evaluation or module")
     if not evals:
diff --git a/app/scodoc/sco_page_etud.py b/app/scodoc/sco_page_etud.py
index 554ce78046ed1e01f56474ecec0c554984f011f0..4b62d3817689a6470af4438a45b6ced8c46d4b9f 100644
--- a/app/scodoc/sco_page_etud.py
+++ b/app/scodoc/sco_page_etud.py
@@ -642,6 +642,12 @@ def menus_etud(etudid):
             "args": {"etudid": etud["etudid"]},
             "enabled": authuser.has_permission(Permission.ScoEtudInscrit),
         },
+        {
+            "title": "Copier dans un autre département...",
+            "endpoint": "scolar.etud_copy_in_other_dept",
+            "args": {"etudid": etud["etudid"]},
+            "enabled": authuser.has_permission(Permission.ScoEtudInscrit),
+        },
         {
             "title": "Supprimer cet étudiant...",
             "endpoint": "scolar.etudident_delete",
@@ -656,7 +662,9 @@ def menus_etud(etudid):
         },
     ]
 
-    return htmlutils.make_menu("Étudiant", menuEtud, alone=True)
+    return htmlutils.make_menu(
+        "Étudiant", menuEtud, alone=True, css_class="menu-etudiant"
+    )
 
 
 def etud_info_html(etudid, with_photo="1", debug=False):
diff --git a/app/scodoc/sco_placement.py b/app/scodoc/sco_placement.py
index d0cc4ca1b6729d7ed42d5a92a7f021aaae917efd..daaa2f1866e1fd2a8d973a5bb59cb6efaa4e5866 100644
--- a/app/scodoc/sco_placement.py
+++ b/app/scodoc/sco_placement.py
@@ -138,7 +138,7 @@ class PlacementForm(FlaskForm):
 
     def set_evaluation_infos(self, evaluation_id):
         """Initialise les données du formulaire avec les données de l'évaluation."""
-        eval_data = sco_evaluation_db.get_evaluation_dict(
+        eval_data = sco_evaluation_db.get_evaluations_dict(
             {"evaluation_id": evaluation_id}
         )
         if not eval_data:
@@ -239,7 +239,7 @@ class PlacementRunner:
             self.groups_ids = [
                 gid if gid != TOUS else form.tous_id for gid in form["groups"].data
             ]
-        self.eval_data = sco_evaluation_db.get_evaluation_dict(
+        self.eval_data = sco_evaluation_db.get_evaluations_dict(
             {"evaluation_id": self.evaluation_id}
         )[0]
         self.groups = sco_groups.listgroups(self.groups_ids)
diff --git a/app/scodoc/sco_report.py b/app/scodoc/sco_report.py
index a558bce129cd7d6193d3ad8a0d12a2a2d9988f77..203865eb735694ae08f736d2c9ce1823ae52b776 100644
--- a/app/scodoc/sco_report.py
+++ b/app/scodoc/sco_report.py
@@ -524,11 +524,11 @@ def table_suivi_cohorte(
     # 3-- Regroupe les semestres par date de debut
     P = []  #  liste de periodsem
 
-    class periodsem(object):
+    class PeriodSem:
         pass
 
     # semestre de depart:
-    porigin = periodsem()
+    porigin = PeriodSem()
     d, m, y = [int(x) for x in sem["date_debut"].split("/")]
     porigin.datedebut = datetime.datetime(y, m, d)
     porigin.sems = [sem]
@@ -543,7 +543,7 @@ def table_suivi_cohorte(
                 merged = True
                 break
         if not merged:
-            p = periodsem()
+            p = PeriodSem()
             p.datedebut = s["date_debut_dt"]
             p.sems = [s]
             P.append(p)
@@ -743,7 +743,7 @@ def formsemestre_suivi_cohorte(
     civilite=None,
     statut="",
     only_primo=False,
-):
+) -> str:
     """Affiche suivi cohortes par numero de semestre"""
     annee_bac = str(annee_bac or "")
     annee_admission = str(annee_admission or "")
@@ -794,14 +794,6 @@ def formsemestre_suivi_cohorte(
             '<p><a href="%s&percent=1">Afficher les résultats en pourcentages</a></p>'
             % burl
         )
-    help = (
-        pplink
-        + """    
-    <p class="help">Nombre d'étudiants dans chaque semestre. Les dates indiquées sont les dates approximatives de <b>début</b> des semestres (les semestres commençant à des dates proches sont groupés). Le nombre de diplômés est celui à la <b>fin</b> du semestre correspondant. Lorsqu'il y a moins de %s étudiants dans une case, vous pouvez afficher leurs noms en passant le curseur sur le chiffre.</p>
-<p class="help">Les menus permettent de n'étudier que certaines catégories d'étudiants (titulaires d'un type de bac, garçons ou filles). La case "restreindre aux primo-entrants" permet de ne considérer que les étudiants qui n'ont jamais été inscrits dans ScoDoc avant le semestre considéré.</p>
-    """
-        % (MAX_ETUD_IN_DESCR,)
-    )
 
     H = [
         html_sco_header.sco_header(page_title=tab.page_title),
@@ -824,7 +816,20 @@ def formsemestre_suivi_cohorte(
             percent=percent,
         ),
         t,
-        help,
+        f"""{pplink}
+    <p class="help">Nombre d'étudiants dans chaque semestre.
+    Les dates indiquées sont les dates approximatives de <b>début</b> des semestres
+    (les semestres commençant à des dates proches sont groupés). Le nombre de diplômés
+    est celui à la <b>fin</b> du semestre correspondant.
+    Lorsqu'il y a moins de {MAX_ETUD_IN_DESCR} étudiants dans une case, vous pouvez
+    afficher leurs noms en passant le curseur sur le chiffre.
+    </p>
+    <p class="help">Les menus permettent de n'étudier que certaines catégories
+    d'étudiants (titulaires d'un type de bac, garçons ou filles).
+    La case "restreindre aux primo-entrants" permet de ne considérer que les étudiants
+    qui n'ont jamais été inscrits dans ScoDoc avant le semestre considéré.
+    </p>
+    """,
         expl,
         html_sco_header.sco_footer(),
     ]
@@ -870,35 +875,33 @@ def _gen_form_selectetuds(
     else:
         selected = 'selected="selected"'
     F = [
-        """<form id="f" method="get" action="%s">
+        f"""<form id="f" method="get" action="{request.base_url}">
     <p>Bac: <select name="bac" onchange="javascript: submit(this);">
-    <option value="" %s>tous</option>
+    <option value="" {selected}>tous</option>
     """
-        % (request.base_url, selected)
     ]
     for b in bacs:
         if bac == b:
             selected = 'selected="selected"'
         else:
             selected = ""
-        F.append('<option value="%s" %s>%s</option>' % (b, selected, b))
+        F.append(f'<option value="{b}" {selected}>{b}</option>')
     F.append("</select>")
     if bacspecialite:
         selected = ""
     else:
         selected = 'selected="selected"'
     F.append(
-        """&nbsp; Bac/Specialité: <select name="bacspecialite" onchange="javascript: submit(this);">
-    <option value="" %s>tous</option>
+        f"""&nbsp; Bac/Specialité: <select name="bacspecialite" onchange="javascript: submit(this);">
+    <option value="" {selected}>tous</option>
     """
-        % selected
     )
     for b in bacspecialites:
         if bacspecialite == b:
             selected = 'selected="selected"'
         else:
             selected = ""
-        F.append('<option value="%s" %s>%s</option>' % (b, selected, b))
+        F.append(f'<option value="{b}" {selected}>{b}</option>')
     F.append("</select>")
     #
     F.append(
@@ -910,46 +913,44 @@ def _gen_form_selectetuds(
     )
     #
     F.append(
-        """&nbsp; Genre: <select name="civilite" onchange="javascript: submit(this);">
-    <option value="" %s>tous</option>
+        f"""&nbsp; Genre: <select name="civilite" onchange="javascript: submit(this);">
+    <option value="" {selected}>tous</option>
     """
-        % selected
     )
     for b in civilites:
         if civilite == b:
             selected = 'selected="selected"'
         else:
             selected = ""
-        F.append('<option value="%s" %s>%s</option>' % (b, selected, b))
+        F.append(f'<option value="{b}" {selected}>{b}</option>')
     F.append("</select>")
 
     F.append(
-        """&nbsp; Statut: <select name="statut" onchange="javascript: submit(this);">
-    <option value="" %s>tous</option>
+        f"""&nbsp; Statut: <select name="statut" onchange="javascript: submit(this);">
+    <option value="" {selected}>tous</option>
     """
-        % selected
     )
     for b in statuts:
         if statut == b:
             selected = 'selected="selected"'
         else:
             selected = ""
-        F.append('<option value="%s" %s>%s</option>' % (b, selected, b))
+        F.append(f'<option value="{b}" {selected}>{b}</option>')
     F.append("</select>")
 
-    if only_primo:
-        checked = 'checked="1"'
-    else:
-        checked = ""
     F.append(
-        '<br><input type="checkbox" name="only_primo" onchange="javascript: submit(this);" %s/>Restreindre aux primo-entrants'
-        % checked
+        f"""<br>
+        <input type="checkbox" name="only_primo"
+            onchange="javascript: submit(this);"
+            {'checked="1"' if only_primo else ""}/>Restreindre aux primo-entrants
+        <input type="hidden" name="formsemestre_id" value="{formsemestre_id}"/>
+        <input type="hidden" name="percent" value="{percent}"/>
+
+        </p>
+        </form>
+        """
     )
-    F.append(
-        '<input type="hidden" name="formsemestre_id" value="%s"/>' % formsemestre_id
-    )
-    F.append('<input type="hidden" name="percent" value="%s"/>' % percent)
-    F.append("</p></form>")
+
     return "\n".join(F)
 
 
@@ -964,7 +965,7 @@ def _gen_select_annee(field, values, value) -> str:
     return menu_html + "</select>"
 
 
-def _descr_etud_set(etudids):
+def _descr_etud_set(etudids) -> str:
     "textual html description of a set of etudids"
     etuds = []
     for etudid in etudids:
@@ -980,15 +981,22 @@ def _count_dem_reo(formsemestre_id, etudids):
     "count nb of demissions and reorientation in this etud set"
     formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
-
+    validations_annuelles = nt.get_validations_annee() if nt.is_apc else {}
     dems = set()
     reos = set()
     for etudid in etudids:
         if nt.get_etud_etat(etudid) == "D":
             dems.add(etudid)
-        dec = nt.get_etud_decision_sem(etudid)
-        if dec and dec["code"] in codes_cursus.CODES_SEM_REO:
-            reos.add(etudid)
+        if nt.is_apc:
+            # BUT: utilise les validations annuelles
+            validation = validations_annuelles.get(etudid)
+            if validation and validation.code in codes_cursus.CODES_SEM_REO:
+                reos.add(etudid)
+        else:
+            # Autres formations: validations de semestres
+            dec = nt.get_etud_decision_sem(etudid)
+            if dec and dec["code"] in codes_cursus.CODES_SEM_REO:
+                reos.add(etudid)
     return dems, reos
 
 
diff --git a/app/scodoc/sco_undo_notes.py b/app/scodoc/sco_undo_notes.py
index 61b84c0c4a7b048fb71bf7cb7b58ef1cd3a85527..dd1895048529087c458a4bd9daadd45d3f57cfa6 100644
--- a/app/scodoc/sco_undo_notes.py
+++ b/app/scodoc/sco_undo_notes.py
@@ -149,7 +149,7 @@ def list_operations(evaluation_id):
 
 def evaluation_list_operations(evaluation_id):
     """Page listing operations on evaluation"""
-    E = sco_evaluation_db.get_evaluation_dict({"evaluation_id": evaluation_id})[0]
+    E = sco_evaluation_db.get_evaluations_dict({"evaluation_id": evaluation_id})[0]
     M = sco_moduleimpl.moduleimpl_list(moduleimpl_id=E["moduleimpl_id"])[0]
 
     Ops = list_operations(evaluation_id)
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index baf81050392de74359ee4cde9f0f3ae32be530e3..500b41094ce2cffdb08de279985eb512508f772e 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -10,12 +10,12 @@
 
 html,
 body {
-  margin: 0;
-  padding: 0;
-  width: 100%;
   background-color: var(--sco-color-background);
   font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
   font-size: 12pt;
+  margin: 0;
+  padding: 0;
+  width: 100%;
 }
 
 @media print {
@@ -24,6 +24,10 @@ body {
   }
 }
 
+div.container {
+  margin-bottom: 24px;
+}
+
 h1,
 h2,
 h3 {
@@ -1672,6 +1676,10 @@ formsemestre_page_title .lock img {
   font-family: Arial, Helvetica, sans-serif;
 }
 
+.menu-etudiant>li {
+  width: 200px !important;
+}
+
 span.inscr_addremove_menu {
   width: 150px;
 }
diff --git a/app/templates/assiduites/pages/ajout_justificatif.j2 b/app/templates/assiduites/pages/ajout_justificatif.j2
index 28c4b6159abcb06e56488559a4d6677f26a915f9..c4885b3f8e61549b63746b0a913aed6789e0b75e 100644
--- a/app/templates/assiduites/pages/ajout_justificatif.j2
+++ b/app/templates/assiduites/pages/ajout_justificatif.j2
@@ -219,20 +219,27 @@
     }
 
     function dayOnly() {
+        const { deb, fin } = getDates();
 
         if (document.getElementById('justi_journee').checked) {
             document.getElementById("justi_date_debut").type = "date"
+            document.getElementById("justi_date_debut").value = deb.slice(0, deb.indexOf('T'))
+
             document.getElementById("justi_date_fin").type = "date"
+            document.getElementById("justi_date_fin").value = fin.slice(0, fin.indexOf('T'))
         } else {
             document.getElementById("justi_date_debut").type = "datetime-local"
+            document.getElementById("justi_date_debut").value = `${deb}T${assi_morning}`
+
             document.getElementById("justi_date_fin").type = "datetime-local"
+            document.getElementById("justi_date_fin").value = `${fin}T${assi_evening}`
         }
     }
 
     function getDates() {
         if (document.querySelector('.page #justi_journee').checked) {
             const date_str_deb = document.querySelector(".page #justi_date_debut").value
-            const date_str_fin = document.querySelector(".page #justi_date_debut").value
+            const date_str_fin = document.querySelector(".page #justi_date_fin").value
 
 
 
diff --git a/app/templates/scolar/etud_copy_in_other_dept.j2 b/app/templates/scolar/etud_copy_in_other_dept.j2
new file mode 100644
index 0000000000000000000000000000000000000000..55b4f77ffdce21495d0d57346f19d9b35ea2ab90
--- /dev/null
+++ b/app/templates/scolar/etud_copy_in_other_dept.j2
@@ -0,0 +1,99 @@
+{# -*- mode: jinja-html -*- #}
+{% extends 'base.j2' %}
+
+{% block styles %}
+{{super()}}
+<style>
+.dept-name {
+    font-size: 120%;
+    font-weight: bold;
+}
+.dept {
+    background-color: bisque;
+    border-radius: 12px;
+    padding: 8px;
+    margin-bottom: 12px;
+}
+.dept label {
+    font-weight: normal;
+}
+button[name="action"] {
+    margin-right: 32px;
+}
+#submit-button:disabled {
+  background-color: #CCCCCC;
+  color: #888888;
+  cursor: not-allowed;
+  border: 1px solid #AAAAAA;
+}
+</style>
+{% endblock %}
+
+{% block app_content %}
+
+<h2>Création d'une copie de {{ etud.html_link_fiche() | safe }}</h2>
+
+<div class="help"> 
+
+<p>Utiliser cette page lorsqu'un étudinat change de département. ScoDoc gère
+séparéement les étudiants des départements. Il faut donc dans ce cas
+exceptionnel créer une copie de l'étudiant et l'inscrire dans un semestre de son
+nouveau département. Seules les donénes sur l'identité de l'étudiant (état
+civil, adresse, ...) sont dupliquées. Dans le noveau département, les résultats
+obtenus dans le département d'origine ne seront pas visibles.
+</p>
+
+<p>Si des UEs ou compétences de l'ancien département doivent être validées dans
+le nouveau, il faudra utiliser ensuite une "validation d'UE antérieure".
+</p>
+
+<p>Attention: seuls les départements dans lesquels vous avez la permission
+d'inscrire des étudiants sont présentés ici. Il faudra peut-être solliciter
+l'administrateur de ce ScoDoc.
+</p> 
+
+<p>Dans chaque département autorisés, seuls les semestres non verrouillés sont
+montrés. Choisir le semestre destination et valider le formulaire.
+</p>
+
+<p>Ensuite, ne pas oublier d'inscrire l'étudiant à ses groupes, notamment son
+parcours si besoin.
+</p>
+
+</div>
+
+<form method="POST">
+    {% for dept in departements.values() %}
+        <div class="dept">
+            <div class="dept-name">Département {{ dept.acronym }}</div>
+            {% for sem in formsemestres_by_dept[dept.id]%}
+            <div>
+                <label>
+                    <input type="radio" class="formsemestre" name="formsemestre_id" value="{{ sem.id }}"> 
+                    {{ sem.html_link_status() | safe }}
+                </label>
+            </div>
+            {% endfor %}
+        </div>
+    {% endfor %}
+    <button type="submit" name="action" value="submit" disabled id="submit-button">Créer une copie de l'étudiant et l'inscrire au semestre choisi</button>
+    <button type="submit" name="action" value="cancel">Annuler</button>
+</form>
+
+<script>
+    const radioButtons = document.querySelectorAll('input.formsemestre');
+    const submitButton = document.getElementById('submit-button');
+
+    radioButtons.forEach(radioButton => {
+        radioButton.addEventListener('change', () => {
+            const isAnyRadioButtonChecked = [...radioButtons].some(radioButton => radioButton.checked);
+            if (isAnyRadioButtonChecked) {
+                submitButton.removeAttribute('disabled');
+            } else {
+                submitButton.setAttribute('disabled', 'disabled');
+            }
+        });
+    });
+</script>
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/views/assiduites.py b/app/views/assiduites.py
index d8dda7168033fbbebc8ec95e5279ce9645e63cd8..f9a579d5a9d5e97c84500f301b06a01c33553ef0 100644
--- a/app/views/assiduites.py
+++ b/app/views/assiduites.py
@@ -517,7 +517,7 @@ def ajout_justificatif_etud():
                 dept_id=g.scodoc_dept_id,
             ),
             assi_morning=ScoDocSiteConfig.get("assi_morning_time", "08:00"),
-            assi_evening=ScoDocSiteConfig.get("assi_evening_time", "18:00"),
+            assi_evening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00"),
         ),
     ).build()
 
@@ -1129,7 +1129,7 @@ def signal_assiduites_diff():
             defdem=_get_etuds_dem_def(formsemestre),
             timeMorning=ScoDocSiteConfig.get("assi_morning_time", "08:00:00"),
             timeNoon=ScoDocSiteConfig.get("assi_lunch_time", "13:00:00"),
-            timeEvening=ScoDocSiteConfig.get("assi_evening_time", "18:00:00"),
+            timeEvening=ScoDocSiteConfig.get("assi_afternoon_time", "18:00:00"),
             defaultDates=_get_days_between_dates(date_deb, date_fin),
             nonworkdays=_non_work_days(),
         ),
diff --git a/app/views/notes.py b/app/views/notes.py
index 9f198ba3bcb88543180e945cbb2fbb5719822de7..af1d011aff7dc85f7bccc30f5469e9e9f91d8e5d 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -407,14 +407,13 @@ def moduleimpl_evaluation_renumber(moduleimpl_id):
         )
     Evaluation.moduleimpl_evaluation_renumber(modimpl)
     # redirect to moduleimpl page:
-    if redirect:
-        return flask.redirect(
-            url_for(
-                "notes.moduleimpl_status",
-                scodoc_dept=g.scodoc_dept,
-                moduleimpl_id=moduleimpl_id,
-            )
+    return flask.redirect(
+        url_for(
+            "notes.moduleimpl_status",
+            scodoc_dept=g.scodoc_dept,
+            moduleimpl_id=moduleimpl_id,
         )
+    )
 
 
 sco_publish(
diff --git a/app/views/scolar.py b/app/views/scolar.py
index 7b13e2dd4f386a2703a6446474d24cbad41c8954..7d1cf2e258dfff2b39ffa742f3fb57bb9d921b30 100644
--- a/app/views/scolar.py
+++ b/app/views/scolar.py
@@ -31,11 +31,12 @@ issu de ScoDoc7 / ZScolar.py
 Emmanuel Viennet, 2021
 """
 import datetime
-import requests
 import time
 
+import requests
+
 import flask
-from flask import url_for, flash, render_template, make_response
+from flask import abort, flash, make_response, render_template, url_for
 from flask import g, request
 from flask_json import as_json
 from flask_login import current_user
@@ -43,6 +44,7 @@ from flask_wtf import FlaskForm
 from flask_wtf.file import FileField, FileAllowed
 from wtforms import SubmitField
 
+import app
 from app import db
 from app import log
 from app.decorators import (
@@ -52,6 +54,7 @@ from app.decorators import (
     permission_required_compat_scodoc7,
 )
 from app.models import (
+    Departement,
     FormSemestre,
     Identite,
     Partition,
@@ -69,6 +72,7 @@ from app.scodoc.scolog import logdb
 from app.scodoc.sco_permissions import Permission
 from app.scodoc.sco_exceptions import (
     AccessDenied,
+    ScoPermissionDenied,
     ScoValueError,
 )
 
@@ -1770,6 +1774,77 @@ def _etudident_create_or_edit_form(edit):
         )
 
 
+@bp.route("/etud_copy_in_other_dept/<int:etudid>", methods=["GET", "POST"])
+@scodoc
+@permission_required(
+    Permission.ScoView
+)  # il faut aussi ScoEtudInscrit dans le nouveau dept
+def etud_copy_in_other_dept(etudid: int):
+    """Crée une copie de l'étudiant (avec ses adresses et codes) dans un autre département
+    et l'inscrit à un formsemestre
+    """
+    etud = Identite.get_etud(etudid)
+    if request.method == "POST":
+        action = request.form.get("action")
+        if action == "cancel":
+            return flask.redirect(
+                url_for("scolar.ficheEtud", scodoc_dept=g.scodoc_dept, etudid=etud.id)
+            )
+        try:
+            formsemestre_id = int(request.form.get("formsemestre_id"))
+        except ValueError:
+            log("etud_copy_in_other_dept: invalid formsemestre_id")
+            abort(404, description="formsemestre_id invalide")
+        formsemestre: FormSemestre = FormSemestre.query.get_or_404(formsemestre_id)
+        if not current_user.has_permission(
+            Permission.ScoEtudInscrit, formsemestre.departement.acronym
+        ):
+            raise ScoPermissionDenied("non autorisé")
+        new_etud = etud.clone(new_dept_id=formsemestre.dept_id)
+        db.session.commit()
+        # Attention: change le département pour opérer dans le nouveau
+        # avec les anciennes fonctions ScoDoc7
+        orig_dept = g.scodoc_dept
+        try:
+            app.set_sco_dept(formsemestre.departement.acronym, open_cnx=False)
+            sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules(
+                formsemestre.id,
+                new_etud.id,
+                method="etud_copy_in_other_dept",
+                dept_id=formsemestre.dept_id,
+            )
+        finally:
+            app.set_sco_dept(orig_dept, open_cnx=False)
+        flash(f"Etudiant dupliqué et inscrit en {formsemestre.departement.acronym}")
+        # Attention, ce redirect change de département !
+        return flask.redirect(
+            url_for(
+                "scolar.ficheEtud",
+                scodoc_dept=formsemestre.departement.acronym,
+                etudid=new_etud.id,
+            )
+        )
+    departements = {
+        dept.id: dept
+        for dept in Departement.query.order_by(Departement.acronym)
+        if current_user.has_permission(Permission.ScoEtudInscrit, dept.acronym)
+        and dept.id != etud.dept_id
+    }
+    formsemestres_by_dept = {
+        dept.id: dept.formsemestres.filter_by(etat=True)
+        .filter(FormSemestre.modalite != "EXT")
+        .order_by(FormSemestre.date_debut, FormSemestre.semestre_id)
+        .all()
+        for dept in departements.values()
+    }
+    return render_template(
+        "scolar/etud_copy_in_other_dept.j2",
+        departements=departements,
+        etud=etud,
+        formsemestres_by_dept=formsemestres_by_dept,
+    )
+
+
 @bp.route("/etudident_delete", methods=["GET", "POST"])
 @scodoc
 @permission_required(Permission.ScoEtudInscrit)
diff --git a/sco_version.py b/sco_version.py
index 61dc9fa95b5133160b7cbd077437d80005b7217b..68196cdec1ea46b12ac45aba67582f79095ed9eb 100644
--- a/sco_version.py
+++ b/sco_version.py
@@ -1,7 +1,7 @@
 # -*- mode: python -*-
 # -*- coding: utf-8 -*-
 
-SCOVERSION = "9.6.35"
+SCOVERSION = "9.6.38"
 
 SCONAME = "ScoDoc"
 
diff --git a/tests/api/setup_test_api.py b/tests/api/setup_test_api.py
index 63df403b5a4ddc8896903a3bed895d9260fb77da..adcd2e0a36909746dd0564c3e701a4cea0cc05fc 100644
--- a/tests/api/setup_test_api.py
+++ b/tests/api/setup_test_api.py
@@ -129,7 +129,7 @@ def check_fields(data: dict, fields: dict = None):
     """
     assert set(data.keys()) == set(fields.keys())
     for key in data:
-        if key in ("moduleimpl_id", "desc", "user_id", "external_data"):
+        if key in ("moduleimpl_id", "desc", "external_data"):
             assert (
                 isinstance(data[key], fields[key]) or data[key] is None
             ), f"error [{key}:{type(data[key])}, {data[key]}, {fields[key]}]"
diff --git a/tests/api/test_api_assiduites.py b/tests/api/test_api_assiduites.py
index 1333047182538da2d3edb86f83fa19f6eec359a3..1a7fb653df0d4d7c47cc33b831985076a98f204a 100644
--- a/tests/api/test_api_assiduites.py
+++ b/tests/api/test_api_assiduites.py
@@ -6,6 +6,7 @@ Ecrit par HARTMANN Matthias
 """
 
 from random import randint
+from types import NoneType
 
 from tests.api.setup_test_api import (
     GET,
@@ -34,7 +35,8 @@ ASSIDUITES_FIELDS = {
     "etat": str,
     "desc": str,
     "entry_date": str,
-    "user_id": str,
+    "user_id": (int, NoneType),
+    "user_name": (str, NoneType),
     "est_just": bool,
     "external_data": dict,
 }