diff --git a/app/api/formsemestres.py b/app/api/formsemestres.py
index 7834c4cb1ad96d0916ca80b9874174766cb6e8e6..1cfc85256de63326f91ac78c01d50be94949d4e3 100644
--- a/app/api/formsemestres.py
+++ b/app/api/formsemestres.py
@@ -13,6 +13,7 @@
   FormSemestre
 
 """
+import mimetypes
 from operator import attrgetter, itemgetter
 
 from flask import g, make_response, request
@@ -790,3 +791,49 @@ def formsemestre_edt(formsemestre_id: int):
     return sco_edt_cal.formsemestre_edt_dict(
         formsemestre, group_ids=group_ids, show_modules_titles=show_modules_titles
     )
+
+
+@bp.route("/formsemestre/<int:formsemestre_id>/description")
+@api_web_bp.route("/formsemestre/<int:formsemestre_id>/description")
+@login_required
+@scodoc
+@permission_required(Permission.ScoView)
+@as_json
+def formsemestre_get_description(formsemestre_id: int):
+    """Description externe du formsemestre. Peut être vide.
+
+    formsemestre_id : l'id du formsemestre
+
+    SAMPLES
+    -------
+    /formsemestre/1/description
+    """
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    return formsemestre.description.to_dict() if formsemestre.description else {}
+
+
+@bp.route("/formsemestre/<int:formsemestre_id>/description/image")
+@api_web_bp.route("/formsemestre/<int:formsemestre_id>/description/image")
+@login_required
+@scodoc
+@permission_required(Permission.ScoView)
+def formsemestre_get_description_image(formsemestre_id: int):
+    """Image de la description externe du formsemestre. Peut être vide.
+
+    formsemestre_id : l'id du formsemestre
+    """
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    if not formsemestre.description or not formsemestre.description.image:
+        return make_response("", 204)  # 204 No Content status
+
+    # Guess the mimetype based on the image data
+    image_data = formsemestre.description.image
+    mimetype = mimetypes.guess_type("image")[0]
+
+    if not mimetype:
+        # Default to binary stream if mimetype cannot be determined
+        mimetype = "application/octet-stream"
+
+    response = make_response(image_data)
+    response.headers["Content-Type"] = mimetype
+    return response
diff --git a/app/forms/formsemestre/edit_description.py b/app/forms/formsemestre/edit_description.py
new file mode 100644
index 0000000000000000000000000000000000000000..2bf64c0440862c26aac4f5c6670b26cde09940b1
--- /dev/null
+++ b/app/forms/formsemestre/edit_description.py
@@ -0,0 +1,35 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Formulaire édition description formsemestre
+"""
+
+from flask_wtf import FlaskForm
+from wtforms import StringField, TextAreaField, FileField, SubmitField
+from wtforms.validators import Optional
+
+
+class FormSemestreDescriptionForm(FlaskForm):
+    "Formulaire édition description formsemestre"
+    description = TextAreaField(
+        "Description",
+        validators=[Optional()],
+        description="""texte libre : informations
+    sur le contenu, les objectifs, les modalités d'évaluation, etc.""",
+    )
+    horaire = StringField(
+        "Horaire", validators=[Optional()], description="ex: les lundis 9h-12h"
+    )
+    image = FileField(
+        "Image", validators=[Optional()], description="Image illustrant cette formation"
+    )
+    lieu = StringField("Lieu", validators=[Optional()], description="ex: salle 123")
+    responsable = StringField(
+        "Responsable", validators=[Optional()], description="ex: nom de l'enseignant"
+    )
+
+    submit = SubmitField("Enregistrer")
+    cancel = SubmitField("Annuler", render_kw={"formnovalidate": True})
diff --git a/app/forms/formsemestre/edit_modimpls_codes_apo.py b/app/forms/formsemestre/edit_modimpls_codes_apo.py
index cccf1978c9d91769bca58629cedc8632d3e501ec..d0cd8a37100704cb9a2aa9f68b597cf2ac33e43b 100644
--- a/app/forms/formsemestre/edit_modimpls_codes_apo.py
+++ b/app/forms/formsemestre/edit_modimpls_codes_apo.py
@@ -14,12 +14,13 @@ class _EditModimplsCodesForm(FlaskForm):
     # construit dynamiquement ci-dessous
 
 
