diff --git a/app/models/__init__.py b/app/models/__init__.py
index 61371e389d977d57aa8c6177d68519b7e21f2334..e4c6bd17a0f67b6946c3cd885fb252744b416422 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -45,7 +45,7 @@ class ScoDocModel:
         The instance is added to the session (but not flushed nor committed).
         Use only relevant arributes for the given model and ignore others.
         """
-        args = cls.filter_model_attributes(data)
+        args = cls.convert_dict_fields(cls.filter_model_attributes(data))
         obj = cls(**args)
         db.session.add(obj)
         return obj
diff --git a/app/models/departements.py b/app/models/departements.py
index d4005d24d833acd41d792b5538fbfbd76de0923a..c6bb93c8c82ed17e0c9f3f47d08c2dad67ebb7fe 100644
--- a/app/models/departements.py
+++ b/app/models/departements.py
@@ -26,7 +26,17 @@ class Departement(db.Model):
     )  # sur page d'accueil
 
     # entreprises = db.relationship("Entreprise", lazy="dynamic", backref="departement")
-    etudiants = db.relationship("Identite", lazy="dynamic", backref="departement")
+    etudiants = db.relationship(
+        "Identite",
+        back_populates="departement",
+        cascade="all,delete-orphan",
+        lazy="dynamic",
+    )
+    # This means if a Departement is deleted, all related Identite instances are also
+    # deleted (all,delete). The orphan part means that if an Identite instance becomes
+    # detached from its parent Departement (for example, by setting my_identite.departement = None),
+    # it will be deleted.
+
     formations = db.relationship("Formation", lazy="dynamic", backref="departement")
     formsemestres = db.relationship(
         "FormSemestre", lazy="dynamic", backref="departement"
diff --git a/app/models/etudiants.py b/app/models/etudiants.py
index a68101d17e96f7dd11125fc97ef3a19394fde15c..432cd5c0dd94e00d4b9285f2661842fc32456c93 100644
--- a/app/models/etudiants.py
+++ b/app/models/etudiants.py
@@ -22,21 +22,27 @@ from app.scodoc.sco_exceptions import ScoInvalidParamError, ScoValueError
 import app.scodoc.sco_utils as scu
 
 
-class Identite(db.Model):
+class Identite(db.Model, models.ScoDocModel):
     """étudiant"""
 
     __tablename__ = "identite"
-    __table_args__ = (
-        db.UniqueConstraint("dept_id", "code_nip"),
-        db.UniqueConstraint("dept_id", "code_ine"),
-        db.CheckConstraint("civilite IN ('M', 'F', 'X')"),
-        db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"),
-    )
 
     id = db.Column(db.Integer, primary_key=True)
     etudid = db.synonym("id")
-    dept_id = db.Column(db.Integer, db.ForeignKey("departement.id"), index=True)
 
+    admission_id = db.Column(db.Integer, db.ForeignKey("admissions.id"), nullable=True)
+    admission = db.relationship(
+        "Admission",
+        back_populates="etud",
+        uselist=False,
+        cascade="all,delete",
+        single_parent=True,
+    )
+
+    dept_id = db.Column(
+        db.Integer, db.ForeignKey("departement.id"), index=True, nullable=False
+    )
+    departement = db.relationship("Departement", back_populates="etudiants")
     nom = db.Column(db.Text())
     prenom = db.Column(db.Text())
     nom_usuel = db.Column(db.Text())
@@ -53,7 +59,10 @@ class Identite(db.Model):
     dept_naissance = db.Column(db.Text())
     nationalite = db.Column(db.Text())
     statut = db.Column(db.Text())
-    boursier = db.Column(db.Boolean())  # True si boursier ('O' en ScoDoc7)
+    boursier = db.Column(
+        db.Boolean(), nullable=False, default=False, server_default="false"
+    )
+    "True si boursier"
     photo_filename = db.Column(db.Text())
     # Codes INE et NIP pas unique car le meme etud peut etre ds plusieurs dept
     code_nip = db.Column(db.Text(), index=True)
@@ -61,11 +70,37 @@ class Identite(db.Model):
     # Ancien id ScoDoc7 pour les migrations de bases anciennes
     # ne pas utiliser après migrate_scodoc7_dept_archives
     scodoc7_id = db.Column(db.Text(), nullable=True)
-    #
-    adresses = db.relationship("Adresse", lazy="dynamic", backref="etud")
+
+    # ----- Contraintes
+    __table_args__ = (
+        # Define a unique constraint on (dept_id, code_nip) when code_nip is not NULL
+        db.UniqueConstraint("dept_id", "code_nip", name="unique_dept_nip_except_null"),
+        db.Index(
+            "unique_dept_nip_except_null",
+            "dept_id",
+            "code_nip",
+            unique=True,
+            postgresql_where=(code_nip.isnot(None)),
+        ),
+        # Define a unique constraint on (dept_id, code_ine) when code_ine is not NULL
+        db.UniqueConstraint("dept_id", "code_ine", name="unique_dept_ine_except_null"),
+        db.Index(
+            "unique_dept_ine_except_null",
+            "dept_id",
+            "code_ine",
+            unique=True,
+            postgresql_where=(code_ine.isnot(None)),
+        ),
+        db.CheckConstraint("civilite IN ('M', 'F', 'X')"),
+        db.CheckConstraint("civilite_etat_civil IN ('M', 'F', 'X')"),
+    )
+    # ----- Relations
+    adresses = db.relationship(
+        "Adresse", back_populates="etud", cascade="all,delete", lazy="dynamic"
+    )
+
     billets = db.relationship("BilletAbsence", backref="etudiant", lazy="dynamic")
     #
-    admission = db.relationship("Admission", backref="identite", lazy="dynamic")
     dispense_ues = db.relationship(
         "DispenseUE",
         back_populates="etud",
@@ -125,8 +160,7 @@ class Identite(db.Model):
         copy = self.__class__(**d)
         db.session.add(copy)
         copy.adresses = [adr.clone() for adr in self.adresses]
-        for admission in self.admission:
-            copy.admission.append(admission.clone())
+        copy.admission = self.admission.clone()
         log(
             f"cloning etud <{self.id} {self.nom!r} {self.prenom!r}> in dept_id={new_dept_id}"
         )
@@ -157,11 +191,25 @@ class Identite(db.Model):
         return cls.query.filter_by(id=etudid).first_or_404()
 
     @classmethod
-    def create_etud(cls, **args):
+    def create_etud(cls, **args) -> "Identite":
         "Crée un étudiant, avec admission et adresse vides."
         etud: Identite = cls(**args)
         etud.adresses.append(Adresse(typeadresse="domicile"))
-        etud.admission.append(Admission())
+        etud.admission = Admission()
+        return etud
+
+    @classmethod
+    def create_from_dict(cls, data) -> "Identite":
+        """Crée un étudiant à partir d'un dict, avec admission et adresse vides.
+        (added to session but not flushed nor commited)
+        """
+        etud: Identite = super(cls, cls).create_from_dict(data)
+        if (data.get("admission_id", None) is None) and (
+            data.get("admission", None) is None
+        ):
+            etud.admission = Admission()
+        etud.adresses.append(Adresse(typeadresse="domicile"))
+        db.session.flush()
         return etud
 
     @property
@@ -263,7 +311,9 @@ class Identite(db.Model):
 
     @classmethod
     def convert_dict_fields(cls, args: dict) -> dict:
-        "Convert fields in the given dict. No other side effect"
+        """Convert fields in the given dict. No other side effect.
+        If required dept_id is not specified, set it to the current dept.
+        """
         fs_uppercase = {"nom", "prenom", "prenom_etat_civil"}
         fs_empty_stored_as_nulls = {
             "nom",
@@ -279,6 +329,8 @@ class Identite(db.Model):
             "code_ine",
         }
         args_dict = {}
+        if not "dept_id" in args:
+            args["dept_id"] = g.scodoc_dept_id
         for key, value in args.items():
             if hasattr(cls, key) and not isinstance(getattr(cls, key, None), property):
                 # compat scodoc7 (mauvaise idée de l'époque)
@@ -286,8 +338,10 @@ class Identite(db.Model):
                     value = None
                 if key in fs_uppercase and value:
                     value = value.upper()
-                if key == "civilite" or key == "civilite_etat_civil":
+                if key == "civilite":  # requis
                     value = input_civilite(value)
+                elif key == "civilite_etat_civil":
+                    value = input_civilite(value) if value else None
                 elif key == "boursier":
                     value = bool(value)
                 elif key == "date_naissance":
@@ -295,16 +349,6 @@ class Identite(db.Model):
                 args_dict[key] = value
         return args_dict
 
-    def from_dict(self, args: dict):
-        "update fields given in dict. Add to session but don't commit."
-        args_dict = Identite.convert_dict_fields(args)
-        args_dict.pop("id", None)
-        args_dict.pop("etudid", None)
-        for key, value in args_dict.items():
-            if hasattr(self, key):
-                setattr(self, key, value)
-        db.session.add(self)
-
     def to_dict_short(self) -> dict:
         """Les champs essentiels"""
         return {
@@ -325,17 +369,17 @@ class Identite(db.Model):
         """Représentation dictionnaire,
         compatible ScoDoc7 mais sans infos admission
         """
-        e = dict(self.__dict__)
-        e.pop("_sa_instance_state", None)
+        e_dict = self.__dict__.copy()  # dict(self.__dict__)
+        e_dict.pop("_sa_instance_state", None)
         # ScoDoc7 output_formators: (backward compat)
-        e["etudid"] = self.id
-        e["date_naissance"] = ndb.DateISOtoDMY(e["date_naissance"])
-        e["ne"] = self.e
-        e["nomprenom"] = self.nomprenom
+        e_dict["etudid"] = self.id
+        e_dict["date_naissance"] = ndb.DateISOtoDMY(e_dict["date_naissance"])
+        e_dict["ne"] = self.e
+        e_dict["nomprenom"] = self.nomprenom
         adresse = self.adresses.first()
         if adresse:
-            e.update(adresse.to_dict())
-        return {k: e[k] or "" for k in e}  # convert_null_outputs_to_empty
+            e_dict.update(adresse.to_dict())
+        return {k: v or "" for k, v in e_dict.items()}  # convert_null_outputs_to_empty
 
     def to_dict_bul(self, include_urls=True):
         """Infos exportées dans les bulletins
