diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py
index 4d91bc4f5168053f9b249801018ea588f01e9d01..af73d6f2f5b2feb82fc6b745e9743cb3bacf55bd 100644
--- a/app/api/formsemestres.py
+++ b/app/api/formsemestres.py
@@ -823,7 +823,7 @@ def formsemestre_get_description(formsemestre_id: int):
 @as_json
 def formsemestre_edit_description(formsemestre_id: int):
     """Modifie description externe du formsemestre.
-    Les images peuvent êtres passées dans el json, encodées en base64.
+    Les images peuvent êtres passées dans le json, encodées en base64.
     formsemestre_id : l'id du formsemestre
 
     SAMPLES
diff --git a/app/formations/edit_ue.py b/app/formations/edit_ue.py
index 83a484aad0318e7a785e6eefe2974cb4f6e25c58..eb37a617254d9a96031d8fe4fb4b8ace8d6b739c 100644
--- a/app/formations/edit_ue.py
+++ b/app/formations/edit_ue.py
@@ -911,7 +911,7 @@ du programme" (menu "Semestre") si vous avez un semestre en cours);
 
         """
         )
-        if formsemestres:
+        if not formsemestres:
             H.append(
                 f"""
             <li><a class="stdlink" href="{
diff --git a/app/forms/formsemestre/edit_description.py b/app/forms/formsemestre/edit_description.py
index e0b49b2a03d2b1b28b3d654d89cb3298193a788e..d10cd8075bb92c89c59a49f34c23cd0d4191227f 100644
--- a/app/forms/formsemestre/edit_description.py
+++ b/app/forms/formsemestre/edit_description.py
@@ -14,9 +14,11 @@ from wtforms import (
     TextAreaField,
     SubmitField,
 )
-from wtforms.validators import AnyOf, Optional
+from flask_wtf.file import FileAllowed
+from wtforms.validators import AnyOf, Optional, DataRequired
 
 from app.forms import ScoDocForm
+from app.formsemestre.import_from_descr import describe_field
 from app.models import FORMSEMESTRE_DISPOSITIFS
 from app.scodoc import sco_utils as scu
 
@@ -46,44 +48,50 @@ class FormSemestreDescriptionForm(ScoDocForm):
     description = TextAreaField(
         "Description",
         validators=[Optional()],
-        description="""texte libre : informations
-    sur le contenu, les objectifs, les modalités d'évaluation, etc.""",
+        description=describe_field("descr_description"),
     )
     horaire = StringField(
-        "Horaire", validators=[Optional()], description="ex: les lundis 9h-12h"
+        "Horaire", validators=[Optional()], description=describe_field("descr_horaire")
     )
     date_debut_inscriptions = DateDMYField(
         "Date de début des inscriptions",
-        description="""date d'ouverture des inscriptions
-                (laisser vide pour autoriser tout le temps)""",
+        description=describe_field("descr_date_debut_inscriptions"),
         render_kw={
             "id": "date_debut_inscriptions",
         },
     )
     date_fin_inscriptions = DateDMYField(
         "Date de fin des inscriptions",
+        description=describe_field("descr_date_fin_inscriptions"),
         render_kw={
             "id": "date_fin_inscriptions",
         },
     )
     image = FileField(
-        "Image", validators=[Optional()], description="Image illustrant cette formation"
+        "Image", validators=[Optional()], description=describe_field("descr_image")
     )
     campus = StringField(
-        "Campus", validators=[Optional()], description="ex: Villetaneuse"
+        "Campus", validators=[Optional()], description=describe_field("descr_campus")
+    )
+    salle = StringField(
+        "Salle", validators=[Optional()], description=describe_field("descr_salle")
     )
-    salle = StringField("Salle", validators=[Optional()], description="ex: salle 123")
     dispositif = SelectField(
         "Dispositif",
         choices=FORMSEMESTRE_DISPOSITIFS.items(),
         coerce=int,
-        description="modalité de formation",
+        description=describe_field("descr_dispositif"),
         validators=[AnyOf(FORMSEMESTRE_DISPOSITIFS.keys())],
     )