+# pylint: disable=invalid-name
 def EditModimplsCodesForm(formsemestre: FormSemestre) -> _EditModimplsCodesForm:
     "Création d'un formulaire pour éditer les codes"
 
     # Formulaire dynamique, on créé une classe ad-hoc
     class F(_EditModimplsCodesForm):
-        pass
+        "class factory"
 
     def _gen_mod_form(modimpl: ModuleImpl):
         field = StringField(
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 0cf5498dd159ba6c61b956f213d12e34de9c6f72..df83dfacfa0d9faf401082a3f34de42934e59063 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -38,7 +38,7 @@ class ScoDocModel(db.Model):
     __abstract__ = True  # declare an abstract class for SQLAlchemy
 
     def clone(self, not_copying=()):
-        """Clone, not copying the given attrs
+        """Clone, not copying the given attrs, and add to session.
         Attention: la copie n'a pas d'id avant le prochain flush ou commit.
         """
         d = dict(self.__dict__)
@@ -188,6 +188,7 @@ from app.models.formsemestre import (
     NotesSemSet,
     notes_semset_formsemestre,
 )
+from app.models.formsemestre_descr import FormSemestreDescription
 from app.models.moduleimpls import (
     ModuleImpl,
     notes_modules_enseignants,
diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 6be8fc618f5f64c6d201ef4cfe0c805dd3cd7e6f..09b08b2fb999d803e76e0898b1fe496c72796e38 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -143,6 +143,12 @@ class FormSemestre(models.ScoDocModel):
         lazy="dynamic",
         cascade="all, delete-orphan",
     )
+    description = db.relationship(
+        "FormSemestreDescription",
+        back_populates="formsemestre",
+        cascade="all, delete-orphan",
+        uselist=False,
+    )
     etuds = db.relationship(
         "Identite",
         secondary="notes_formsemestre_inscription",
diff --git a/app/models/formsemestre_descr.py b/app/models/formsemestre_descr.py
new file mode 100644
index 0000000000000000000000000000000000000000..8c73127a4d00cb665478c45815910bbd7d44f4bc
--- /dev/null
+++ b/app/models/formsemestre_descr.py
@@ -0,0 +1,71 @@
+##############################################################################
+# ScoDoc
+# Copyright (c) 1999 - 2024 Emmanuel Viennet.  All rights reserved.
+# See LICENSE
+##############################################################################
+
+"""Description d'un formsemestre pour applications tierces.
+
+Ces informations sont éditables dans ScoDoc et publiés sur l'API
+pour affichage dans l'application tierce.
+"""
+
+from app import db
+from app import models
+
+
+class FormSemestreDescription(models.ScoDocModel):
+    """Informations décrivant un "semestre" (session) de formation
+    pour un apprenant.
+    """
+
+    __tablename__ = "notes_formsemestre_description"
+
+    id = db.Column(db.Integer, primary_key=True)
+    # Storing image data directly in the database:
+    image = db.Column(db.LargeBinary(), nullable=True)
+    description = db.Column(
+        db.Text(), nullable=False, default="", server_default=""
+    )  # HTML allowed
+    responsable = db.Column(db.Text(), nullable=False, default="", server_default="")
+    lieu = db.Column(db.Text(), nullable=False, default="", server_default="")
+    horaire = db.Column(db.Text(), nullable=False, default="", server_default="")
+
+    formsemestre_id = db.Column(
+        db.Integer,
+        db.ForeignKey("notes_formsemestre.id", ondelete="CASCADE"),
+        nullable=False,
+    )
+    formsemestre = db.relationship(
+        "FormSemestre", back_populates="description", uselist=False
+    )
+
+    def __init__(
+        self,
+        image=None,
+        description="",
+        responsable="",
+        lieu="",
+        horaire="",
+    ):
+        self.description = description
+        self.horaire = horaire
+        self.image = image
+        self.lieu = lieu
+        self.responsable = responsable
+
+    def __repr__(self):
+        return f"<FormSemestreDescription {self.id} {self.formsemestre}>"
+
+    def clone(self, not_copying=()) -> "FormSemestreDescription":
+        """clone instance"""
+        return super().clone(not_copying=not_copying + ("formsemestre_id",))
+
+    def to_dict(self):
+        return {
+            "formsemestre_id": self.formsemestre_id,
+            "description": self.description,
+            "responsable": self.responsable,
+            "lieu": self.lieu,
+            "horaire": self.horaire,
+        }
diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
index dc066f219031b578d4d64019b3e951e27f744497..c4cb7b84a81cff03ef057f6898eced74854485f8 100644
--- a/app/scodoc/sco_formsemestre_edit.py
+++ b/app/scodoc/sco_formsemestre_edit.py
@@ -508,6 +508,12 @@ def do_formsemestre_createwithmodules(edit=False, formsemestre: FormSemestre = N
             scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
         }">Modifier les codes Apogée et emploi du temps des modules</a>
         </p>
+
+        <p><a class="stdlink" href="{url_for("notes.edit_formsemestre_description",
+            scodoc_dept=g.scodoc_dept, formsemestre_id=formsemestre.id)
+        }">Éditer la description externe du semestre</a>
+        </p>
+
         <h3>Sélectionner les modules, leurs responsables et les étudiants
         à inscrire:</h3>
         """
@@ -1287,6 +1293,7 @@ def do_formsemestre_clone(
     clone_partitions=False,
 ):
     """Clone a semestre: make copy, same modules, same options, same resps, same partitions.
+    Clone description.
     New dates, responsable_id
     """
     log(f"do_formsemestre_clone: {orig_formsemestre_id}")
@@ -1375,10 +1382,14 @@ def do_formsemestre_clone(
 
     # 5- Copie les parcours
     formsemestre.parcours = formsemestre_orig.parcours
+
+    # 6- Copy description
+    formsemestre.description = formsemestre_orig.description.clone()
+
     db.session.add(formsemestre)
     db.session.commit()
 
-    # 6- Copy partitions and groups
+    # 7- Copy partitions and groups
     if clone_partitions:
         sco_groups_copy.clone_partitions_and_groups(
             orig_formsemestre_id, formsemestre.id
diff --git a/app/templates/formsemestre/edit_description.j2 b/app/templates/formsemestre/edit_description.j2
new file mode 100644
index 0000000000000000000000000000000000000000..637a0966aefc727cab0fc454f3c757d82241a457
--- /dev/null
+++ b/app/templates/formsemestre/edit_description.j2
@@ -0,0 +1,88 @@
+{% extends "sco_page.j2" %}
+{% import 'wtf.j2' as wtf %}
+
+{% block styles %}
+{{super()}}
+<style>
+.field_descr {
+    font-style: italic;
+    color: green;
+}
+.submit {
+    margin-top: 32px;
+    display: flex;
+    justify-content: start;
+    gap: 24px;
+}
+div.image {
+    margin-left: 32px;
+}
+div.image img {
+    border: 1px dashed #b60c0c;
+}
+</style>
+{% endblock %}
+
+{% block app_content %}
+
+<div class="tab-content">
+<h2>Édition de la description du semestre</h2>
+
+<div class="help">
+    <p>Les informations saisies ici ne sont pas utilisées par ScoDoc mais
+    mises à disposition des applications tierces comme AutoSco.
+    </p>
+    <p>La description du semestre est un </p>
+    <p>Il est possible d'ajouter une image pour illustrer le semestre.</p>
+    <p>Le responsable est généralement l'enseignant en charge de la formation, ou
+    la personne qui s'occupant de l'organisation du semestre.</p>
+    <p>Tous les champs sont optionnels.</p>
+</div>
+
+<form method="POST" enctype="multipart/form-data">
+    {{ form.hidden_tag() }}
+    <div>
+        {{ form.description.label }}<br>
+        {{ form.description(cols=80, rows=8) }}
+        <div class="field_descr">{{ form.description.description }}</div>
+    </div>
+    <div>
+        {{ form.responsable.label }}<br>
+        {{ form.responsable(size=64) }}<br>
+        <div class="field_descr">{{ form.responsable.description }}</div>
+    </div>
+    <div>
+        {{ form.lieu.label }}<br>
+        {{ form.lieu(size=48) }}
+        <div class="field_descr">{{ form.lieu.description }}</div>
+    </div>
+    <div>
+        {{ form.horaire.label }}<br>
+        {{ form.horaire(size=64) }}<br>
+        <div class="field_descr">{{ form.horaire.description }}</div>
+    </div>
+
+    {{ form.image.label }}
+    <div class="image">
+        {% if formsemestre_description.image %}
+            <img src="{{ url_for('apiweb.formsemestre_get_description_image',
+            scodoc_dept=g.scodoc_dept,
+            formsemestre_id=formsemestre.id) }}"
+            alt="Current Image" style="max-width: 200px;">
+            <div>
+                Changer l'image: {{ form.image() }}
+            </div>
+        {% else %}
+            <em>Aucune image n'est actuellement associée à ce semestre.</em>
+             {{ form.image() }}
+        {% endif %}
+
+    </div>
+    <div class="submit">
+        {{ form.submit }} {{ form.cancel }}
+    </div>
+</form>
+
+
+</div>
+{% endblock %}
diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py
index 4868d2baddb7eb4fec800ba387956f6a66ded12f..972d25bc5db73a599ac1358ca8ab0d3bdb8504dd 100644
--- a/app/views/notes_formsemestre.py
+++ b/app/views/notes_formsemestre.py
@@ -25,20 +25,29 @@
 ##############################################################################
 
 """
-Vues "modernes" des formsemestre
+Vues "modernes" des formsemestres
 Emmanuel Viennet, 2023
 """
 
 from flask import flash, redirect, render_template, url_for