@@ -382,7 +426,7 @@ class Identite(db.Model):
         """Représentation dictionnaire pour export API, avec adresses et admission."""
         e = dict(self.__dict__)
         e.pop("_sa_instance_state", None)
-        admission = self.admission.first()
+        admission = self.admission
         e["admission"] = admission.to_dict() if admission is not None else None
         e["adresses"] = [adr.to_dict() for adr in self.adresses]
         e["dept_acronym"] = self.departement.acronym
@@ -648,11 +692,14 @@ def make_etud_args(
     return args
 
 
-def input_civilite(s):
+def input_civilite(s: str) -> str:
     """Converts external representation of civilite to internal:
     'M', 'F', or 'X' (and nothing else).
     Raises ScoValueError if conversion fails.
     """
+    if not isinstance(s, str):
+        breakpoint()
+        raise ScoValueError("valeur invalide pour la civilité (chaine attendue)")
     s = s.upper().strip()
     if s in ("M", "M.", "MR", "H"):
         return "M"
@@ -688,10 +735,10 @@ class Adresse(db.Model, models.ScoDocModel):
 
     id = db.Column(db.Integer, primary_key=True)
     adresse_id = db.synonym("id")
-    etudid = db.Column(
-        db.Integer,
-        db.ForeignKey("identite.id", ondelete="CASCADE"),
-    )
+    etudid = db.Column(db.Integer, db.ForeignKey("identite.id"), nullable=False)
+    # Relationship to Identite
+    etud = db.relationship("Identite", back_populates="adresses")
+
     email = db.Column(db.Text())  # mail institutionnel
     emailperso = db.Column(db.Text)  # email personnel (exterieur)
     domicile = db.Column(db.Text)
@@ -722,10 +769,13 @@ class Admission(db.Model, models.ScoDocModel):
 
     id = db.Column(db.Integer, primary_key=True)
     adm_id = db.synonym("id")
-    etudid = db.Column(
-        db.Integer,
-        db.ForeignKey("identite.id", ondelete="CASCADE"),
-    )
+    # obsoleted by migration 497ba81343f7_identite_admission.py:
+    # etudid = db.Column(
+    #     db.Integer,
+    #     db.ForeignKey("identite.id", ondelete="CASCADE"),
+    # )
+    etud = db.relationship("Identite", back_populates="admission", uselist=False)
+
     # Anciens champs de ScoDoc7, à revoir pour être plus générique et souple
     # notamment dans le cadre du bac 2021
     # de plus, certaines informations liées à APB ne sont plus disponibles
diff --git a/app/scodoc/TrivialFormulator.py b/app/scodoc/TrivialFormulator.py
index b0334ae78c9b2377f68dc167361285ce0ebe5577..6921d45aaf307638789ee06d793deee17a080d8d 100644
--- a/app/scodoc/TrivialFormulator.py
+++ b/app/scodoc/TrivialFormulator.py
@@ -584,14 +584,14 @@ class TF(object):
             elif input_type == "menu":
                 lem.append('<select name="%s" id="%s" %s>' % (field, wid, attribs))
                 labels = descr.get("labels", descr["allowed_values"])
-                for i in range(len(labels)):
-                    if str(descr["allowed_values"][i]) == str(values[field]):
+                allowed_values = list(descr["allowed_values"])
+                for i, label in enumerate(labels):
+                    if str(allowed_values[i]) == str(values[field]):
                         selected = "selected"
                     else:
                         selected = ""
                     lem.append(
-                        '<option value="%s" %s>%s</option>'
-                        % (descr["allowed_values"][i], selected, labels[i])
+                        f"""<option value="{allowed_values[i]}" {selected}>{label}</option>"""
                     )
                 lem.append("</select>")
             elif input_type == "checkbox" or input_type == "boolcheckbox":
diff --git a/app/scodoc/notesdb.py b/app/scodoc/notesdb.py
index 044d0dc612855f7c016fdf42abc383cb2ddd5fef..3aba1848cbfb0d104c79622e0016270e8211874f 100644
--- a/app/scodoc/notesdb.py
+++ b/app/scodoc/notesdb.py
@@ -457,8 +457,8 @@ def dictfilter(d, fields, filter_nulls=True):
 # --- Misc Tools
 
 
-def DateDMYtoISO(dmy: str, null_is_empty=False) -> str:  # XXX deprecated
-    """Convert date string from french format to ISO.
+def DateDMYtoISO(dmy: str, null_is_empty=False) -> str | None:  # XXX deprecated
+    """Convert date string from french format (or ISO) to ISO.
     If null_is_empty (default false), returns "" if no input.
     """
     if not dmy:
@@ -472,8 +472,11 @@ def DateDMYtoISO(dmy: str, null_is_empty=False) -> str:  # XXX deprecated
         raise ScoValueError(f'Date (j/m/a) invalide: "{dmy}"')
     try:
         dt = datetime.datetime.strptime(dmy, "%d/%m/%Y")
-    except ValueError as exc:
-        raise ScoValueError(f'Date (j/m/a) invalide: "{dmy}"') from exc
+    except ValueError:
+        try:
+            dt = datetime.datetime.fromisoformat(dmy)
+        except ValueError as exc:
+            raise ScoValueError(f'Date (j/m/a or iso) invalide: "{dmy}"') from exc
     return dt.date().isoformat()
 
 
diff --git a/app/scodoc/sco_etud.py b/app/scodoc/sco_etud.py
index 0c7d1a4d30193dce3080c02a9c3f088c6483d629..77d445ea46b5912da2f2588ec4aaa05ea07c763d 100644
--- a/app/scodoc/sco_etud.py
+++ b/app/scodoc/sco_etud.py
@@ -509,7 +509,6 @@ _admissionEditor = ndb.EditableTable(
     "adm_id",
     (
         "adm_id",
-        "etudid",
         "annee",
         "bac",
         "specialite",
@@ -556,33 +555,33 @@ admission_edit = _admissionEditor.edit
 # Edition simultanee de identite et admission
 class EtudIdentEditor(object):
     def create(self, cnx, args):
+        admission_id = admission_create(cnx, args)
+        args["admission_id"] = admission_id
         etudid = identite_create(cnx, args)
-        args["etudid"] = etudid
-        admission_create(cnx, args)
         return etudid
 
-    def list(self, *args, **kw):
-        R = identite_list(*args, **kw)
-        Ra = admission_list(*args, **kw)
-        # print len(R), len(Ra)
-        # merge: add admission fields to identite
-        A = {}
-        for r in Ra:
-            A[r["etudid"]] = r
+    def list(self, *args, **kw) -> list[dict]:
+        etuds_dict = identite_list(*args, **kw)
         res = []
-        for i in R:
-            res.append(i)
-            if i["etudid"] in A:
+        for etud_dict in etuds_dict:
+            res.append(etud_dict)
+            adms_dict = (
+                admission_list(args[0], args={"id": etud_dict["admission_id"]})
+                if etud_dict["admission_id"]
+                else []
+            )
+            if adms_dict:
                 # merge
-                res[-1].update(A[i["etudid"]])
+                adms_dict[0].pop("id", None)
+                adms_dict[0].pop("etudid", None)
+                res[-1] |= adms_dict[0]
             else:  # pas d'etudiant trouve
-                # print "*** pas d'info admission pour %s" % str(i)
                 void_adm = {
                     k: None
                     for k in _admissionEditor.dbfields
-                    if k != "etudid" and k != "adm_id"
+                    if k not in ("id", "etudid", "adm_id")
                 }
-                res[-1].update(void_adm)
+                res[-1] |= void_adm
         # tri par nom
         res.sort(key=itemgetter("nom", "prenom"))
         return res
@@ -638,7 +637,7 @@ def create_etud(cnx, args: dict = None):
     etud = Identite.create_etud(**args_dict)
     db.session.add(etud)
     db.session.commit()
-    admission = etud.admission.first()
+    admission = etud.admission
     admission.from_dict(args)
     db.session.add(admission)
     db.session.commit()
diff --git a/app/scodoc/sco_import_etuds.py b/app/scodoc/sco_import_etuds.py
index 0e554ae07fc792a74a384f3524002958e60594eb..fdd25af7e8b09e48c6ea84ce2fa529bc42f6918f 100644
--- a/app/scodoc/sco_import_etuds.py
+++ b/app/scodoc/sco_import_etuds.py
@@ -37,7 +37,7 @@ import time
 from flask import g, url_for
 
 from app import db, log
-from app.models import ScolarNews, GroupDescr
+from app.models import Identite, GroupDescr, ScolarNews
 from app.models.etudiants import input_civilite
 
 from app.scodoc.gen_tables import GenTable
@@ -327,20 +327,18 @@ def scolars_import_excel_file(
             values = {}
             fs = line
             # remove quotes
-            for i in range(len(fs)):
-                if fs[i] and (
-                    (fs[i][0] == '"' and fs[i][-1] == '"')
-                    or (fs[i][0] == "'" and fs[i][-1] == "'")
+            for i, field in enumerate(fs):
+                if field and (
+                    (field[0] == '"' and field[-1] == '"')
+                    or (field[0] == "'" and field[-1] == "'")
                 ):
-                    fs[i] = fs[i][1:-1]
-            for i in range(len(fs)):
-                val = fs[i].strip()
-                typ, table, an, descr, aliases = tuple(titles[titleslist[i]])
-                # log('field %s: %s %s %s %s'%(titleslist[i], table, typ, an, descr))
-                if not val and not an:
+                    fs[i] = field[1:-1]
+            for i, field in enumerate(fs):
+                val = field.strip()
+                typ, table, allow_nulls, descr, aliases = tuple(titles[titleslist[i]])
+                if not val and not allow_nulls:
                     raise ScoValueError(
-                        "line %d: null value not allowed in column %s"
-                        % (linenum, titleslist[i])
+                        f"line {linenum}: null value not allowed in column {titleslist[i]}"
                     )
                 if val == "":
                     val = None
@@ -349,11 +347,11 @@ def scolars_import_excel_file(
                         val = val.replace(",", ".")  # si virgule a la française
                         try:
                             val = float(val)
-                        except:
+                        except (ValueError, TypeError) as exc:
                             raise ScoValueError(
-                                "valeur nombre reel invalide (%s) sur line %d, colonne %s"
-                                % (val, linenum, titleslist[i])
-                            )
+                                f"""valeur nombre reel invalide ({
+                                    val}) sur ligne {linenum}, colonne {titleslist[i]}"""
+                            ) from exc
                     elif typ == "integer":
                         try:
                             # on doit accepter des valeurs comme "2006.0"
@@ -362,20 +360,22 @@ def scolars_import_excel_file(
                             if val % 1.0 > 1e-4:
                                 raise ValueError()
                             val = int(val)
-                        except:
+                        except (ValueError, TypeError) as exc:
                             raise ScoValueError(
-                                "valeur nombre entier invalide (%s) sur ligne %d, colonne %s"
-                                % (val, linenum, titleslist[i])
-                            )
-                # xxx Ad-hoc checks (should be in format description)
-                if titleslist[i].lower() == "sexe":
+                                f"""valeur nombre entier invalide ({
+                                    val}) sur ligne {linenum}, colonne {titleslist[i]}"""
+                            ) from exc
+                # Ad-hoc checks (should be in format description)
+                if titleslist[i].lower() == "civilite":
                     try:
                         val = input_civilite(val)
-                    except:
+                    except ScoValueError as exc:
                         raise ScoValueError(
-                            "valeur invalide pour 'SEXE' (doit etre 'M', 'F', ou 'MME', 'H', 'X' ou vide, mais pas '%s') ligne %d, colonne %s"
-                            % (val, linenum, titleslist[i])
-                        )
+                            f"""valeur invalide pour 'civilite' 
+                            (doit etre 'M', 'F', ou 'MME', 'H', 'X' mais pas '{
+                                val}') ligne {linenum}, colonne {titleslist[i]}"""
+                        ) from exc
+
                 # Excel date conversion:
                 if titleslist[i].lower() == "date_naissance":
                     if val:
