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/formsemestre/import_from_descr.py b/app/formsemestre/import_from_descr.py
index b28276723c6435ca269eb8107248c43c593373e5..938cdc062a9457c1682c0335ede2d2030f1840da 100644
--- a/app/formsemestre/import_from_descr.py
+++ b/app/formsemestre/import_from_descr.py
@@ -41,7 +41,7 @@ Les champs sont définis ci-dessous, pour chaque objet:
 from collections import namedtuple
 import datetime
 
-from flask import g
+from flask import g, url_for
 from flask_login import current_user
 
 from app import db, log
@@ -56,6 +56,7 @@ from app.models import (
 )
 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
@@ -69,7 +70,15 @@ FieldDescr = namedtuple(
 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=False),
+    FieldDescr(
+        "formation_version", "version de la formation", optional=True, default=1
+    ),
+    FieldDescr(
+        "formation_commentaire",
+        "commentaire à usage interne",
+        optional=True,
+        default="",
+    ),
 )
 # --- FormSemestre
 FORMSEMESTRE_FIELDS = (
@@ -188,13 +197,18 @@ def generate_sample():
 
 
 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":
-            if value:
+            return None  # XXX ignore
+            if value:  # WIP
                 raise NotImplementedError("image import from Excel not implemented")
         case "bool":
             return scu.to_bool(value)
@@ -204,13 +218,17 @@ def check_and_convert(value, field: FieldDescr):
             if isinstance(value, datetime.datetime):
                 return value.date
             if isinstance(value, str):
-                return datetime.date.fromisoformat(value)
+                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.date.fromisoformat(value)
+                return datetime.datetime.fromisoformat(value)
             raise ValueError(f"invalid datetime for {field.key}")
     raise NotImplementedError(f"unimplemented type {field.type} for field {field.key}")
 
@@ -220,7 +238,7 @@ def read_excel(datafile) -> list[dict]:
     exceldata = datafile.read()
     diag, rows = sco_excel.excel_bytes_to_dict(exceldata)
     # check and convert types
-    for line_num, row in enumerate(rows, start=1):
+    for line_num, row in enumerate(rows, start=2):
         for field in ALL_FIELDS:
             if field.key not in row:
                 if field.optional:
@@ -234,7 +252,12 @@ def read_excel(datafile) -> list[dict]:
                     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}"
+                        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
@@ -242,8 +265,16 @@ def read_excel(datafile) -> list[dict]:
 
 def _create_formation_and_modimpl(data) -> Formation:
     """Create a new formation, with a UE and module"""
-    args = {field.key: data[field.key] for field in FORMATION_FIELDS}
+    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(
         {
@@ -266,6 +297,7 @@ def _create_formation_and_modimpl(data) -> Formation:
             "titre": data["formation_titre"],
             "abbrev": data["formation_titre"],
             "code": data["formation_acronyme"],
+            "coefficient": 1.0,
         }
     )
     return formation
@@ -295,6 +327,7 @@ def create_formsemestre_from_description(
         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()
@@ -307,7 +340,9 @@ def create_formsemestre_from_description(
     args["dept_id"] = g.scodoc_dept_id
     args["formation_id"] = formation.id
     args["responsables"] = [user]
-    formsemestre = FormSemestre.create_formsemestre(data)
+    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,
@@ -317,9 +352,11 @@ def create_formsemestre_from_description(
     )
     # --- FormSemestreDescription
     args = {
-        field.key[6:] if field.key.startswith("descr_") else field.key: data[field.key]
+        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)
     #
@@ -328,9 +365,12 @@ def create_formsemestre_from_description(
 
 
 def create_formsemestres_from_description(
-    infos: list[dict], create_formation=False
+    infos: list[dict], create_formation: bool = False
 ) -> 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)
         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/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_excel.py b/app/scodoc/sco_excel.py
index d1e78de845d26c606a00cae6f09f890804436934..c9da7336265d7e04eded36656635a472abb7cb80 100644
--- a/app/scodoc/sco_excel.py
+++ b/app/scodoc/sco_excel.py
@@ -544,7 +544,7 @@ def excel_bytes_to_dict(
         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]
+    return diag, [dict(zip(keys, row)) for row in rows[1:]]
 
 
 def excel_file_to_list(filelike) -> tuple[list, list[list]]:
diff --git a/app/templates/formsemestre/import_from_description.j2 b/app/templates/formsemestre/import_from_description.j2
index 3a464b3a343fc47344fb1d43082b8d07d049d3fd..65f93aefa0369a0f59a6267c1037bd3b78fb08ed 100644
--- a/app/templates/formsemestre/import_from_description.j2
+++ b/app/templates/formsemestre/import_from_description.j2
@@ -45,4 +45,14 @@ décrits dans un fichier excel. </p>
         </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
index 66b09d93d46d79174a8c1adb38c14393d3b879fe..e264f6fa4433e82de5abac1076ac589b4b238ddd 100644
--- a/app/templates/formsemestre/import_from_description_result.j2
+++ b/app/templates/formsemestre/import_from_description_result.j2
@@ -14,4 +14,9 @@
     </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_formsemestre.py b/app/views/notes_formsemestre.py
index 5b3e9d3f127ab31d944c97c93eb513a348817c78..deb7b3af63c501f07bc67ad04dec8ebab158b73a 100644
--- a/app/views/notes_formsemestre.py
+++ b/app/views/notes_formsemestre.py
@@ -375,8 +375,13 @@ def formsemestres_import_from_description():
                 )
             )
         datafile = request.files[form.fichier.name]
-        create_formation = form.create_formation
+        create_formation = form.create_formation.data
         infos = import_from_descr.read_excel(datafile)
+        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
         )
@@ -384,12 +389,19 @@ def formsemestres_import_from_description():
             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")
+        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)
+        },
     )
 
 
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")