-from flask import g, request
+from flask import current_app, g, request
 
 from app import db, log
 from app.decorators import (
     scodoc,
     permission_required,
 )
-from app.forms.formsemestre import change_formation, edit_modimpls_codes_apo
-from app.models import Formation, FormSemestre, ScoDocSiteConfig
+from app.forms.formsemestre import (
+    change_formation,
+    edit_modimpls_codes_apo,
+    edit_description,
+)
+from app.models import (
+    Formation,
+    FormSemestre,
+    FormSemestreDescription,
+    ScoDocSiteConfig,
+)
 from app.scodoc import (
     sco_edt_cal,
     sco_formations,
@@ -223,3 +232,67 @@ def formsemestre_edt_help_config(formsemestre_id: int):
         ScoDocSiteConfig=ScoDocSiteConfig,
         title="Aide configuration EDT",
     )
+
+
+@bp.route(
+    "/formsemestre_description/<int:formsemestre_id>/edit", methods=["GET", "POST"]
+)
+@scodoc
+@permission_required(Permission.EditFormSemestre)
+def edit_formsemestre_description(formsemestre_id: int):
+    "Edition de la description d'un formsemestre"
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    if not formsemestre.description:
+        formsemestre.description = FormSemestreDescription()
+        db.session.add(formsemestre)
+        db.session.commit()
+    formsemestre_description = formsemestre.description
+    form = edit_description.FormSemestreDescriptionForm(obj=formsemestre_description)
+
+    if form.validate_on_submit():
+        if form.cancel.data:  # cancel button
+            return redirect(
+                url_for(
+                    "notes.formsemestre_editwithmodules",
+                    scodoc_dept=g.scodoc_dept,
+                    formsemestre_id=formsemestre.id,
+                )
+            )
+        form_image = form.image
+        del form.image
+        form.populate_obj(formsemestre_description)
+
+        if form_image.data:
+            image_data = form_image.data.read()
+            max_length = current_app.config.get("MAX_CONTENT_LENGTH")
+            if max_length and len(image_data) > max_length:
+                flash(
+                    f"Image too large, max {max_length} bytes",
+                    "danger",
+                )
+                return redirect(
+                    url_for(
+                        "notes.edit_formsemestre_description",
+                        formsemestre_id=formsemestre.id,
+                        scodoc_dept=g.scodoc_dept,
+                    )
+                )
+            formsemestre_description.image = image_data
+
+        db.session.commit()
+        flash("Description enregistrée", "success")
+        return redirect(
+            url_for(
+                "notes.formsemestre_status",
+                formsemestre_id=formsemestre.id,
+                scodoc_dept=g.scodoc_dept,
+            )
+        )
+
+    return render_template(
+        "formsemestre/edit_description.j2",
+        form=form,
+        formsemestre=formsemestre,
+        formsemestre_description=formsemestre_description,
+        sco=ScoData(formsemestre=formsemestre),
+    )
diff --git a/migrations/versions/2640b7686de6_formsemestre_description.py b/migrations/versions/2640b7686de6_formsemestre_description.py
new file mode 100644
index 0000000000000000000000000000000000000000..88ec4388460c897ce75e9c80ed9cc094374b541d
--- /dev/null
+++ b/migrations/versions/2640b7686de6_formsemestre_description.py
@@ -0,0 +1,38 @@
+"""FormSemestreDescription
+
+Revision ID: 2640b7686de6
+Revises: f6cb3d4e44ec
+Create Date: 2024-08-11 15:44:32.560054
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "2640b7686de6"
+down_revision = "f6cb3d4e44ec"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.create_table(
+        "notes_formsemestre_description",
+        sa.Column("id", sa.Integer(), nullable=False),
+        sa.Column("image", sa.LargeBinary(), nullable=True),
+        sa.Column("description", sa.Text(), server_default="", nullable=False),
+        sa.Column("responsable", sa.Text(), server_default="", nullable=False),
+        sa.Column("lieu", sa.Text(), server_default="", nullable=False),
+        sa.Column("horaire", sa.Text(), server_default="", nullable=False),
+        sa.Column("formsemestre_id", sa.Integer(), nullable=False),
+        sa.ForeignKeyConstraint(
+            ["formsemestre_id"], ["notes_formsemestre.id"], ondelete="CASCADE"
+        ),
+        sa.PrimaryKeyConstraint("id"),
+    )
+
+
+def downgrade():
+    op.drop_table("notes_formsemestre_description")
diff --git a/tests/unit/test_formsemestre.py b/tests/unit/test_formsemestre.py
index e9677c4f8d231c7ed8cc65ec0f955bd2bf22ec92..75ed6793427b1e800197a412bc4f1ba72118452d 100644
--- a/tests/unit/test_formsemestre.py
+++ b/tests/unit/test_formsemestre.py
@@ -3,12 +3,12 @@
 
 """ Test création/accès/clonage formsemestre
 """
-from flask import Response
+from flask import g, Response
 import pytest
-from tests.unit import yaml_setup, call_view
 
 import app
-from app.models import Formation, FormSemestre
+from app import db
+from app.models import Formation, FormSemestre, FormSemestreDescription
 from app.scodoc import (
     sco_archives_formsemestre,
     sco_cost_formation,
@@ -35,6 +35,7 @@ from app.scodoc import (
 from app.scodoc import sco_utils as scu
 from app.views import notes, scolar
 from config import TestConfig
+from tests.unit import yaml_setup, call_view
 
 DEPT = TestConfig.DEPT_TEST
 
@@ -203,3 +204,36 @@ def test_formsemestre_misc_views(test_client):
     ans = sco_debouche.report_debouche_date(start_year=2000)
     ans = sco_cost_formation.formsemestre_estim_cost(formsemestre.id)
     # pas de test des indicateurs de suivi BUT
+
+
+def test_formsemestre_description(test_client):
+    """Test FormSemestreDescription"""
+    app.set_sco_dept(DEPT)
+    #
+    nb_descriptions = FormSemestreDescription.query.count()
+    # Création d'un semestre
+
+    formsemestre = FormSemestre(
+        dept_id=g.scodoc_dept_id,
+        titre="test description",
+        date_debut="2024-08-01",
+        date_fin="2024-08-31",
+    )
+    db.session.add(formsemestre)
+    db.session.commit()
+    assert formsemestre.description is None
+    # Association d'une description
+    formsemestre.description = FormSemestreDescription(
+        description="Description 2",
+        responsable="Responsable 2",
+        lieu="Lieu 2",
+        horaire="Horaire 2",
+    )
+    db.session.add(formsemestre)
+    db.session.commit()
+    assert formsemestre.description.formsemestre.id == formsemestre.id
+    assert FormSemestreDescription.query.count() == nb_descriptions + 1
+    # Suppression / cascade
+    db.session.delete(formsemestre)
+    db.session.commit()
+    assert FormSemestreDescription.query.count() == nb_descriptions