+    dispositif_descr = TextAreaField(
+        "Description du dispositif",
+        validators=[Optional()],
+        description=describe_field("descr_dispositif_descr"),
+    )
     modalites_mcc = TextAreaField(
         "Modalités de contrôle des connaissances",
         validators=[Optional()],
-        description="texte libre",
+        description=describe_field("descr_modalites_mcc"),
     )
     photo_ens = FileField(
         "Photo de l'enseignant(e)",
@@ -94,13 +102,12 @@ class FormSemestreDescriptionForm(ScoDocForm):
         "Public visé", validators=[Optional()], description="ex: débutants"
     )
     prerequis = TextAreaField(
-        "Prérequis", validators=[Optional()], description="texte libre"
+        "Prérequis", validators=[Optional()], description="texte libre. HTML autorisé."
     )
     responsable = StringField(
         "Responsable",
         validators=[Optional()],
-        description="""nom de l'enseignant de la formation, ou personne
-            chargée de l'organisation du semestre.""",
+        description=describe_field("descr_responsable"),
     )
 
     wip = BooleanField(
@@ -110,3 +117,26 @@ class FormSemestreDescriptionForm(ScoDocForm):
 
     submit = SubmitField("Enregistrer")
     cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
+
+
+class FormSemestresImportFromDescrForm(ScoDocForm):
+    """Formulaire import excel semestres"""
+
+    fichier = FileField(
+        "Fichier à importer",
+        validators=[
+            DataRequired(),
+            FileAllowed(["xlsx"], "Fichier .xlsx uniquement"),
+        ],
+    )
+    image_archive_file = FileField(
+        "Fichier zip avec les images",
+        validators=[
+            FileAllowed(["zip"], "Fichier .zip uniquement"),
+        ],
+    )
+    create_formation = BooleanField(
+        "Créer les programmes de formations s'ils n'existent pas", default=True
+    )
+    submit = SubmitField("Importer et créer les formations")
+    cancel = SubmitField("Annuler")
diff --git a/app/formsemestre/import_from_descr.py b/app/formsemestre/import_from_descr.py
new file mode 100644
index 0000000000000000000000000000000000000000..07cdf0c3b95e9b8ca34f76ae9d7ab467f2c28f25
--- /dev/null
+++ b/app/formsemestre/import_from_descr.py
@@ -0,0 +1,382 @@
+# -*- coding: utf-8 -*-
+
+##############################################################################
+#
+# ScoDoc
+#
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+#
+#   Emmanuel Viennet      emmanuel.viennet@viennet.net
+#
+##############################################################################
+
+"""
+Importation directe de formsemestres depuis un fichier excel
+donnant leurs paramètres et description.
+(2024 pour EL)
+
+- Formation: indiquée par ("dept", "formation_acronyme", "formation_titre", "formation_version")
+- Modules: ces formsemestres ne prennent que le premier module de la formation
+à laquelle ils sont rattachés.
+
+Les champs sont définis ci-dessous, pour chaque objet:
+    Formation, FormSemestre, FormSemestreDescription.
+
+"""
+
+from collections import namedtuple
+import datetime
+
+from flask import g, url_for
+from flask_login import current_user
+
+from app import db, log
+from app.models import (
+    Formation,
+    FormSemestre,
+    FormSemestreDescription,
+    Matiere,
+    Module,
+    ModuleImpl,
+    UniteEns,
+)
+from app.scodoc import sco_excel
+from app.scodoc import sco_utils as scu
+from app.scodoc.codes_cursus import CodesCursus
+from app.scodoc.sco_exceptions import ScoValueError
+
+# Définition des champs
+FieldDescr = namedtuple(
+    "DescrField",
+    ("key", "description", "optional", "default", "type", "allow_html"),
+    defaults=(None, "", True, "", "str", False),
+)
+
+# --- Formation
+FORMATION_FIELDS = (
+    FieldDescr("formation_acronyme", "acronyme de la formation", optional=False),
+    FieldDescr("formation_titre", "titre de la formation", optional=False),
+    FieldDescr(
+        "formation_version", "version de la formation", optional=True, default=1
+    ),
+    FieldDescr(
+        "formation_commentaire",
+        "commentaire à usage interne",
+        optional=True,
+        default="",
+    ),
+)
+# --- FormSemestre
+FORMSEMESTRE_FIELDS = (
+    FieldDescr(
+        "semestre_id",
+        "indice du semestre dans la formation",
+        optional=False,
+        type="int",
+    ),
+    FieldDescr("titre", "titre du semestre (si vide, sera déduit de la formation)"),
+    FieldDescr(
+        "capacite_accueil",
+        "capacité d'accueil (nombre ou vide).",
+        type="int",
+        default=None,
+    ),
+    FieldDescr(
+        "date_debut", "date début des cours du semestre", type="date", optional=False
+    ),
+    FieldDescr(
+        "date_fin", "date fin des cours du semestre", type="date", optional=False
+    ),
+    FieldDescr("edt_id", "identifiant emplois du temps (optionnel)"),
+    FieldDescr("etat", "déverrouillage.", type="bool", default=True),
+    FieldDescr("modalite", "modalité de formation: 'FI', 'FAP', 'FC'", default="FI"),
+    FieldDescr(
+        "elt_sem_apo",
+        "code(s) Apogée élement semestre, eg 'VRTW1' ou 'V2INCS4,V2INLS4'",
+    ),
+    FieldDescr("elt_annee_apo", "code(s) Apogée élement année"),
+    FieldDescr("elt_passage_apo", "code(s) Apogée élement passage"),
+)
+# --- Description externe (FormSemestreDescription)
+# --- champs préfixés par "descr_"
+FORMSEMESTRE_DESCR_FIELDS = (
+    FieldDescr(
+        "descr_description",
+        """description du cours: informations sur le contenu, les objectifs, les modalités d'évaluation, etc.""",
+        allow_html=True,
+    ),
+    FieldDescr(
+        "descr_horaire",
+        "indication sur l'horaire, texte libre, ex.: les lundis 9h-12h",
+    ),
+    FieldDescr(
+        "descr_date_debut_inscriptions",
+        "Date d'ouverture des inscriptions (laisser vide pour autoriser tout le temps)",
+        type="datetime",
+    ),
+    FieldDescr(
+        "descr_date_fin_inscriptions", "Date de fin des inscriptions.", type="datetime"
+    ),
+    FieldDescr(
+        "descr_wip",
+        "work in progress: si vrai, affichera juste le titre du semestre",
+        type="bool",
+        default=False,
+    ),
+    FieldDescr(
+        "descr_image",
+        "image illustrant cette formation (en excel, nom du fichier dans le zip associé)",
+        type="image",
+    ),
+    FieldDescr("descr_campus", "campus, par ex. Villetaneuse"),
+    FieldDescr("descr_salle", "salle."),
+    FieldDescr(
+        "descr_dispositif",
+        "modalité de formation: 0 présentiel, 1 online, 2 hybride",
+        type="int",
+        default=0,
+    ),
+    FieldDescr(
+        "descr_dispositif_descr", "décrit modalités de formation", allow_html=True
+    ),
+    FieldDescr(
+        "descr_modalites_mcc",
+        "modalités de contrôle des connaissances.",
+        allow_html=True,
+    ),
+    FieldDescr(
+        "descr_photo_ens",
+        "photo de l'enseignant(e) ou autre illustration (en excel, nom du fichier dans le zip associé)",
+        type="image",
+    ),
+    FieldDescr("descr_public", "public visé"),
+    FieldDescr("descr_prerequis", "prérequis", allow_html=True),
+    FieldDescr(
+        "descr_responsable",
+        "responsable du cours ou personne chargée de l'organisation du semestre",
+        allow_html=True,
+    ),
+)
+
+ALL_FIELDS = FORMATION_FIELDS + FORMSEMESTRE_FIELDS + FORMSEMESTRE_DESCR_FIELDS
+
+FIELDS_BY_KEY = {}
+
+
+def describe_field(key: str) -> str:
+    """texte aide décrivant ce champ"""
+    if not FIELDS_BY_KEY:
+        FIELDS_BY_KEY.update({field.key: field for field in ALL_FIELDS})
+    field = FIELDS_BY_KEY[key]
+    return field.description + (" HTML autorisé" if field.allow_html else "")
+
+
+def generate_sample():
+    """Generate excel xlsx for import"""
+    titles = [fs.key for fs in ALL_FIELDS]
+    comments = [fs.description for fs in ALL_FIELDS]
+
+    style = sco_excel.excel_make_style(bold=True)
+    titles_styles = [style] * len(titles)
+    return sco_excel.excel_simple_table(
+        titles=titles,
+        titles_styles=titles_styles,
+        sheet_name="Import semestres",
+        comments=comments,
+    )
+
+
+def check_and_convert(value, field: FieldDescr):
+    if value is None or value == "":
+        if not field.optional:
+            raise ValueError(f"champs {field.key} requis")
+        return field.default
+    match field.type:
+        case "str":
+            return str(value).strip()
+        case "int":
+            return int(value)
+        case "image":
+            return str(value).strip()  # image path
+        case "bool":
+            return scu.to_bool(value)
+        case "date":
+            if isinstance(value, datetime.date):
+                return value
+            if isinstance(value, datetime.datetime):
+                return value.date
+            if isinstance(value, str):
+                try:
+                    return datetime.date.fromisoformat(value)
+                except ValueError:
+                    # try datetime
+                    return datetime.datetime.fromisoformat(value)
+            raise ValueError(f"invalid date for {field.key}")
+        case "datetime":
+            if isinstance(value, datetime.datetime):
+                return value
+            if isinstance(value, str):
+                return datetime.datetime.fromisoformat(value)
+            raise ValueError(f"invalid datetime for {field.key}")
+    raise NotImplementedError(f"unimplemented type {field.type} for field {field.key}")
+
+
+def read_excel(datafile) -> list[dict]:
+    "lecture fichier excel import formsemestres"
+    exceldata = datafile.read()
+    diag, rows = sco_excel.excel_bytes_to_dict(exceldata)
+    # check and convert types
+    for line_num, row in enumerate(rows, start=2):
+        for field in ALL_FIELDS:
+            if field.key not in row:
+                if field.optional:
+                    row[field.key] = field.default
+                else:
+                    raise ScoValueError(
+                        f"Ligne {line_num}, colonne {field.key}: valeur requise"
+                    )
+            else:
+                try:
+                    row[field.key] = check_and_convert(row[field.key], field)
+                except ValueError as exc:
+                    raise ScoValueError(
+                        f"Ligne {line_num}, colonne {field.key}: {exc.args}",
+                        dest_label="Reprendre",
+                        dest_url=url_for(
+                            "notes.formsemestres_import_from_description",
+                            scodoc_dept=g.scodoc_dept,
+                        ),
+                    ) from exc
+    log(diag)  # XXX
+    return rows
+
+
+def _create_formation_and_modimpl(data) -> Formation:
+    """Create a new formation, with a UE and module"""
+    args = {
+        field.key.removeprefix("formation_"): data[field.key]
+        for field in FORMATION_FIELDS
+    }
+    args["dept_id"] = g.scodoc_dept_id
+    # add some required fields:
+    if "titre_officiel" not in args:
+        args["titre_officiel"] = args["titre"]
+    if not args.get("type_parcours"):
+        args["type_parcours"] = CodesCursus.Mono
+    formation = Formation.create_from_dict(args)
+    ue = UniteEns.create_from_dict(
+        {
+            "formation": formation,
+            "acronyme": f"UE {data['formation_acronyme']}",
+            "titre": data["formation_titre"],
+        }
+    )
+    matiere = Matiere.create_from_dict(
+        {
+            "ue": ue,
+            "titre": data["formation_titre"],
+        }
+    )
+    module = Module.create_from_dict(
+        {
+            "ue": ue,
+            "formation": formation,
+            "matiere": matiere,
+            "titre": data["formation_titre"],
+            "abbrev": data["formation_titre"],
+            "code": data["formation_acronyme"],
+            "coefficient": 1.0,
+        }
+    )
+    return formation
+
+
+def create_formsemestre_from_description(
+    data: dict, create_formation=False, images: dict | None = None
+) -> FormSemestre:
+    """Create from fields in data.
+    - Search formation: if needed and create_formation, create it;
+    - Create formsemestre
+    - Create formsemestre description
+    """
+    images = images or {}
+    created = []  # list of created objects XXX unused
+    user = current_user  # resp. semestre et module
+    formation = (
+        db.session.query(Formation)
+        .filter_by(
+            dept_id=g.scodoc_dept_id,
+            acronyme=data["formation_acronyme"],
+            titre=data["formation_titre"],
+            version=data["formation_version"],
+        )
+        .first()
+    )
+    if not formation:
+        if not create_formation:
+            raise ScoValueError("formation inexistante dans ce département")
+        formation = _create_formation_and_modimpl(data)
+        db.session.flush()
+        created.append(formation)
+    # Détermine le module à placer dans le formsemestre
+    module = formation.modules.first()
+    if not module:
+        raise ScoValueError(
+            f"La formation {formation.get_titre_version()} n'a aucun module"
+        )
+    # --- FormSemestre
+    args = {field.key: data[field.key] for field in FORMSEMESTRE_FIELDS}
+    args["dept_id"] = g.scodoc_dept_id
+    args["formation_id"] = formation.id
+    args["responsables"] = [user]
+    if not args.get("titre"):
+        args["titre"] = formation.titre or formation.titre_officiel
+    formsemestre = FormSemestre.create_formsemestre(args)
+    modimpl = ModuleImpl.create_from_dict(
+        {
+            "module_id": module.id,
+            "formsemestre_id": formsemestre.id,
+            "responsable_id": user.id,
+        }
+    )
+    # --- FormSemestreDescription
+    args = {
+        field.key.removeprefix("descr_"): data[field.key]
+        for field in FORMSEMESTRE_DESCR_FIELDS
+    }
+    args["image"] = args["image"] or None
+    args["photo_ens"] = args["photo_ens"] or None
+    args["formsemestre_id"] = formsemestre.id
+    formsemestre_descr = FormSemestreDescription.create_from_dict(args)
+    #
+    db.session.commit()
+    return formsemestre
+
+
+def create_formsemestres_from_description(
+    infos: list[dict], create_formation: bool = False, images: dict | None = None
+) -> list[FormSemestre]:
+    "Creation de tous les semestres mono-modules"
+    log(
+        f"create_formsemestres_from_description: {len(infos)} items, create_formation={create_formation}"
+    )
+    return [
+        create_formsemestre_from_description(
+            data, create_formation=create_formation, images=images
+        )
+        for data in infos
+    ]
diff --git a/app/models/formations.py b/app/models/formations.py
index 559b6fe7d267f46050179cf031cd6a4e9c497bd9..9ed7cd0a3e8d2278ae14c767f6ce34456ecfecea 100644
--- a/app/models/formations.py
+++ b/app/models/formations.py
@@ -381,19 +381,22 @@ class Matiere(ScoDocModel):
     @classmethod
     def create_from_dict(cls, data: dict) -> "Matiere":
         """Create matière from dict. Log, news, cache.
-        data must include ue_id, a valid UE id.
+        data must include ue_id, a valid UE id, or ue.
         Commit session.
         """
         # check ue
         if data.get("ue_id") is None:
-            raise ScoValueError("UE id missing")
-        _ = UniteEns.get_ue(data["ue_id"])
+            if data.get("ue") is None:
+                raise ScoValueError("UE missing")
+        else:  # check ue_id
+            _ = UniteEns.get_ue(data["ue_id"])
 
         mat = super().create_from_dict(data)
         db.session.commit()
         db.session.refresh(mat)
         # news
         formation = mat.ue.formation
+        log(f"Matiere.create_from_dict: created {mat} from {data}")
         ScolarNews.add(
             typ=ScolarNews.NEWS_FORM,
             obj=formation.id,
diff --git a/app/models/formsemestre_descr.py b/app/models/formsemestre_descr.py
index 1e754c526228000ff62fdfea2d5a1dcfa136fc39..cff56185283074e80f656e837955c7f543909284 100644
--- a/app/models/formsemestre_descr.py
+++ b/app/models/formsemestre_descr.py
@@ -39,8 +39,12 @@ class FormSemestreDescription(models.ScoDocModel):
 
     dispositif = db.Column(db.Integer, nullable=False, default=0, server_default="0")
     "0 présentiel, 1 online, 2 hybride"
+    dispositif_descr = db.Column(
+        db.Text(), nullable=False, default="", server_default=""
+    )
+    "décrit modalités de formation. html autorisé."
     modalites_mcc = db.Column(db.Text(), nullable=False, default="", server_default="")
-    "modalités de contrôle des connaissances"
+    "modalités de contrôle des connaissances (texte libre, html autorisé)"
     photo_ens = db.Column(db.LargeBinary(), nullable=True)
     "photo de l'enseignant(e)"
     public = db.Column(db.Text(), nullable=False, default="", server_default="")
diff --git a/app/models/moduleimpls.py b/app/models/moduleimpls.py
index a3ce27a726811afcbe8605e0d7544d38f5501774..ffe7db113588e065c3c0cea3335a168d8b117722 100644
--- a/app/models/moduleimpls.py
+++ b/app/models/moduleimpls.py
@@ -77,7 +77,9 @@ class ModuleImpl(ScoDocModel):
         # check required args
         for required_arg in ("formsemestre_id", "module_id", "responsable_id"):
             if required_arg not in data:
-                raise ScoValueError(f"missing argument: {required_arg}")
+                raise ScoValueError(
+                    f"ModuleImpl.create_from_dict: missing argument: {required_arg}"
+                )
         _ = FormSemestre.get_formsemestre(data["formsemestre_id"])
         _ = Module.get_instance(data["module_id"])
         if not db.session.get(User, data["responsable_id"]):
diff --git a/app/models/modules.py b/app/models/modules.py
index 2df6a8b2c33e7114eb0a585cef40e8a312fb5504..7c7a38203e47f048bb8ad4095d3b08811da57058 100644
--- a/app/models/modules.py
+++ b/app/models/modules.py
@@ -210,14 +210,27 @@ class Module(models.ScoDocModel):
         from app.models.formations import Formation
 
         # check required arguments
-        for required_arg in ("code", "formation_id", "ue_id"):
-            if required_arg not in data:
-                raise ScoValueError(f"missing argument: {required_arg}")
+        if "code" not in data:
+            raise ScoValueError("Module.create_from_dict: missing 'code' argument")
         if not data["code"]:
-            raise ScoValueError("module code must be non empty")
+            raise ScoValueError(
+                "Module.create_from_dict: module code must be non empty"
+            )
+        # Check ue
+        if data.get("ue_id") is None:
+            ue = data.get("ue")
+            if ue is None or not isinstance(ue, UniteEns):
+                raise ScoValueError("Module.create_from_dict: UE missing")
+        else:  # check ue_id
+            ue = UniteEns.get_ue(data["ue_id"])
         # Check formation
-        formation = Formation.get_formation(data["formation_id"])
-        ue = UniteEns.get_ue(data["ue_id"])
+        if data.get("formation_id") is None:
+            formation = data.get("formation")
+            if formation is None or not isinstance(formation, Formation):
+                raise ScoValueError("Module.create_from_dict: formation missing")
+        else:  # check ue_id
+            formation = UniteEns.get_ue(data["ue_id"])
+        # formation = Formation.get_formation(data["formation_id"])
         # refuse de créer un module APC avec semestres semestre du module != semestre de l'UE
         if formation.is_apc():
             if int(data.get("semestre_id", 1)) != ue.semestre_idx:
diff --git a/app/scodoc/sco_dept.py b/app/scodoc/sco_dept.py
index e033a89a54f60144e1a375ccd1012dd27cbd010f..e81bf450f1af5993f5966799261e313fc8c5810f 100644
--- a/app/scodoc/sco_dept.py
+++ b/app/scodoc/sco_dept.py
@@ -55,17 +55,17 @@ def index_html(showcodes=0, showsemtable=0, export_table_formsemestres=False):
     current_formsemestres = (
         FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id, etat=True)
         .filter(FormSemestre.modalite != "EXT")
-        .order_by(desc(FormSemestre.date_debut))
+        .order_by(desc(FormSemestre.date_debut), FormSemestre.titre)
     )
     locked_formsemestres = (
         FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id, etat=False)
         .filter(FormSemestre.modalite != "EXT")
-        .order_by(desc(FormSemestre.date_debut))
+        .order_by(desc(FormSemestre.date_debut), FormSemestre.titre)
     )
     formsemestres = (
         FormSemestre.query.filter_by(dept_id=g.scodoc_dept_id)
         .filter(FormSemestre.modalite != "EXT")
-        .order_by(desc(FormSemestre.date_debut))
+        .order_by(desc(FormSemestre.date_debut), FormSemestre.titre)
     )
     if showsemtable:  # table de tous les formsemestres
         table = _sem_table_gt(
diff --git a/app/scodoc/sco_excel.py b/app/scodoc/sco_excel.py
index acd4293c22add34dfb012b7ef946247744366bac..c9da7336265d7e04eded36656635a472abb7cb80 100644
--- a/app/scodoc/sco_excel.py
+++ b/app/scodoc/sco_excel.py
@@ -470,9 +470,11 @@ def excel_simple_table(
     lines: list[list[str]] = None,
     sheet_name: str = "feuille",
     titles_styles=None,
-    comments=None,
+    comments: list[str] | None = None,
 ):
-    """Export simple type 'CSV': 1ere ligne en gras, le reste tel quel."""
+    """Export simple type 'CSV': 1ere ligne en gras, le reste tel quel.
+    comments (optionnel) donne des commentaires à placer sur les cellules de titre.
+    """
     ws = ScoExcelSheet(sheet_name)
     if titles is None:
         titles = []
@@ -510,7 +512,11 @@ def excel_simple_table(
 
 
 def excel_bytes_to_list(bytes_content) -> tuple[list, list[list]]:
-    "Lecture d'un flux xlsx"
+    """Lecture d'un flux xlsx.
+    returns:
+    - diag : a list of strings (error messages aimed at helping the user)
+    - a list of lists: the spreadsheet cells
+    """
     try:
         filelike = io.BytesIO(bytes_content)
         return _excel_to_list(filelike)
@@ -522,8 +528,31 @@ def excel_bytes_to_list(bytes_content) -> tuple[list, list[list]]:
         ) from exc
 
 
+def excel_bytes_to_dict(
+    bytes_content, force_lowercase_keys=True
+) -> tuple[list[str], list[dict]]:
+    """Lecture d'un flux xlsx et conversion en dict,
+    les clés étant données par les titres sur la première ligne.
+    returns:
+    - diag : a list of strings (error messages aimed at helping the user)
+    - a list of dict: the spreadsheet cells
+    """
+    diag, rows = excel_bytes_to_list(bytes_content)
+    if len(rows) < 1:
+        raise ScoValueError("Fichier excel vide")
+    if force_lowercase_keys:
+        keys = [k.strip().lower() for k in rows[0]]
+    else:
+        keys = [k.strip() for k in rows[0]]
+    return diag, [dict(zip(keys, row)) for row in rows[1:]]
+
+
 def excel_file_to_list(filelike) -> tuple[list, list[list]]:
-    "Lecture d'un flux xlsx"
+    """Lecture d'un flux xlsx
+    returns:
+    - diag : a list of strings (error messages aimed at helping the user)
+    - a list of lists: the spreadsheet cells
+    """
     try:
         return _excel_to_list(filelike)
     except Exception as exc:
@@ -554,7 +583,10 @@ def _open_workbook(filelike, dump_debug=False) -> Workbook:
 
 
 def _excel_to_list(filelike) -> tuple[list, list[list]]:
-    """returns list of list"""
+    """returns:
+    - diag : a list of strings (error messages aimed at helping the user)
+    - a list of lists: the spreadsheet cells
+    """
     workbook = _open_workbook(filelike)
     diag = []  # liste de chaines pour former message d'erreur
     if len(workbook.sheetnames) < 1:
diff --git a/app/templates/formsemestre/import_from_description.j2 b/app/templates/formsemestre/import_from_description.j2
new file mode 100644
index 0000000000000000000000000000000000000000..b89aab5f55040bf93b85ea867153543d3b84b760
--- /dev/null
+++ b/app/templates/formsemestre/import_from_description.j2
@@ -0,0 +1,59 @@
+{% extends "sco_page.j2" %}
+{% import 'wtf.j2' as wtf %}
+
+{% block app_content %}
+<h2 class="formsemestre">Importation / création de semestres de formation monomodules</h2>
+
+<div class="scobox help explanation"> <p style="color: red">Fonction réservé à
+la création de semestres ne comprenant qu'un seul module (dans une UE unique),
+décrits dans un fichier excel. </p>
+
+<p>L'opération se déroule en plusieurs étapes:
+</p>
+<ol>
+    <li style="margin-bottom:8px;"> Dans un premier temps, vous téléchargez une
+    feuille Excel donnant les titres des colonnes. Certaines colonnes sont
+    optionnelles et peuvent être ignorés. Si vous ajoutez d'autres colonnes,
+    elles seront ignorées.
+    </li>
+
+    <li style="margin-bottom:8px;">Vous ajoutez une ligne par semestre/formation
+    à créer, avec votre logiciel tableur préféré. En option, les colonnes images donnent
+    les noms complets (avec chemin) d'un fichier dans l'archive zip associée.
+    </li>
+
+    <li style="margin-bottom:32px;">Revenez sur cette page et chargez le fichier dans ScoDoc.
+    </li>
+</ol>
+</div>
+
+<div class="scobox">
+    <div class="scobox-title">Étape 1: exporter fichier Excel à charger</div>
+    <ul>
+        <li><a class="stdlink" href="{{
+            url_for('notes.formsemestres_import_from_description_sample', scodoc_dept=g.scodoc_dept)
+        }}">Obtenir la feuille excel à remplir</a>
+        </li>
+    </ul>
+</div>
+
+<div class="scobox">
+    <div class="scobox-title">Étape 2: charger le fichier Excel rempli</div>
+
+    <div class="row">
+        <div class="col-md-8">
+            {{ wtf.quick_form(form) }}
+        </div>
+    </div>
+</div>
+
+
+<div class="scobox help explanation">
+    <div class="scobox-title">Description des champs du fichier excel</div>
+        <ul>
+        {% for field, descr in fields_description.items() %}
+            <li><tt>{{field}}</tt>&nbsp;: <span>{{descr}}</span></li>
+        {% endfor %}
+        </ul>
+</div>
+{% endblock %}
diff --git a/app/templates/formsemestre/import_from_description_result.j2 b/app/templates/formsemestre/import_from_description_result.j2
new file mode 100644
index 0000000000000000000000000000000000000000..e264f6fa4433e82de5abac1076ac589b4b238ddd
--- /dev/null
+++ b/app/templates/formsemestre/import_from_description_result.j2
@@ -0,0 +1,22 @@
+{% extends "sco_page.j2" %}
+{% import 'wtf.j2' as wtf %}
+
+{% block app_content %}
+<h2 class="formsemestre">Semestres créés</h2>
+
+<div class="scobox">
+    <div class="scobox-title">Les semestres suivants ont été créés:</div>
+    <ul>
+    {% for formsemestre in formsemestres %}
+        <li>{{ formsemestre.html_link_status() | safe }}
+        </li>
+    {% endfor %}
+    </ul>
+</div>
+
+<div>
+ <a class="stdlink" href="{{
+    url_for('scolar.index_html', scodoc_dept=g.scodoc_dept)}}">Accueil semestres</a>
+</div>
+
+{% endblock %}
diff --git a/app/views/notes.py b/app/views/notes.py
index b317169e9ac45893c78c03f9bdf21adfdb745c5d..eedb4c67d0e4c478a703f7fbe5ff507b2eaa752c 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -740,6 +740,16 @@ def index_html():
         </li>
         </ul>
     </div>