@@ -383,7 +383,8 @@ def scolars_import_excel_file(
                             val = sco_excel.xldate_as_datetime(val)
                         except ValueError as exc:
                             raise ScoValueError(
-                                f"date invalide ({val}) sur ligne {linenum}, colonne {titleslist[i]}"
+                                f"""date invalide ({val}) sur ligne {
+                                    linenum}, colonne {titleslist[i]}"""
                             ) from exc
                 # INE
                 if (
@@ -392,8 +393,7 @@ def scolars_import_excel_file(
                     and not val
                 ):
                     raise ScoValueError(
-                        "Code INE manquant sur ligne %d, colonne %s"
-                        % (linenum, titleslist[i])
+                        "Code INE manquant sur ligne {linenum}, colonne {titleslist[i]}"
                     )
 
                 # --
@@ -422,7 +422,6 @@ def scolars_import_excel_file(
                     np_imported_homonyms += 1
                 # Insert in DB tables
                 _import_one_student(
-                    cnx,
                     formsemestre_id,
                     values,
                     GroupIdInferers,
@@ -521,7 +520,6 @@ def students_import_admission(
 
 
 def _import_one_student(
-    cnx,
     formsemestre_id,
     values,
     GroupIdInferers,
@@ -533,22 +531,22 @@ def _import_one_student(
     Import d'un étudiant et inscription dans le semestre.
     Return: id du semestre dans lequel il a été inscrit.
     """
-    log(
-        "scolars_import_excel_file: formsemestre_id=%s values=%s"
-        % (formsemestre_id, str(values))
-    )
+    log(f"scolars_import_excel_file: formsemestre_id={formsemestre_id} values={values}")
     # Identite
     args = values.copy()
-    etudid = sco_etud.identite_create(cnx, args)
-    created_etudids.append(etudid)
-    # Admissions
-    args["etudid"] = etudid
     args["annee"] = annee_courante
-    _ = sco_etud.admission_create(cnx, args)
+    etud: Identite = Identite.create_from_dict(args)
+    etud.admission.from_dict(args)
+    etudid = etud.id
+    created_etudids.append(etudid)
     # Adresse
     args["typeadresse"] = "domicile"
     args["description"] = "(infos admission)"
-    _ = sco_etud.adresse_create(cnx, args)
+    adresse = etud.adresses.first()
+    adresse.from_dict(args)
+    db.session.add(etud)
+    db.session.commit()
+
     # Inscription au semestre
     args["etat"] = scu.INSCRIT  # etat insc. semestre
     if formsemestre_id:
@@ -574,7 +572,7 @@ def _import_one_student(
     group_ids = list({}.fromkeys(group_ids).keys())  # uniq
     if None in group_ids:
         raise ScoValueError(
-            "groupe invalide sur la ligne %d (groupe %s)" % (linenum, groupes)
+            f"groupe invalide sur la ligne {linenum} (groupe {groupes})"
         )
 
     do_formsemestre_inscription_with_modules(
@@ -605,16 +603,18 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
     étant ignorés).
 
     On tolère plusieurs variantes pour chaque nom de colonne (ici aussi, la casse, les espaces
-    et les caractères spéciaux sont ignorés. Ainsi, la colonne "Prénom:" sera considéré comme "prenom".
+    et les caractères spéciaux sont ignorés.
+    Ainsi, la colonne "Prénom:" sera considéré comme "prenom".
 
-    Le parametre type_admission remplace les valeurs vides (dans la base ET dans le fichier importé) du champ type_admission.
+    Le parametre type_admission remplace les valeurs vides (dans la base ET
+    dans le fichier importé) du champ type_admission.
     Si une valeur existe ou est présente dans le fichier importé, ce paramètre est ignoré.
 
     TODO:
     - choix onglet du classeur
     """
 
-    log("scolars_import_admission: formsemestre_id=%s" % formsemestre_id)
+    log(f"scolars_import_admission: formsemestre_id={formsemestre_id}")
     members = sco_groups.get_group_members(
         sco_groups.get_default_group(formsemestre_id)
     )
@@ -670,7 +670,7 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
             diag.append(msg)
         else:
             etud = etuds_by_nomprenom[(nom, prenom)]
-            cur_adm = sco_etud.admission_list(cnx, args={"etudid": etud["etudid"]})[0]
+            cur_adm = sco_etud.admission_list(cnx, args={"id": etud["admission_id"]})[0]
             # peuple les champs presents dans le tableau
             args = {}
             for idx, field in fields.items():
@@ -680,8 +680,8 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
                         val = convertor(line[idx])
                     except ValueError as exc:
                         raise ScoFormatError(
-                            'scolars_import_admission: valeur invalide, ligne %d colonne %s: "%s"'
-                            % (nline, field_name, line[idx]),
+                            f"""scolars_import_admission: valeur invalide, ligne {
+                                nline} colonne {field_name}: '{line[idx]}'""",
                             dest_url=url_for(
                                 "scolar.form_students_import_infos_admissions",
                                 scodoc_dept=g.scodoc_dept,
@@ -732,10 +732,10 @@ def scolars_import_admission(datafile, formsemestre_id=None, type_admission=None
                             )
 
                 #
-                diag.append("import de %s" % (etud["nomprenom"]))
+                diag.append(f"import de {etud['nomprenom']}")
                 n_import += 1
         nline += 1
-    diag.append("%d lignes importées" % n_import)
+    diag.append(f"{n_import} lignes importées")
     if n_import > 0:
         sco_cache.invalidate_formsemestre(formsemestre_id=formsemestre_id)
     return diag
diff --git a/app/scodoc/sco_prepajury.py b/app/scodoc/sco_prepajury.py
index ab54df69b207b005669fe064dd95c864e092f17a..bef329232fcda1079e81fb755029b39f61ab69d9 100644
--- a/app/scodoc/sco_prepajury.py
+++ b/app/scodoc/sco_prepajury.py
@@ -57,7 +57,7 @@ def feuille_preparation_jury(formsemestre_id):
     """
     formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
     nt: NotesTableCompat = res_sem.load_formsemestre_results(formsemestre)
-    etuds: Identite = nt.get_inscrits(order_by="moy")  # tri par moy gen
+    etuds = nt.get_inscrits(order_by="moy")  # tri par moy gen
     sem = sco_formsemestre.get_formsemestre(formsemestre_id)
 
     etud_groups = sco_groups.formsemestre_get_etud_groupnames(formsemestre_id)
@@ -240,7 +240,6 @@ def feuille_preparation_jury(formsemestre_id):
             cells.append(sheet.make_cell(etud.code_nip))
         if sco_preferences.get_preference("prepa_jury_ine"):
             cells.append(sheet.make_cell(etud.code_ine))
-        admission = etud.admission.first()
         cells += sheet.make_row(
             [
                 etud.id,
@@ -248,9 +247,9 @@ def feuille_preparation_jury(formsemestre_id):
                 sco_etud.format_nom(etud.nom),
                 sco_etud.format_prenom(etud.prenom),
                 etud.date_naissance,
-                admission.bac,
-                admission.specialite,
-                admission.classement,
+                etud.admission.bac if etud.admission else "",
+                etud.admission.specialite if etud.admission else "",
+                etud.admission.classement if etud.admission else "",
                 parcours[etud.id],
                 groupestd[etud.id],
             ]
diff --git a/app/scodoc/sco_report_but.py b/app/scodoc/sco_report_but.py
index cb216d4eb2ef971c008aced4d659127da25ae543..f44cdde6c4612367825a03ecda452322fee6a7a1 100644
--- a/app/scodoc/sco_report_but.py
+++ b/app/scodoc/sco_report_but.py
@@ -192,7 +192,7 @@ def _formsemestre_inscriptions_by_bac(formsemestre: FormSemestre) -> defaultdict
     "liste d'inscriptions, par type de bac"
     inscriptions_by_bac = defaultdict(list)  # bac : etuds
     for inscr in formsemestre.inscriptions:
-        adm = inscr.etud.admission.first()
+        adm = inscr.etud.admission
         bac = adm.get_bac().abbrev() if adm else "?"
         inscriptions_by_bac[bac].append(inscr)
     return inscriptions_by_bac
diff --git a/app/scodoc/sco_synchro_etuds.py b/app/scodoc/sco_synchro_etuds.py
index 02af0f712a69b9435c7c5ec63380eb436c439255..10c9662a7e00c48f026ee9eeb48ae9c12e483c1c 100644
--- a/app/scodoc/sco_synchro_etuds.py
+++ b/app/scodoc/sco_synchro_etuds.py
@@ -34,8 +34,8 @@ from operator import itemgetter
 from flask import g, url_for
 from flask_login import current_user
 
-from app import log
-from app.models import ScolarNews
+from app import db, log
+from app.models import Admission, Adresse, Identite, ScolarNews
 
 import app.scodoc.sco_utils as scu
 import app.scodoc.notesdb as ndb
@@ -592,7 +592,8 @@ def gender2civilite(gender):
     return "X"  # "X" en général n'est pas affiché, donc bon choix si invalide
 
 
-def get_opt_str(etud, k):
+def get_opt_str(etud: dict, k) -> str | None:
+    "etud[k].strip() ou None"
     v = etud.get(k, None)
     if not v:
         return v
@@ -611,7 +612,7 @@ def get_annee_naissance(ddmmyyyyy: str) -> int:
 
 def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident):
     """Inscrit les etudiants Apogee dans ce semestre."""
-    log("do_import_etuds_from_portal: a_importer=%s" % a_importer)
+    log(f"do_import_etuds_from_portal: a_importer={a_importer}")
     if not a_importer:
         return
     cnx = ndb.GetDBConnexion()
@@ -619,51 +620,52 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident):
 
     try:  # --- begin DB transaction
         for key in a_importer:
-            etud = etudsapo_ident[
-                key
-            ]  # on a ici toutes les infos renvoyées par le portail
+            etud_portal: dict = etudsapo_ident[key]
+            # -> toutes les infos renvoyées par le portail
 
             # Traduit les infos portail en infos pour ScoDoc:
-            address = etud.get("address", "").strip()
+            address = etud_portal.get("address", "").strip()
             if address[-2:] == "\\n":  # certains champs se terminent par \n
                 address = address[:-2]
 
             args = {
-                "code_nip": etud["nip"],
-                "nom": etud["nom"].strip(),
-                "prenom": etud["prenom"].strip(),
+                "code_nip": etud_portal["nip"],
+                "nom": etud_portal["nom"].strip(),
+                "prenom": etud_portal["prenom"].strip(),
                 # Les champs suivants sont facultatifs (pas toujours renvoyés par le portail)
-                "code_ine": etud.get("ine", "").strip(),
-                "civilite": gender2civilite(etud["gender"].strip()),
-                "etape": etud.get("etape", None),
-                "email": etud.get("mail", "").strip(),
-                "emailperso": etud.get("mailperso", "").strip(),
-                "date_naissance": etud.get("naissance", "").strip(),
-                "lieu_naissance": etud.get("ville_naissance", "").strip(),
-                "dept_naissance": etud.get("code_dep_naissance", "").strip(),
+                "code_ine": etud_portal.get("ine", "").strip(),
+                "civilite": gender2civilite(etud_portal["gender"].strip()),
+                "etape": etud_portal.get("etape", None),
+                "email": etud_portal.get("mail", "").strip(),
+                "emailperso": etud_portal.get("mailperso", "").strip(),
+                "date_naissance": etud_portal.get("naissance", "").strip(),
+                "lieu_naissance": etud_portal.get("ville_naissance", "").strip(),
+                "dept_naissance": etud_portal.get("code_dep_naissance", "").strip(),
                 "domicile": address,
-                "codepostaldomicile": etud.get("postalcode", "").strip(),
-                "villedomicile": etud.get("city", "").strip(),
-                "paysdomicile": etud.get("country", "").strip(),
-                "telephone": etud.get("phone", "").strip(),
+                "codepostaldomicile": etud_portal.get("postalcode", "").strip(),
+                "villedomicile": etud_portal.get("city", "").strip(),
+                "paysdomicile": etud_portal.get("country", "").strip(),
+                "telephone": etud_portal.get("phone", "").strip(),
                 "typeadresse": "domicile",
-                "boursier": etud.get("bourse", None),
+                "boursier": etud_portal.get("bourse", None),
                 "description": "infos portail",
             }
 
             # Identite
-            args["etudid"] = sco_etud.identite_create(cnx, args)
-            created_etudids.append(args["etudid"])
-            # Admissions
-            do_import_etud_admission(cnx, args["etudid"], etud)
-
+            etud: Identite = Identite.create_from_dict(args)
+            db.session.flush()
+            created_etudids.append(etud.id)
             # Adresse
-            sco_etud.adresse_create(cnx, args)
+            adresse = etud.adresses.first()
+            adresse.from_dict(args)
+
+            # Admissions
+            do_import_etud_admission(etud, etud_portal)
 
             # Inscription au semestre
             sco_formsemestre_inscriptions.do_formsemestre_inscription_with_modules(
                 sem["formsemestre_id"],
-                args["etudid"],
+                etud.id,
                 etat=scu.INSCRIT,
                 etape=args["etape"],
                 method="synchro_apogee",
@@ -713,53 +715,32 @@ def do_import_etuds_from_portal(sem, a_importer, etudsapo_ident):
     )
 
 
-def do_import_etud_admission(
-    cnx, etudid, etud, import_naissance=False, import_identite=False
-):
+def do_import_etud_admission(etud: Identite, etud_data: dict, import_identite=False):
     """Importe les donnees admission pour cet etud.
-    etud est un dictionnaire traduit du XML portail
+    etud_data est un dictionnaire traduit du XML portail
     """
     annee_courante = time.localtime()[0]
-    serie_bac, spe_bac = get_bac(etud)
+    serie_bac, spe_bac = _get_bac(etud_data)
     # Les champs n'ont pas les mêmes noms dans Apogee et dans ScoDoc:
     args = {
-        "etudid": etudid,
-        "annee": get_opt_str(etud, "inscription") or annee_courante,
+        "annee": get_opt_str(etud_data, "inscription") or annee_courante,
         "bac": serie_bac,
         "specialite": spe_bac,
-        "annee_bac": get_opt_str(etud, "anneebac"),
-        "codelycee": get_opt_str(etud, "lycee"),
-        "nomlycee": get_opt_str(etud, "nom_lycee"),
-        "villelycee": get_opt_str(etud, "ville_lycee"),
-        "codepostallycee": get_opt_str(etud, "codepostal_lycee"),
-        "boursier": get_opt_str(etud, "bourse"),
+        "annee_bac": get_opt_str(etud_data, "anneebac"),
+        "codelycee": get_opt_str(etud_data, "lycee"),
+        "nomlycee": get_opt_str(etud_data, "nom_lycee"),
+        "villelycee": get_opt_str(etud_data, "ville_lycee"),
+        "codepostallycee": get_opt_str(etud_data, "codepostal_lycee"),
+        "boursier": get_opt_str(etud_data, "bourse"),
     }
-    # log("do_import_etud_admission: etud=%s" % pprint.pformat(etud))
-    adm_list = sco_etud.admission_list(cnx, args={"etudid": etudid})
-    if not adm_list:
-        sco_etud.admission_create(cnx, args)  # -> adm_id
-    else:
-        # existing data: merge
-        adm_info = adm_list[0]
-        if get_opt_str(etud, "inscription"):
-            adm_info["annee"] = args["annee"]
-        keys = list(args.keys())
-        for k in keys:
-            if not args[k]:
-                del args[k]
-        adm_info.update(args)
-        sco_etud.admission_edit(cnx, adm_info)
-    # Traite cas particulier de la date de naissance pour anciens
-    # etudiants IUTV
-    if import_naissance and "naissance" in etud:
-        date_naissance = etud["naissance"].strip()
-        if date_naissance:
-            sco_etud.identite_edit_nocheck(
-                cnx, {"etudid": etudid, "date_naissance": date_naissance}
-            )
+    if etud.admission is None:
+        etud.admission = Admission()
+    args = {k: v for k, v in args.items() if v not in ("", None)}
+    etud.admission.from_dict(args)
+
     # Reimport des identités
     if import_identite:
-        args = {"etudid": etudid}
+        args = {}
         # Les champs n'ont pas les mêmes noms dans Apogee et dans ScoDoc:
         fields_apo_sco = [
             ("naissance", "date_naissance"),
@@ -771,18 +752,21 @@ def do_import_etud_admission(
             ("bourse", "boursier"),
         ]
         for apo_field, sco_field in fields_apo_sco:
-            x = etud.get(apo_field, "").strip()
+            x = etud_data.get(apo_field, "").strip()
             if x:
                 args[sco_field] = x
         # Champs spécifiques:
-        civilite = gender2civilite(etud["gender"].strip())
+        civilite = gender2civilite(etud_data["gender"].strip())
         if civilite:
             args["civilite"] = civilite
 
-        sco_etud.identite_edit_nocheck(cnx, args)
+        etud.from_dict(args)
+    db.session.add(etud)
+    db.session.commit()
+    db.session.refresh(etud)
 
 
-def get_bac(etud):
+def _get_bac(etud) -> tuple[str | None, str | None]:
     bac = get_opt_str(etud, "bac")
     if not bac:
         return None, None
@@ -820,14 +804,10 @@ def formsemestre_import_etud_admission(
     ins = sco_formsemestre_inscriptions.do_formsemestre_inscription_list(
         {"formsemestre_id": formsemestre_id}
     )
-    log(
-        "formsemestre_import_etud_admission: %s (%d etuds)"
-        % (formsemestre_id, len(ins))
-    )
+    log(f"formsemestre_import_etud_admission: {formsemestre_id} ({len(ins)} etuds)")
     no_nip = []  # liste d'etudids sans code NIP
     unknowns = []  # etudiants avec NIP mais inconnus du portail
-    changed_mails = []  # modification d'adresse mails
-    cnx = ndb.GetDBConnexion()
+    changed_mails: list[tuple[Identite, str]] = []  # modification d'adresse mails
 
     # Essaie de recuperer les etudiants des étapes, car
     # la requete get_inscrits_etape est en général beaucoup plus
@@ -844,49 +824,47 @@ def formsemestre_import_etud_admission(
 
     for i in ins:
         etudid = i["etudid"]
-        info = sco_etud.get_etud_info(etudid=etudid, filled=True)[0]
-        code_nip = info["code_nip"]
+        etud: Identite = Identite.query.get_or_404(etudid)
+        code_nip = etud.code_nip
         if not code_nip:
             no_nip.append(etudid)
         else:
-            etud = apo_etuds.get(code_nip)
-            if not etud:
+            data_apo = apo_etuds.get(code_nip)
+            if not data_apo:
                 # pas vu dans les etudiants de l'étape, tente en individuel
-                etud = sco_portal_apogee.get_etud_apogee(code_nip)
-            if etud:
-                update_etape_formsemestre_inscription(i, etud)
+                data_apo = sco_portal_apogee.get_etud_apogee(code_nip)
+            if data_apo:
+                update_etape_formsemestre_inscription(i, data_apo)
                 do_import_etud_admission(
-                    cnx,
-                    etudid,
                     etud,
-                    import_naissance=True,
+                    data_apo,
                     import_identite=import_identite,
                 )
-                apo_emailperso = etud.get("mailperso", "")
-                if info["emailperso"] and not apo_emailperso:
-                    apo_emailperso = info["emailperso"]
+                adresse = etud.adresses.first()
+                if adresse is None:
+                    adresse = Adresse()
+                    etud.adresses.append(adresse)
+                apo_emailperso = data_apo.get("mailperso", "")
+                if adresse.emailperso and not apo_emailperso:
+                    apo_emailperso = adresse.emailperso
                 if import_email:
-                    if not "mail" in etud:
+                    if not "mail" in data_apo:
                         raise ScoValueError(
                             "la réponse portail n'a pas le champs requis 'mail'"
                         )
                     if (
-                        info["email"] != etud["mail"]
-                        or info["emailperso"] != apo_emailperso
+                        adresse.email != data_apo["mail"]
+                        or adresse.emailperso != apo_emailperso
                     ):
-                        sco_etud.adresse_edit(
-                            cnx,
-                            args={
-                                "etudid": etudid,
-                                "adresse_id": info["adresse_id"],
-                                "email": etud["mail"],
-                                "emailperso": apo_emailperso,
-                            },
-                        )
+                        old_mail = adresse.email
+                        adresse.email = data_apo["mail"]
+                        adresse.emailperso = apo_emailperso
+                        db.session.add(adresse)
                         # notifie seulement les changements d'adresse mail institutionnelle
-                        if info["email"] != etud["mail"]:
-                            changed_mails.append((info, etud["mail"]))
+                        if adresse.email != data_apo["mail"]:
+                            changed_mails.append((etud, old_mail))
             else:
                 unknowns.append(code_nip)
+    db.session.commit()
     sco_cache.invalidate_formsemestre(formsemestre_id=sem["formsemestre_id"])
     return no_nip, unknowns, changed_mails
diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py
index 31ff703ed48da68c1990857b9c80daf1c51f9229..c567b8d3c24404f1879cd8804cdaf342e10eecc8 100644
--- a/app/scodoc/sco_utils.py
+++ b/app/scodoc/sco_utils.py
@@ -637,7 +637,15 @@ def get_mime_suffix(format_code: str) -> tuple[str, str]:
 # Différents types de voies d'admission:
 # (stocké en texte libre dans la base, mais saisie par menus pour harmoniser)
 TYPE_ADMISSION_DEFAULT = "Inconnue"
-TYPES_ADMISSION = (TYPE_ADMISSION_DEFAULT, "APB", "APB-PC", "CEF", "Direct")
+TYPES_ADMISSION = (
+    TYPE_ADMISSION_DEFAULT,
+    "Parcoursup",
+    "Transfert",
+    "APB",
+    "APB-PC",
+    "CEF",
+    "Direct",
+)
 
 BULLETINS_VERSIONS = {
     "short": "Version courte",
diff --git a/app/tables/recap.py b/app/tables/recap.py
index 2b5926269ebb4cea37b5a0dab695f4e4a2d63c40..df9247bcfe1d5257a478dd3767e1f001c5b20311 100644
--- a/app/tables/recap.py
+++ b/app/tables/recap.py
@@ -481,7 +481,7 @@ class TableRecap(tb.Table):
 
         for row in self.rows:
             etud = row.etud
-            admission = etud.admission.first()
+            admission = etud.admission
             if admission:
                 first = True
                 for cid, title in fields.items():
diff --git a/app/views/scolar.py b/app/views/scolar.py
index e7ac1a4a60a28d7a69baf0468006ca73260691dd..fe97332d35628a408ffed612e9491dbb057f6b5e 100644
--- a/app/views/scolar.py
+++ b/app/views/scolar.py
@@ -1768,14 +1768,12 @@ def _etudident_create_or_edit_form(edit):
         else:
             # modif d'un etudiant
             etud_o.from_dict(tf[2])
-            db.session.add(etud_o)
-            admission = etud_o.admission.first()
+            admission = etud_o.admission
             if admission is None:
                 # ? ne devrait pas arriver mais...
                 admission = Admission()
-                admission.etudid = etud_o.id
+                etud_o.admission = admission
             admission.from_dict(tf[2])
-            db.session.add(admission)
             db.session.commit()
 
             etud = sco_etud.etudident_list(cnx, {"etudid": etud_o.id})[0]
@@ -2200,7 +2198,7 @@ Les champs avec un astérisque (*) doivent être présents (nulls non autorisés
 <tr><td><b>Attribut</b></td><td><b>Type</b></td><td><b>Description</b></td></tr>"""
     ]
     for t in sco_import_etuds.sco_import_format(
-        with_codesemestre=(formsemestre_id == None)
+        with_codesemestre=(formsemestre_id is None)
     ):
         if int(t[3]):
             ast = ""
@@ -2291,37 +2289,33 @@ def form_students_import_infos_admissions(formsemestre_id=None):
     # On a le droit d'importer:
     H = [
         html_sco_header.sco_header(page_title="Import données admissions Parcoursup"),
-        """<h2 class="formsemestre">Téléchargement des informations sur l'admission des étudiants depuis feuilles import Parcoursup</h2>
+        f"""<h2 class="formsemestre">Téléchargement des informations sur l'admission des étudiants depuis feuilles import Parcoursup</h2>
             <div style="color: red">
             <p>A utiliser pour renseigner les informations sur l'origine des étudiants (lycées, bac, etc). Ces informations sont facultatives mais souvent utiles pour mieux connaitre les étudiants et aussi pour effectuer des statistiques (résultats suivant le type de bac...). Les données sont affichées sur les fiches individuelles des étudiants.</p>
             </div>
             <p>
             Importer ici la feuille excel utilisée pour envoyer le classement Parcoursup. 
             Seuls les étudiants actuellement inscrits dans ce semestre ScoDoc seront affectés, 
-            les autres lignes de la feuille seront ignorées. Et seules les colonnes intéressant ScoDoc 
+            les autres lignes de la feuille seront ignorées. 
+            Et seules les colonnes intéressant ScoDoc 
             seront importées: il est inutile d'éliminer les autres.
             <br>
-            <em>Seules les données "admission" seront modifiées (et pas l'identité de l'étudiant).</em>
+            <em>Seules les données "admission" seront modifiées 
+            (et pas l'identité de l'étudiant).</em>
             <br>
             <em>Les colonnes "nom" et "prenom" sont requises, ou bien une colonne "etudid".</em>
             </p>
             <p>
-            Avant d'importer vos données, il est recommandé d'enregistrer les informations actuelles:
-            <a href="import_generate_admission_sample?formsemestre_id=%(formsemestre_id)s">exporter les données actuelles de ScoDoc</a> (ce fichier peut être ré-importé après d'éventuelles modifications)
+            Avant d'importer vos données, il est recommandé d'enregistrer 
+            les informations actuelles:
+            <a class="stdlink" href="{
+                url_for("scolar.import_generate_admission_sample", 
+                        scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre_id)
+                }">exporter les données actuelles de ScoDoc</a> 
+                (ce fichier peut être ré-importé après d'éventuelles modifications)
             </p>
-            """
-        % {"formsemestre_id": formsemestre_id},
-    ]  # '
-
-    type_admission_list = (
-        "Autre",
-        "Parcoursup",
-        "Parcoursup PC",
-        "APB",
-        "APB PC",
-        "CEF",
-        "Direct",
-    )
+            """,
+    ]
 
     tf = TrivialFormulator(
         request.base_url,
@@ -2337,7 +2331,7 @@ def form_students_import_infos_admissions(formsemestre_id=None):
                     "title": "Type d'admission",
                     "explanation": "sera attribué aux étudiants modifiés par cet import n'ayant pas déjà un type",
                     "input_type": "menu",
-                    "allowed_values": type_admission_list,
+                    "allowed_values": scu.TYPES_ADMISSION,
                 },
             ),
             ("formsemestre_id", {"input_type": "hidden"}),
@@ -2346,7 +2340,8 @@ def form_students_import_infos_admissions(formsemestre_id=None):
     )
 
     help_text = (
-        """<p>Les colonnes importables par cette fonction sont indiquées dans la table ci-dessous. 
+        """<p>Les colonnes importables par cette fonction sont indiquées 
+        dans la table ci-dessous. 
     Seule la première feuille du classeur sera utilisée.
     <div id="adm_table_description_format">
     """
@@ -2375,7 +2370,7 @@ def form_students_import_infos_admissions(formsemestre_id=None):
 @permission_required(Permission.EtudChangeAdr)
 @scodoc7func
 def formsemestre_import_etud_admission(formsemestre_id, import_email=True):
-    """Reimporte donnees admissions par synchro Portail Apogée"""
+    """Ré-importe donnees admissions par synchro Portail Apogée"""
     (
         no_nip,
         unknowns,
@@ -2384,7 +2379,7 @@ def formsemestre_import_etud_admission(formsemestre_id, import_email=True):
         formsemestre_id, import_identite=True, import_email=import_email
     )
     H = [
-        html_sco_header.html_sem_header("Reimport données admission"),
+        html_sco_header.html_sem_header("Ré-import données admission"),
         "<h3>Opération effectuée</h3>",
     ]
     if no_nip:
@@ -2396,12 +2391,12 @@ def formsemestre_import_etud_admission(formsemestre_id, import_email=True):
             + "</p>"
         )
     if changed_mails:
-        H.append("<h3>Adresses mails modifiées:</h3>")
-        for info, new_mail in changed_mails:
+        H.append("<h3>Adresses mails modifiées:</h3><ul>")
+        for etud, old_mail in changed_mails:
             H.append(
-                "%s: <tt>%s</tt> devient <tt>%s</tt><br>"
-                % (info["nom"], info["email"], new_mail)
+                f"""<li>{etud.nom}: <tt>{old_mail}</tt> devient <tt>{etud.email}</tt></li>"""
             )
+        H.append("</ul>")
     return "\n".join(H) + html_sco_header.sco_footer()
 
 
diff --git a/migrations/versions/497ba81343f7_identite_admission.py b/migrations/versions/497ba81343f7_identite_admission.py
new file mode 100644
index 0000000000000000000000000000000000000000..73b97ec1a92b67ce2c52dbaa37eb8003cb751eee
--- /dev/null
+++ b/migrations/versions/497ba81343f7_identite_admission.py
@@ -0,0 +1,125 @@
+"""identite_admission
+
+Revision ID: 497ba81343f7
+Revises: 5c44d0d215ca
+Create Date: 2023-10-14 10:09:02.330634
+
+Diverses amlioration du modèle Identite:
+- boursier non null
+- departement non null
+- admission : 1 seule admission (one-to-one)
+- adresse: etudid non null.
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.orm import sessionmaker  # added by ev
+
+
+# revision identifiers, used by Alembic.
+revision = "497ba81343f7"
+down_revision = "5c44d0d215ca"
+branch_labels = None
+depends_on = None
+
+Session = sessionmaker()
+
+
+def upgrade():
+    bind = op.get_bind()
+    session = Session(bind=bind)
+    # Enleve les éventuels nulls de boursier
+    session.execute(
+        sa.text(
+            """
+            UPDATE identite SET boursier = false WHERE boursier IS NULL;
+            """
+        )
+    )
+    # Enleve les éventuelles adresses orphelines:
+    session.execute(
+        sa.text(
+            """
+            DELETE FROM adresse WHERE etudid IS NULL;
+            """
+        )
+    )
+    # Affecte arbitrairement les éventuels étudiants sans département au 1er
+    # (il ne devrait pas y en avoir, sauf essais manuels ou bugs)
+    session.execute(
+        sa.text(
+            """
+            UPDATE identite SET dept_id = (
+                SELECT MIN(id)
+                FROM departement
+            ) WHERE dept_id IS NULL;
+            """
+        )
+    )
+
+    with op.batch_alter_table("identite", schema=None) as batch_op:
+        batch_op.add_column(sa.Column("admission_id", sa.Integer(), nullable=True))
+        batch_op.alter_column("boursier", existing_type=sa.BOOLEAN(), nullable=False)
+        batch_op.alter_column("dept_id", existing_type=sa.Integer(), nullable=False)
+        batch_op.create_foreign_key(
+            "admissions_etudid_fkey", "admissions", ["admission_id"], ["id"]
+        )
+        batch_op.drop_constraint("identite_dept_id_code_ine_key", type_="unique")
+        batch_op.drop_constraint("identite_dept_id_code_nip_key", type_="unique")
+
+    with op.batch_alter_table("adresse", schema=None) as batch_op:
+        batch_op.alter_column("etudid", existing_type=sa.Integer(), nullable=False)
+        batch_op.drop_constraint("adresse_etudid_fkey", type_="foreignkey")
+        batch_op.create_foreign_key(
+            "adresse_etudid_fkey", "identite", ["etudid"], ["id"]
+        )
+
+    # Elimine eventuels duplicats dans Admission
+    session.execute(
+        sa.text(
+            """
+            DELETE FROM admissions
+            WHERE id NOT IN (
+                SELECT MIN(id)
+                FROM admissions
+                GROUP BY etudid
+            );
+            """
+        )
+    )
+    # Copie id
+    session.execute(
+        sa.text(
+            """
+            UPDATE identite SET admission_id = admissions.id 
+            FROM admissions WHERE admissions.etudid = identite.id;
+            """
+        )
+    )
+
+    with op.batch_alter_table("admissions", schema=None) as batch_op:
+        batch_op.drop_constraint("admissions_etudid_fkey", type_="foreignkey")
+        # laisse l'ancienne colonne pour downgrade (tests)
+        # batch_op.drop_column('etudid')
+
+
+def downgrade():
+    with op.batch_alter_table("identite", schema=None) as batch_op:
+        batch_op.drop_constraint("admissions_etudid_fkey", type_="foreignkey")
+        batch_op.alter_column("boursier", existing_type=sa.BOOLEAN(), nullable=True)
+        batch_op.alter_column("dept_id", existing_type=sa.Integer(), nullable=True)
+        batch_op.drop_column("admission_id")
+
+    with op.batch_alter_table("admissions", schema=None) as batch_op:
+        # batch_op.add_column(
+        #    sa.Column("etudid", sa.INTEGER(), autoincrement=False, nullable=True)
+        # )
+        batch_op.create_foreign_key(
+            "admissions_etudid_fkey", "identite", ["etudid"], ["id"], ondelete="CASCADE"
+        )
+
+    with op.batch_alter_table("adresse", schema=None) as batch_op:
+        batch_op.drop_constraint("adresse_etudid_fkey", type_="foreignkey")
+        batch_op.create_foreign_key(
+            "adresse_etudid_fkey", "identite", ["etudid"], ["id"], ondelete="CASCADE"
+        )
+        batch_op.alter_column("etudid", existing_type=sa.INTEGER(), nullable=True)
diff --git a/tests/unit/sco_fake_gen.py b/tests/unit/sco_fake_gen.py
index 2e2e78c386af86cba5dec37348779869d1f60388..1f2dff8bdb53d1f7021aba04913c88fb9e341f7c 100644
--- a/tests/unit/sco_fake_gen.py
+++ b/tests/unit/sco_fake_gen.py
@@ -21,6 +21,7 @@ from app.models import (
     Evaluation,
     Formation,
     FormationModalite,
+    Identite,
     Matiere,
     ModuleImpl,
 )
@@ -29,8 +30,6 @@ from app.scodoc import codes_cursus
 from app.scodoc import sco_edit_matiere
 from app.scodoc import sco_edit_module
 from app.scodoc import sco_edit_ue
-from app.scodoc import sco_etud
-from app.scodoc import sco_evaluation_db
 from app.scodoc import sco_formsemestre
 from app.scodoc import sco_formsemestre_inscriptions
 from app.scodoc import sco_formsemestre_validation
@@ -83,12 +82,14 @@ def logging_meth(func):
 class ScoFake(object):
     """Helper for ScoDoc tests"""
 
-    def __init__(self, verbose=True):
+    def __init__(self, verbose=True, dept=None):
         self.verbose = verbose
         self.default_user = User.query.filter_by(user_name="bach").first()
         if not self.default_user:
             raise ScoValueError('User test "bach" not found !')
-        self.dept = Departement.query.filter_by(acronym=TestConfig.DEPT_TEST).first()
+        self.dept = (
+            dept or Departement.query.filter_by(acronym=TestConfig.DEPT_TEST).first()
+        )
         assert self.dept
 
     def log(self, msg):
@@ -116,10 +117,10 @@ class ScoFake(object):
     def create_etud(
         self,
         cnx=None,
-        code_nip="",
+        code_nip=None,
         nom="",
         prenom="",
-        code_ine="",
+        code_ine=None,
         civilite="",
         etape="TST1",
         email="test@localhost",
@@ -135,10 +136,8 @@ class ScoFake(object):
         typeadresse="domicile",
         boursier=None,
         description="etudiant test",
-    ):
+    ) -> dict:
         """Crée un étudiant"""
-        if not cnx:
-            cnx = ndb.GetDBConnexion()
         if code_nip == "":
             code_nip = str(random.randint(10000, 99999))
         if not civilite or not nom or not prenom:
@@ -149,10 +148,13 @@ class ScoFake(object):
                 nom = r_nom
             if not prenom:
                 prenom = r_prenom
-        etud = sco_etud.create_etud(cnx, args=locals())
+        dept_id = self.dept.id  # pylint: disable=possibly-unused-variable
         inscription = "2020"  # pylint: disable=possibly-unused-variable
-        sco_synchro_etuds.do_import_etud_admission(cnx, etud["etudid"], locals())
-        return etud
+        args = locals()
+        etud = Identite.create_from_dict(args)
+        db.session.commit()
+        sco_synchro_etuds.do_import_etud_admission(etud, args)
+        return etud.to_dict_scodoc7()
 
     @logging_meth
     def create_formation(
diff --git a/tests/unit/test_assiduites.py b/tests/unit/test_assiduites.py
index 3bc5c51a3c9c43573562a188eee8f009bdf687a6..e763597aa670a53a8503cced3374b21f82f5cf66 100644
--- a/tests/unit/test_assiduites.py
+++ b/tests/unit/test_assiduites.py
@@ -86,7 +86,6 @@ def test_general(test_client):
         date_debut="01/01/2024",
         date_fin="31/07/2024",
     )
-
     formsemestre_1 = FormSemestre.get_formsemestre(formsemestre_id_1)
     formsemestre_2 = FormSemestre.get_formsemestre(formsemestre_id_2)
     formsemestre_3 = FormSemestre.get_formsemestre(formsemestre_id_3)
@@ -110,37 +109,36 @@ def test_general(test_client):
         module_id=module_id_2,
         formsemestre_id=formsemestre_id_2,
     )
-
     moduleimpls = [
         moduleimpl_1_1,
         moduleimpl_1_2,
         moduleimpl_2_1,
         moduleimpl_2_2,
     ]
-
     moduleimpls = [
         ModuleImpl.query.filter_by(id=mi_id).first() for mi_id in moduleimpls
     ]
 
-    # Création des étudiants (3)
-
-    etuds_dict = [
-        g_fake.create_etud(code_nip=None, prenom=f"etud{i}") for i in range(3)
-    ]
+    # Création de 3 étudiants
+    etud_0 = g_fake.create_etud(prenom="etud0")
+    etud_1 = g_fake.create_etud(prenom="etud1")
+    etud_2 = g_fake.create_etud(prenom="etud2")
+    etuds_dict = [etud_0, etud_1, etud_2]
+    # etuds_dict = [g_fake.create_etud(prenom=f"etud{i}") for i in range(3)]
 
     etuds = []
     for etud in etuds_dict:
         g_fake.inscrit_etudiant(formsemestre_id=formsemestre_id_1, etud=etud)
         g_fake.inscrit_etudiant(formsemestre_id=formsemestre_id_2, etud=etud)
 
-        etuds.append(Identite.query.filter_by(id=etud["id"]).first())
+        etuds.append(Identite.query.filter_by(id=etud["etudid"]).first())
 
     assert None not in etuds, "Problème avec la conversion en Identite"
 
     # Etudiant faux
 
-    etud_faux_dict = g_fake.create_etud(code_nip=None, prenom="etudfaux")
-    etud_faux = Identite.query.filter_by(id=etud_faux_dict["id"]).first()
+    etud_faux_dict = g_fake.create_etud(prenom="etudfaux")
+    etud_faux = Identite.query.filter_by(id=etud_faux_dict["etudid"]).first()
 
     verif_migration_abs_assiduites()
 
diff --git a/tests/unit/test_departements.py b/tests/unit/test_departements.py
index 9f99b30e5e20f952d5eabec2ca4af1331042a6b3..eeccecbc6fe41a16c074e8ab48c100e1b4655a03 100644
--- a/tests/unit/test_departements.py
+++ b/tests/unit/test_departements.py
@@ -84,7 +84,7 @@ def test_preferences(test_client):
     # --- Preferences d'un semestre
     # rejoue ce test pour avoir un semestre créé
     app.set_sco_dept("D2")
-    test_sco_basic.run_sco_basic()
+    test_sco_basic.run_sco_basic(dept=Departement.query.filter_by(acronym="D2").first())
     sem = sco_formsemestre.do_formsemestre_list()[0]
     formsemestre_id = sem["formsemestre_id"]
     semp = sco_preferences.SemPreferences(formsemestre_id=formsemestre_id)
diff --git a/tests/unit/test_sco_basic.py b/tests/unit/test_sco_basic.py
index be078c9211c64cda8c58353af808047c25714baa..afebd819d62842397a91fc9d4997c1912bd7db27 100644
--- a/tests/unit/test_sco_basic.py
+++ b/tests/unit/test_sco_basic.py
@@ -48,12 +48,12 @@ def test_sco_basic(test_client):
     run_sco_basic()
 
 
-def run_sco_basic(verbose=False) -> FormSemestre:
+def run_sco_basic(verbose=False, dept=None) -> FormSemestre:
     """Scénario de base: création formation, semestre, étudiants, notes,
     décisions jury
     Renvoie le formsemestre créé.
     """
-    G = sco_fake_gen.ScoFake(verbose=verbose)
+    G = sco_fake_gen.ScoFake(verbose=verbose, dept=dept)
 
     # --- Création d'étudiants
     etuds = [G.create_etud(code_nip=None) for _ in range(10)]
diff --git a/tools/fakedatabase/create_test_api_database.py b/tools/fakedatabase/create_test_api_database.py
index 5c300770f6a48382b52bb3518889b47f89e5410b..9c23611ccd040674a7405cd373114b16a3a8ec0a 100644
--- a/tools/fakedatabase/create_test_api_database.py
+++ b/tools/fakedatabase/create_test_api_database.py
@@ -170,9 +170,6 @@ def create_fake_etud(dept: Departement) -> Identite:
     etud.adresse = [models.Adresse(email=f"{etud.prenom}.{etud.nom}@example.com")]
     db.session.add(etud)
     db.session.commit()
-    admission = models.Admission(etudid=etud.id)
-    db.session.add(admission)
-    db.session.commit()
     return etud
 
 
diff --git a/tools/format_import_etudiants.txt b/tools/format_import_etudiants.txt
index ad18c2750f43e7c529bdebe5af675ea524787b86..7d4481b828261ac8bc2e5465dff1fd17f26c5824 100644
--- a/tools/format_import_etudiants.txt
+++ b/tools/format_import_etudiants.txt
@@ -5,11 +5,11 @@
 Code_NIP;     text;     identite;   1; code etudiant (NIP Apogee);NIP
 Code_INE;     text;     identite;   1; code INE;INE
 #
-nom;          text;     identite;   0;  nom de l'etudiant;
+nom;          text;     identite;   0;  nom de l'étudiant;
 nom_usuel; text;    identite;   1;  nom usuel (si different);
-prenom;       text;     identite;   0;  prenom de l'etudiant
-civilite;         text;     identite;   1;  sexe ('M', 'F', 'X');sexe;genre
-prenom_etat_civil;  text;   identite; 1; prenom à l'état-civil (si différent);prenom_etat_civil
+prenom;       text;     identite;   0;  prénom de l'etudiant
+civilite;         text;     identite;   0;  sexe ('M', 'F', 'X');sexe;genre
+prenom_etat_civil;  text;   identite; 1; prénom à l'état-civil (si différent);prenom_etat_civil
 civilite_etat_civil;    text; identite; 1; sexe ('M', 'F', 'X') à l'état civil;civilite_etat_civil
 date_naissance;text;identite;   1;  date de naissance (jj/mm/aaaa)
 lieu_naissance;text;identite; 1; lieu de naissance
@@ -22,14 +22,14 @@ codesemestre; text;     INS;        0;  code semestre inscription
 groupes;     text;     INS;        1;  groupe(s), séparés par des point-virgules, doivent exister avant. On peut spécifier la partition sous la forme partition:groupe.
 # 
 bac;          text;     admissions; 1;  type de bac (S, STI, ...)
-specialite;   text;     admissions; 1;  specialite du bac (SVT, ...)
+specialite;   text;     admissions; 1;  specialité du bac (SVT, ...)
 annee_bac;    integer;  admissions; 1;  annee d'obtention du bac
 math;         real;     admissions; 1;  note de math en terminale
 physique;     real;     admissions; 1;  note de physique en terminale
 anglais;      real;     admissions; 1;  note de anglais en terminale
 francais;     real;     admissions; 1;  note de francais au bac
 type_admission; text; admissions; 1; voie d'admission (APB, APB-PC, CEF, ...) 
-boursier_prec; integer; admissions; 1; 0/1  etait boursier dans le cycle precedent (lycee) ?
+boursier_prec; integer; admissions; 1; 0/1  etait boursier dans le cycle précédent (lycee) ?
 boursier; integer; identite; 1; 0/1 est actuellement boursier
 qualite;      real;     admissions; 1;  note de qualite du dossier
 rapporteur;   text;     admissions; 1;  identite du rapporteur (enseignant IUT)