+
+    <div class="scobox">
+        <div class="scobox-title">Opérations avancées réservées aux connaisseuses</div>
+        <ul class="sco-links">
+        <li><a class="stdlink" href="{
+            url_for('notes.formsemestres_import_from_description', scodoc_dept=g.scodoc_dept)
+        }">Importer des sessions monomodules</a> (expérimental)
+        </li>
+        </ul>
+    </div>
     """
         )
 
diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py
index 4a62df44bc70f608ab7cd9269fc3158a4d6dd9a5..54fa6efa9bf241a761caf8a02474e28f5558839a 100644
--- a/app/views/notes_formsemestre.py
+++ b/app/views/notes_formsemestre.py
@@ -31,6 +31,7 @@ Emmanuel Viennet, 2023
 
 import datetime
 import io
+import zipfile
 
 from flask import flash, redirect, render_template, url_for
 from flask import current_app, g, request
@@ -47,6 +48,7 @@ from app.forms.formsemestre import (
     edit_modimpls_codes_apo,
     edit_description,
 )
+from app.formsemestre import import_from_descr
 from app.models import (
     Formation,
     FormSemestre,
@@ -354,3 +356,122 @@ def edit_formsemestre_description(formsemestre_id: int):
         sco=ScoData(formsemestre=formsemestre),
         title="Modif. description semestre",
     )
+
+
+@bp.route("/formsemestres/import_from_descr", methods=["GET", "POST"])
+@scodoc
+@permission_required(Permission.EditFormSemestre)
+@permission_required(Permission.EditFormation)
+def formsemestres_import_from_description():
+    """Import de formation/formsemestre à partir d'un excel.
+    Un seul module est créé. Utilisé pour EL.
+    """
+    form = edit_description.FormSemestresImportFromDescrForm()
+    if form.validate_on_submit():
+        if form.cancel.data:  # cancel button
+            return redirect(
+                url_for(
+                    "notes.index_html",
+                    scodoc_dept=g.scodoc_dept,
+                )
+            )
+        datafile = request.files[form.fichier.name]
+        image_archive_file = request.files[form.image_archive_file.name]
+        create_formation = form.create_formation.data
+        infos = import_from_descr.read_excel(datafile)
+        images = _extract_images_from_zip(image_archive_file)
+        _load_images_refs(infos, images)
+        for linenum, info in enumerate(infos, start=1):
+            info["formation_commentaire"] = (
+                info.get("formation_commentaire")
+                or f"importé de {request.files[form.fichier.name].filename}, ligne {linenum}"
+            )
+        formsemestres = import_from_descr.create_formsemestres_from_description(
+            infos, create_formation=create_formation, images=images
+        )
+        current_app.logger.info(
+            f"formsemestres_import_from_description: {len(formsemestres)} semestres créés"
+        )
+        flash(f"Importation et création de {len(formsemestres)} semestres")
+        return render_template(
+            "formsemestre/import_from_description_result.j2",
+            formsemestres=formsemestres,
+        )
+
+    return render_template(
+        "formsemestre/import_from_description.j2",
+        title="Importation de semestres de formations monomodules",
+        form=form,
+        fields_description={
+            key: import_from_descr.describe_field(key)
+            for key in sorted(import_from_descr.FIELDS_BY_KEY)
+        },
+    )
+
+
+def _extract_images_from_zip(image_archive_file) -> dict[str, bytes]:
+    """Read archive file, and build dict: { path : image_data }
+    check that image_data is a valid image.
+    """
+    # Image suffixes supported by PIL
+    exts = PIL.Image.registered_extensions()
+    supported_extensions = tuple(ex for ex, f in exts.items() if f in PIL.Image.OPEN)
+
+    images = {}
+    with zipfile.ZipFile(image_archive_file) as archive:
+        for file_info in archive.infolist():
+            if file_info.is_dir() or file_info.filename.startswith("__"):
+                continue
+            if not file_info.filename.lower().endswith(supported_extensions):
+                continue  # ignore non image files
+            with archive.open(file_info) as file:
+                image_data = file.read()
+                try:
+                    _ = PIL.Image.open(io.BytesIO(image_data))
+                    images[file_info.filename] = image_data
+                except PIL.UnidentifiedImageError as exc:
+                    current_app.logger.warning(
+                        f"Invalid image in archive: {file_info.filename}"
+                    )
+                    raise ScoValueError(
+                        f"Image invalide dans l'archive: {file_info.filename}",
+                        dest_url=url_for(
+                            "notes.formsemestres_import_from_description",
+                            scodoc_dept=g.scodoc_dept,
+                        ),
+                        dest_label="Reprendre",
+                    ) from exc
+    return images
+
+
+def _load_images_refs(infos: list[dict], images: dict):
+    """Check if all referenced images in excel (infos)
+    are present in the zip archive (images) and put them in the infos dicts.
+    """
+    for linenum, info in enumerate(infos, start=1):
+        for key in ("descr_image", "descr_photo_ens"):
+            info[key] = (
+                info[key].strip() if isinstance(info[key], str) else None
+            ) or None
+            if info[key]:
+                if info[key] not in images:
+                    raise ScoValueError(
+                        f'Image "{info[key]}" référencée en ligne {linenum}, colonne {key} non trouvée dans le zip',
+                        dest_url=url_for(
+                            "notes.formsemestres_import_from_description",
+                            scodoc_dept=g.scodoc_dept,
+                        ),
+                        dest_label="Reprendre",
+                    )
+                info[key] = images[info[key]]
+
+
+@bp.route("/formsemestres/import_from_descr_sample", methods=["GET", "POST"])
+@scodoc
+@permission_required(Permission.ScoView)
+def formsemestres_import_from_description_sample():
+    "Renvoie fichier excel à remplir"
+    xls = import_from_descr.generate_sample()
+    return scu.send_file(
+        xls, "ImportSemestres", scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE
+    )
diff --git a/migrations/versions/bc85a55e63e1_add_dispositif_descr.py b/migrations/versions/bc85a55e63e1_add_dispositif_descr.py
new file mode 100644
index 0000000000000000000000000000000000000000..0853f6993d5d6231af6c6dd5959e6848fde8bb21
--- /dev/null
+++ b/migrations/versions/bc85a55e63e1_add_dispositif_descr.py
@@ -0,0 +1,33 @@
+"""add dispositif_descr
+
+Revision ID: bc85a55e63e1
+Revises: bcd959a23aea
+Create Date: 2024-12-30 18:32:55.024694
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "bc85a55e63e1"
+down_revision = "bcd959a23aea"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    with op.batch_alter_table(
+        "notes_formsemestre_description", schema=None
+    ) as batch_op:
+        batch_op.add_column(
+            sa.Column("dispositif_descr", sa.Text(), server_default="", nullable=False)
+        )
+
+
+def downgrade():
+    with op.batch_alter_table(
+        "notes_formsemestre_description", schema=None
+    ) as batch_op:
+        batch_op.drop_column("dispositif_descr")
diff --git a/scodoc.py b/scodoc.py
index 562c67fb7b199acce9af7e94ff47d50337651b80..82bea3cd01c96d672987272f0430eb17c601d784 100755
--- a/scodoc.py
+++ b/scodoc.py
@@ -430,6 +430,91 @@ def user_change_login(user_name, new_user_name):
     user.change_user_name(new_user_name)
 
 
+@app.cli.command()
+@click.argument("username")
+@click.option(
+    "-d",
+    "--deactivate",
+    "deactivate",
+    is_flag=True,
+    help="désactive ce compte",
+)
+@click.option(
+    "-a",
+    "--activate",
+    "activate",
+    is_flag=True,
+    help="(ré)active ce compte",
+)
+@click.option("-c", "--cas-id", "cas_id")
+@click.option(
+    "--allow-cas-login",
+    "allow_cas_login",
+    is_flag=True,
+    help="autorise login via CAS",
+)
+@click.option(
+    "--disable-cas-login",
+    "disable_cas_login",
+    is_flag=True,
+    help="interdit login via CAS",
+)
+@click.option(
+    "--allow-scodoc-login",
+    "allow_scodoc_login",
+    is_flag=True,
+    help="autorise login via ScoDoc",
+)
+@click.option(
+    "--disable-scodoc-login",
+    "disable_scodoc_login",
+    is_flag=True,
+    help="interdit login via ScoDoc",
+)
+@click.option(
+    "-v",
+    "--verbose",
+    "verbose",
+    is_flag=True,
+    help="verbose: affiche l'état après modif.",
+)
+def user_edit(
+    username,
+    cas_id: str = None,
+    allow_cas_login=None,
+    allow_scodoc_login=None,
+    disable_cas_login=None,
+    disable_scodoc_login=None,
+    activate=None,
+    deactivate=None,
+    verbose=False,
+):
+    """Add or remove a role to the given user in the given dept"""
+    user: User = User.query.filter_by(user_name=username).first()
+    if not user:
+        sys.stderr.write(f"user_role: user {username} does not exists\n")
+        return 1
+    if cas_id:
+        user.cas_id = cas_id
+    if allow_cas_login:
+        user.cas_allow_login = True
+    if disable_cas_login:
+        user.cas_allow_login = False
+    if allow_scodoc_login:
+        user.cas_allow_scodoc_login = True
+    if disable_scodoc_login:
+        user.cas_allow_scodoc_login = False
+    if activate:
+        user.active = True
+    if deactivate:
+        user.active = False
+    db.session.add(user)
+    db.session.commit()
+    if verbose:
+        for k, v in sorted(user.to_dict().items()):
+            print(f"{k} : {v}")
+
+
 def abort_if_false(ctx, param, value):
     if not value:
         ctx.abort()