diff --git a/app/models/formsemestre.py b/app/models/formsemestre.py
index 62a557034f7e2ebafdc12d7261c9a69b821d55d5..21e719621ddf3e7d30355a49e9407d66c3fc5e5e 100644
--- a/app/models/formsemestre.py
+++ b/app/models/formsemestre.py
@@ -646,17 +646,18 @@ class FormSemestre(models.ScoDocModel):
         )
         return [db.session.get(ModuleImpl, modimpl_id) for modimpl_id in cursor]
 
-    def can_be_edited_by(self, user: User):
-        """Vrai si user peut modifier ce semestre (est chef ou l'un des responsables)"""
+    def can_be_edited_by(self, user: User | None = None, allow_locked=False) -> bool:
+        """Vrai si user (par def. current) peut modifier ce semestre
+        (est chef ou l'un des responsables).
+        Si le semestre est verrouillé, faux sauf si allow_locked.
+        """
+        user = user or current_user
         if user.passwd_must_be_changed or not user.has_permission(
             Permission.EditFormSemestre
-        ):  # pas chef
-            if not self.resp_can_edit or user.id not in [
-                resp.id for resp in self.responsables
-            ]:
+        ):  # pas chef de dept.
+            if not self.resp_can_edit or not self.est_responsable(user):
                 return False
-
-        return True
+        return allow_locked or not self.etat
 
     def est_courant(self) -> bool:
         """Vrai si la date actuelle (now) est dans le semestre
@@ -902,13 +903,6 @@ class FormSemestre(models.ScoDocModel):
         "True si l'user est l'un des responsables du semestre"
         return user.id in [u.id for u in self.responsables]
 
-    def est_chef_or_diretud(self, user: User | None = None) -> bool:
-        "Vrai si utilisateur (par def. current) est admin, chef dept ou responsable du semestre"
-        user = user or current_user
-        return user.has_permission(Permission.EditFormSemestre) or self.est_responsable(
-            user
-        )
-
     def can_change_groups(self, user: User = None) -> bool:
         """Vrai si l'utilisateur (par def. current) peut changer les groupes dans
         ce semestre: vérifie permission et verrouillage (mais pas si la partition est éditable).
@@ -926,10 +920,7 @@ class FormSemestre(models.ScoDocModel):
         """Vrai si utilisateur (par def. current) peut saisir decision de jury
         dans ce semestre: vérifie permission et verrouillage.
         """
-        user = user or current_user
-        if user.passwd_must_be_changed:
-            return False
-        return self.etat and self.est_chef_or_diretud(user)
+        return self.can_be_edited_by(user)
 
     def can_edit_pv(self, user: User = None):
         "Vrai si utilisateur (par def. current) peut editer un PV de jury de ce semestre"
@@ -937,7 +928,7 @@ class FormSemestre(models.ScoDocModel):
         if user.passwd_must_be_changed:
             return False
         # Autorise les secrétariats, repérés via la permission EtudChangeAdr
-        return self.est_chef_or_diretud(user) or user.has_permission(
+        return self.can_be_edited_by(user, allow_locked=True) or user.has_permission(
             Permission.EtudChangeAdr
         )
 
diff --git a/app/scodoc/htmlutils.py b/app/scodoc/htmlutils.py
index 41e452d4ca34ed76d52e03296b12b3a0b694d1dd..3b49d18c07bbb41d40a47629a2860298af73b265 100644
--- a/app/scodoc/htmlutils.py
+++ b/app/scodoc/htmlutils.py
@@ -100,15 +100,11 @@ def make_menu(title, items, css_class="", alone=False) -> str:
     def gen_menu_items(items):
         H.append("<ul>")
         for item in items:
-            if not item.get("enabled", True):
-                cls = ' class="ui-state-disabled"'
-            else:
-                cls = ""
+            cls = f''' class="sco_menu_item {
+                '' if item.get("enabled", True) else 'ui-state-disabled'
+            }"'''
             the_id = item.get("id", "")
-            if the_id:
-                li_id = 'id="%s" ' % the_id
-            else:
-                li_id = ""
+            li_id = f'id="{the_id}" ' if the_id else ""
             if "endpoint" in item:
                 args = item.get("args", {})
                 item["urlq"] = url_for(
@@ -134,7 +130,7 @@ def make_menu(title, items, css_class="", alone=False) -> str:
     H = []
     if alone:
         H.append('<ul class="sco_dropdown_menu %s">' % css_class)
-    H.append("""<li><a href="#">%s</a>""" % title)
+    H.append("""<li class="sco_menu_title"><a href="#">%s</a>""" % title)
     gen_menu_items(items)
     H.append("</li>")
     if alone:
@@ -142,12 +138,8 @@ def make_menu(title, items, css_class="", alone=False) -> str:
     return "".join(H)
 
 
-"""
-HTML <-> text conversions.
-http://stackoverflow.com/questions/328356/extracting-text-from-html-file-using-python
-"""
-
-
+# HTML <-> text conversions.
+# http://stackoverflow.com/questions/328356/extracting-text-from-html-file-using-python
 class _HTMLToText(HTMLParser):
     def __init__(self):
         HTMLParser.__init__(self)
diff --git a/app/scodoc/sco_dept.py b/app/scodoc/sco_dept.py
index 88f355d25235acda0e4f26c0d79cc7151366ae19..1d5b3f4c59360fd92cd26db332ee36b0675d5e39 100644
--- a/app/scodoc/sco_dept.py
+++ b/app/scodoc/sco_dept.py
@@ -36,6 +36,7 @@ from flask_sqlalchemy.query import Query
 import app
 from app import log
 from app.models import FormSemestre, ScolarNews, ScoDocSiteConfig
+from app.scodoc import htmlutils
 import app.scodoc.sco_utils as scu
 from app.scodoc.gen_tables import GenTable
 from app.scodoc.sco_permissions import Permission
@@ -83,6 +84,42 @@ def index_html(showcodes=0, showsemtable=0, export_table_formsemestres=False):
         sco_modalites.group_formsemestres_by_modalite(current_formsemestres)
     )
     passerelle_disabled = ScoDocSiteConfig.is_passerelle_disabled()
+
+    menu_items = [
+        {
+            "title": "Verrouiller les semestres sélectionnés",
+            "endpoint": "notes.formsemestres_lock",
+            "args": {
+                "unlock": False,
+            },
+        },
+        {
+            "title": "Déverrouiller les semestres sélectionnés",
+            "endpoint": "notes.formsemestres_lock",
+            "args": {
+                "unlock": True,
+            },
+        },
+        {
+            "title": "Publier les semestres sélectionnés sur la passerelle",
+            "endpoint": "notes.formsemestres_enable_publish",
+            "args": {
+                "enable": True,
+            },
+        },
+        {
+            "title": "Ne pas publier les semestres sélectionnés sur la passerell ",
+            "endpoint": "notes.formsemestres_enable_publish",
+            "args": {
+                "enable": False,
+            },
+        },
+    ]
+    menu_formsemestres_action = (
+        htmlutils.make_menu("_", menu_items, alone=True)
+        if current_user.has_permission(Permission.EditFormSemestre)
+        else ""
+    )
     return render_template(
         "scolar/index.j2",
         current_user=current_user,
@@ -98,6 +135,7 @@ def index_html(showcodes=0, showsemtable=0, export_table_formsemestres=False):
         icon_hidden="" if passerelle_disabled else scu.ICON_HIDDEN,
         icon_published="" if passerelle_disabled else scu.ICON_PUBLISHED,
         locked_formsemestres=locked_formsemestres,
+        menu_formsemestres_action=menu_formsemestres_action,
         modalites=modalites,
         nb_locked=locked_formsemestres.count(),
         nb_user_accounts=sco_users.get_users_count(dept=g.scodoc_dept),
@@ -107,6 +145,7 @@ def index_html(showcodes=0, showsemtable=0, export_table_formsemestres=False):
         showcodes=showcodes,
         showsemtable=showsemtable,
         sco=ScoData(),
+        title=f"ScoDoc {g.scodoc_dept}",
     )
 
 
diff --git a/app/scodoc/sco_formsemestre_edit.py b/app/scodoc/sco_formsemestre_edit.py
index 557765b3a653fd382665317e47e549327031e4e8..c926c4ff8b6cb38eeab9d43ddba0dafc925e2cad 100644
--- a/app/scodoc/sco_formsemestre_edit.py
+++ b/app/scodoc/sco_formsemestre_edit.py
@@ -1706,42 +1706,6 @@ def formsemestre_edit_options(formsemestre_id):
     )
 
 
-def formsemestre_change_publication_bul(formsemestre_id, dialog_confirmed=False):
-    """Change état publication bulletins sur portail"""
-    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
-    ok, err = sco_permissions_check.check_access_diretud(formsemestre)
-    if not ok:
-        return err
-    etat = not formsemestre.bul_hide_xml
-
-    status_url = url_for(
-        "notes.formsemestre_status",
-        scodoc_dept=g.scodoc_dept,
-        formsemestre_id=formsemestre.id,
-    )
-    if not dialog_confirmed:
-        msg = "non" if etat else ""
-        return scu.confirm_dialog(
-            f"<h2>Confirmer la {msg} publication des bulletins ?</h2>",
-            help_msg="""Il est parfois utile de désactiver la diffusion des bulletins,
-            par exemple pendant la tenue d'un jury ou avant harmonisation des notes.
-            <br>
-            Ce réglage n'a d'effet que si votre établissement a interfacé ScoDoc avec
-            une passerelle étudiant.
-            """,
-            dest_url="",
-            cancel_url=status_url,
-            parameters={"bul_hide_xml": etat, "formsemestre_id": formsemestre_id},
-        )
-
-    formsemestre.bul_hide_xml = etat
-    db.session.add(formsemestre)
-    db.session.commit()
-    log(f"formsemestre_change_publication_bul: {formsemestre} -> {etat}")
-
-    return flask.redirect(status_url)
-
-
 def formsemestre_edit_uecoefs(formsemestre_id, err_ue_id=None):
     """Changement manuel des coefficients des UE capitalisées."""
     formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
diff --git a/app/scodoc/sco_formsemestre_status.py b/app/scodoc/sco_formsemestre_status.py
index 5184143e8c284cbdd83682ba64696656bd88a459..f634d73baa1d3b8cf1468116d59d449a3b93d8c5 100755
--- a/app/scodoc/sco_formsemestre_status.py
+++ b/app/scodoc/sco_formsemestre_status.py
@@ -417,7 +417,7 @@ def formsemestre_status_menubar(formsemestre: FormSemestre | None) -> str:
             "title": "Importer les notes",
             "endpoint": "notes.formsemestre_import_notes",
             "args": {"formsemestre_id": formsemestre_id},
-            "enabled": formsemestre.est_chef_or_diretud(),
+            "enabled": formsemestre.can_be_edited_by(),
         },
     ]
     menu_jury = [
diff --git a/app/scodoc/sco_utils.py b/app/scodoc/sco_utils.py
index 13afb7ad9d4afdc9cca469931f439c96c1c46664..84a3d77d9405d229a1b16371bdc0c1508c45b6f2 100644
--- a/app/scodoc/sco_utils.py
+++ b/app/scodoc/sco_utils.py
@@ -1648,6 +1648,9 @@ def confirm_dialog(
     template="sco_page.j2",
 ):
     """HTML confirmation dialog: submit (POST) to same page or dest_url if given."""
+    from app.models import FormSemestre, Identite
+    from app.views import ScoData
+
     parameters = parameters or {}
     # dialog de confirmation simple
     parameters[target_variable] = 1
@@ -1687,9 +1690,21 @@ def confirm_dialog(
             )
     H.append("</form>")
     if help_msg:
-        H.append('<p class="help">' + help_msg + "</p>")
+        H.append('<div class="scobox help explanation">' + help_msg + "</div>")
     if add_headers:
-        return render_template(template, content="\n".join(H))
+        formsemestre = (
+            FormSemestre.get_formsemestre(parameters["formsemestre_id"])
+            if "formsemestre_id" in parameters
+            else None
+        )
+        etud = (
+            Identite.get_etud(parameters["etudid"]) if "etudid" in parameters else None
+        )
+        return render_template(
+            template,
+            content="\n".join(H),
+            sco=ScoData(formsemestre=formsemestre, etud=etud),
+        )
     return "\n".join(H)
 
 
diff --git a/app/static/css/scodoc.css b/app/static/css/scodoc.css
index 9da9a87fb0e3c2fd52122aedafa144b42738d5fe..bca1f4e1aec823729d6447929c37d756827c0472 100644
--- a/app/static/css/scodoc.css
+++ b/app/static/css/scodoc.css
@@ -1732,18 +1732,16 @@ h2.formsemestre,
   color: black;
 }
 
-.formsemestre_page_title .eye,
-formsemestre_page_title .eye img {
-  display: inline-block;
-  margin-left: 12px;
+.formsemestre_page_title .infos span {
+  margin-right: 16px;
 }
 
-.formsemestre_page_title .infos span.lock,
-formsemestre_page_title .lock img {
+div.formsemestre_page_title .infos span.lock,
+div.formsemestre_page_title .infos span.eye,
+span.lock img,
+span.eye img {
   display: inline-block;
   vertical-align: middle;
-  margin-left: 8px;
-  margin-right: 8px;
 }
 
 #formnotes .tf-explanation {
@@ -1854,10 +1852,6 @@ div.inscr_addremove_menu {
   width: 150px;
 }
 
-.formsemestre_page_title .infos span {
-  padding-right: 25px;
-}
-
 .formsemestre_page_title span.semtitle {
   font-size: 12pt;
 }
diff --git a/app/static/js/scolar_index.js b/app/static/js/scolar_index.js
index c2d09670bc8ab08896be020172db233d173e0d34..0a5e2eb16fbab670e685193fadfcd332adebb0d5 100644
--- a/app/static/js/scolar_index.js
+++ b/app/static/js/scolar_index.js
@@ -16,7 +16,55 @@ $(document).ready(function () {
     orderCellsTop: true, // cellules ligne 1 pour tri
     aaSorting: [], // Prevent initial sorting
   };
-  $("table.semlist").DataTable(table_options);
+  const table = new DataTable("table.semlist", table_options);
+  // Sélection de semestres et mise à jour du menu associé
+  table.on('click', 'tbody tr', function (e) {
+    e.currentTarget.classList.toggle('selected');
+    var nbSelectedRows = table.rows('.selected').count();
+    if (nbSelectedRows == 0) {
+      document.getElementById("formsemestres-select-infos").style.display = 'none';
+    }
+    else {
+      document.getElementById("formsemestres-select-infos").style.display = 'inline';
+      if (nbSelectedRows > 1) {
+        document.querySelector("#formsemestres-select-menu li.sco_menu_title a").childNodes[1].nodeValue = nbSelectedRows + " semestres sélectionnés";
+      } else {
+        document.querySelector("#formsemestres-select-menu li.sco_menu_title a").childNodes[1].nodeValue = nbSelectedRows + " semestre sélectionné";
+      }
+    }
+  });
+  // Lien déselectionner
+  document.getElementById("formsemestres-deselect").addEventListener('click', function (e) {
+    e.preventDefault();
+    table.rows('.selected').nodes().to$().removeClass('selected');
+    document.getElementById("formsemestres-select-infos").style.display = 'none';
+  });
+  // Modification des liens de la section formsemestres-actions: ajout des formsemestres selectionnés:
+  const links = document.querySelectorAll('#formsemestres-select-menu li.sco_menu_item a');
+  links.forEach(link => {
+    link.addEventListener('click', function(event) {
+      // Prevent the default action (navigation)
+      event.preventDefault();
+
+      // Build the query string with formsemestre_id parameters
+      const selectedRows = document.querySelectorAll('tr.selected');
+      const selectedFormsemestreIds = Array.from(selectedRows).map(row => row.dataset.formsemestre_id);
+      const queryString = selectedFormsemestreIds
+        .map(id => `formsemestre_ids=${encodeURIComponent(id)}`)
+        .join('&');
+
+      // Construct the new URL
+      const originalHref = link.getAttribute('href');
+      const newHref = originalHref.includes('?')
+        ? `${originalHref}&${queryString}` // If there's already a query string
+        : `${originalHref}?${queryString}`; // If no query string exists
+
+      // Navigate to the new URL
+      window.location.href = newHref;
+    });
+  });
+
+  // Edition des codes Apo
   let table_editable = document.querySelector("table#semlist.apo_editable");
   if (table_editable) {
     let save_url = document.querySelector("table#semlist.apo_editable").dataset
diff --git a/app/templates/formsemestre_header.j2 b/app/templates/formsemestre_header.j2
index 85d60358747359d84f220dbfa0a9e3b5602bf0aa..10ced6ba448be85f7aaa1234ce02706908f79722 100644
--- a/app/templates/formsemestre_header.j2
+++ b/app/templates/formsemestre_header.j2
@@ -8,7 +8,7 @@
             scodoc_dept=g.scodoc_dept, formsemestre_id=sco.formsemestre.id)
         }}">{{sco.formsemestre.titre}}</a>
             <a title="{{sco.formsemestre.etapes_apo_str()}}">
-                {% if sco.formsemestre.semestre_id != -1 %}, {{sco.formsemestre.formation.get_cursus().SESSION_NAME}}
+                {% if sco.formsemestre.semestre_id != -1 %}  {{sco.formsemestre.formation.get_cursus().SESSION_NAME}}
                 {{sco.formsemestre.semestre_id}}
                 {% endif %}</a>
             {% if sco.formsemestre.modalite %} en {{sco.formsemestre.modalite}}{% endif %}
@@ -35,12 +35,16 @@
         </span>
         <span class="eye">
             {% if not scu.is_passerelle_disabled() %}
-                <a href="{{url_for('notes.formsemestre_change_publication_bul', scodoc_dept=g.scodoc_dept,
-                formsemestre_id=sco.formsemestre.id)}}">
                 {% if sco.formsemestre.bul_hide_xml %}
+                <a href="{{url_for('notes.formsemestres_enable_publish', scodoc_dept=g.scodoc_dept,
+                formsemestre_id=sco.formsemestre.id, enable=True)}}">
                     {{ scu.ICON_HIDDEN|safe}}
+                </a>
                 {% else %}
+                <a href="{{url_for('notes.formsemestres_enable_publish', scodoc_dept=g.scodoc_dept,
+                formsemestre_id=sco.formsemestre.id, enable=False)}}">
                     {{ scu.ICON_PUBLISHED|safe }}
+                </a>
                 {% endif %}
                 </a>
             {% endif %}
diff --git a/app/templates/scolar/index.j2 b/app/templates/scolar/index.j2
index e2850bffd123f100ef95e7fef2e13e86011faeb8..4c69d48ec1462a1279c704193e99fb081d411503 100644
--- a/app/templates/scolar/index.j2
+++ b/app/templates/scolar/index.j2
@@ -30,11 +30,15 @@ a.disabled-link {
     margin-left: 16px;
     color: purple;
     font-weight: normal;
+    display: none;
 }
-#formsemestres-select-infos a {
+a#formsemestres-deselect {
     margin-left: 8px;
     text-decoration: underline;
 }
+#formsemestres-select-menu ul {
+    display: inline-block;
+}
 div.semlist {
     padding-right: 8px;
 }
@@ -253,6 +257,12 @@ div.effectif {
         <a href="{{
             url_for('scolar.export_table_dept_formsemestres', scodoc_dept=g.scodoc_dept)
         }}">{{scu.ICON_XLS|safe}}</a>
+        {% if menu_formsemestres_action %}
+        <span id="formsemestres-select-infos">
+            <span id="formsemestres-select-menu">{{menu_formsemestres_action|safe}}</span>
+            <a id="formsemestres-deselect" href="#">tout désélectionner</a>
+        </span>
+        {% endif %}
     </summary>
     <div class="semlist">
         {{ html_table_formsemestres|safe }}
diff --git a/app/views/notes.py b/app/views/notes.py
index eedb4c67d0e4c478a703f7fbe5ff507b2eaa752c..122d77f137e81088327cb5bf6db2c2925cddc791 100644
--- a/app/views/notes.py
+++ b/app/views/notes.py
@@ -904,48 +904,6 @@ sco_publish(
     methods=["GET", "POST"],
 )
 
-
-@bp.route("/formsemestre_flip_lock", methods=["GET", "POST"])
-@scodoc
-@permission_required(Permission.ScoView)  # acces vérifié dans la vue
-@scodoc7func
-def formsemestre_flip_lock(formsemestre_id, dialog_confirmed=False):
-    "Changement de l'état de verrouillage du semestre"
-    formsemestre: FormSemestre = FormSemestre.get_or_404(formsemestre_id)
-    dest_url = url_for(
-        "notes.formsemestre_status",
-        scodoc_dept=g.scodoc_dept,
-        formsemestre_id=formsemestre.id,
-    )
-    if not formsemestre.est_chef_or_diretud():
-        raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)
-    if not dialog_confirmed:
-        msg = "verrouillage" if formsemestre.etat else "déverrouillage"
-        return scu.confirm_dialog(
-            f"<h2>Confirmer le {msg} du semestre ?</h2>",
-            help_msg="""Les notes d'un semestre verrouillé ne peuvent plus être modifiées.
-            Un semestre verrouillé peut cependant être déverrouillé facilement à tout moment
-            (par son responsable ou un administrateur).
-            <br>
-            Le programme d'une formation qui a un semestre verrouillé ne peut plus être modifié.
-            """,
-            dest_url="",
-            cancel_url=dest_url,
-            parameters={"formsemestre_id": formsemestre_id},
-        )
-
-    formsemestre.flip_lock()
-    db.session.commit()
-
-    return flask.redirect(dest_url)
-
-
-sco_publish(
-    "/formsemestre_change_publication_bul",
-    sco_formsemestre_edit.formsemestre_change_publication_bul,
-    Permission.ScoView,
-    methods=["GET", "POST"],
-)
 sco_publish(
     "/view_formsemestre_by_etape",
     sco_formsemestre.view_formsemestre_by_etape,
@@ -1942,7 +1900,7 @@ def _formsemestre_or_modimpl_import_notes(
             formsemestre_id=formsemestre.id,
         )
     )
-    if formsemestre and not formsemestre.est_chef_or_diretud():
+    if formsemestre and not formsemestre.can_be_edited_by():
         raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)
     if modimpl and not modimpl.can_edit_notes(current_user):
         raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)
diff --git a/app/views/notes_formsemestre.py b/app/views/notes_formsemestre.py
index 54fa6efa9bf241a761caf8a02474e28f5558839a..85e5e9ce95f1c3c7ae69ad6538a00ecae0cae56b 100644
--- a/app/views/notes_formsemestre.py
+++ b/app/views/notes_formsemestre.py
@@ -33,6 +33,7 @@ import datetime
 import io
 import zipfile
 
+import flask
 from flask import flash, redirect, render_template, url_for
 from flask import current_app, g, request
 import PIL
@@ -56,11 +57,8 @@ from app.models import (
     FORMSEMESTRE_DISPOSITIFS,
     ScoDocSiteConfig,
 )
-from app.scodoc import (
-    sco_edt_cal,
-    sco_groups_view,
-)
-from app.scodoc.sco_exceptions import ScoValueError
+from app.scodoc import sco_edt_cal, sco_groups_view
+from app.scodoc.sco_exceptions import ScoPermissionDenied, ScoValueError
 from app.scodoc.sco_permissions import Permission
 from app.scodoc import sco_utils as scu
 from app.views import notes_bp as bp
@@ -475,3 +473,163 @@ def formsemestres_import_from_description_sample():
     return scu.send_file(
         xls, "ImportSemestres", scu.XLSX_SUFFIX, mime=scu.XLSX_MIMETYPE
     )
+
+
+@bp.route("/formsemestre_flip_lock/<int:formsemestre_id>", methods=["GET", "POST"])
+@scodoc
+@permission_required(Permission.ScoView)  # acces vérifié dans la vue
+def formsemestre_flip_lock(formsemestre_id: int):
+    "Changement de l'état de verrouillage du semestre. Si GET, dialogue de confirmation."
+    formsemestre = FormSemestre.get_formsemestre(formsemestre_id)
+    dest_url = url_for(
+        "notes.formsemestre_status",
+        scodoc_dept=g.scodoc_dept,
+        formsemestre_id=formsemestre.id,
+    )
+    if not formsemestre.can_be_edited_by(allow_locked=True):
+        raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)
+    if request.method == "GET":
+        msg = "verrouillage" if formsemestre.etat else "déverrouillage"
+        return scu.confirm_dialog(
+            f"<h2>Confirmer le {msg} du semestre ?</h2>",
+            help_msg="""Les notes d'un semestre verrouillé ne peuvent plus être modifiées.
+            Un semestre verrouillé peut cependant être déverrouillé facilement à tout moment
+            (par son responsable ou un administrateur).
+            <br>
+            Le programme d'une formation qui a un semestre verrouillé ne peut plus être modifié.
+            """,
+            dest_url="",
+            cancel_url=dest_url,
+            parameters={"formsemestre_id": formsemestre_id},
+        )
+
+    formsemestre.flip_lock()
+    db.session.commit()
+
+    return flask.redirect(dest_url)
+
+
+@bp.route("/formsemestres_unlock", defaults={"unlock": True}, methods=["GET", "POST"])
+@bp.route("/formsemestres_lock", defaults={"unlock": False}, methods=["GET", "POST"])
+@scodoc
+@permission_required(Permission.ScoView)  # acces vérifié dans la vue
+def formsemestres_lock(unlock: bool = False):
+    "Lock formsemestres (or unlock if unlock is True). If GET, asks for confirmation."
+    dest_url = url_for("scolar.index_html", scodoc_dept=g.scodoc_dept, showsemtable=1)
+    try:
+        formsemestre_ids = request.args.getlist("formsemestre_ids", type=int)
+    except ValueError as exc:
+        raise ScoValueError("argument formsemestre_ids invalide") from exc
+    if not formsemestre_ids:
+        raise ScoValueError("aucun semestre sélectionné")
+    formsemestres = [
+        FormSemestre.get_formsemestre(formsemestre_id)
+        for formsemestre_id in formsemestre_ids
+    ]
+    for formsemestre in formsemestres:
+        if not formsemestre.can_be_edited_by(allow_locked=True):
+            raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)
+    if request.method == "GET":
+        return scu.confirm_dialog(
+            f"<h2>Confirmer le {'dé' if unlock else ''}verrouillage des semestres ?</h2>",
+            help_msg=f"""
+            <div>
+            Les semestres suivants seront modifiés:
+                <ul>
+                <li>{'</li><li>'.join([ s.html_link_status() for s in formsemestres ])}</li>
+                </ul>
+            </div>
+            <div>
+            Les notes d'un semestre verrouillé ne peuvent plus être modifiées.
+            Un semestre verrouillé peut cependant être déverrouillé facilement à tout moment
+            (par son responsable ou un administrateur).
+            <br>
+            Le programme d'une formation qui a un semestre verrouillé ne peut plus être modifié.
+            </div>
+            """,
+            dest_url="",
+            cancel_url=dest_url,
+            parameters={"formsemestre_ids": formsemestre_ids},
+        )
+
+    for formsemestre in formsemestres:
+        formsemestre.etat = unlock
+        db.session.add(formsemestre)
+    db.session.commit()
+    if unlock:
+        flash(f"{len(formsemestres)} semestres déverrouillés")
+    else:
+        flash(f"{len(formsemestres)} semestres verrouillés")
+    return redirect(dest_url)
+
+
+@bp.route(
+    "/formsemestres_enable_publish", defaults={"enable": True}, methods=["GET", "POST"]
+)
+@bp.route(
+    "/formsemestres_disable_publish",
+    defaults={"enable": False},
+    methods=["GET", "POST"],
+)
+@scodoc
+@permission_required(Permission.ScoView)  # acces vérifié dans la vue
+def formsemestres_enable_publish(enable: bool = False):
+    """Change état publication bulletins sur portail.
+    Peut affecter un (formsemestre_id) ou plusieurs (formsemestre_ids) semestres.
+    """
+    arg_name = (
+        "formsemestre_ids" if "formsemestre_ids" in request.args else "formsemestre_id"
+    )
+    try:
+        formsemestre_ids = request.args.getlist(arg_name, type=int)
+    except ValueError as exc:
+        raise ScoValueError("argument formsemestre_ids invalide") from exc
+    if not formsemestre_ids:
+        raise ScoValueError("aucun semestre sélectionné")
+    formsemestres = [
+        FormSemestre.get_formsemestre(formsemestre_id)
+        for formsemestre_id in formsemestre_ids
+    ]
+    dest_url = (
+        url_for("scolar.index_html", scodoc_dept=g.scodoc_dept, showsemtable=1)
+        if "formsemestre_ids" in request.args
+        else url_for(
+            "notes.formsemestre_status",
+            scodoc_dept=g.scodoc_dept,
+            formsemestre_id=formsemestres[0].id,
+        )
+    )
+    for formsemestre in formsemestres:
+        if not formsemestre.can_be_edited_by(allow_locked=True):
+            raise ScoPermissionDenied("opération non autorisée", dest_url=dest_url)
+
+    if request.method == "GET":
+        return scu.confirm_dialog(
+            f"<h2>Confirmer la {'' if enable else 'non'} publication des bulletins ?</h2>",
+            help_msg="""Il est parfois utile de désactiver la diffusion des bulletins sur
+            la passerelle, par exemple pendant la tenue d'un jury ou avant harmonisation
+            des notes.
+            <br>
+            Ce réglage n'a d'effet que si votre établissement a interfacé ScoDoc avec
+            une passerelle étudiant.
+            """,
+            dest_url="",
+            cancel_url=dest_url,
+            parameters={"formsemestre_ids": formsemestre_ids},
+        )
+
+    for formsemestre in formsemestres:
+        formsemestre.bul_hide_xml = not enable
+        db.session.add(formsemestre)
+        log(
+            f"formsemestres_enable_publish: {formsemestre} -> {formsemestre.bul_hide_xml}"
+        )
+
+    db.session.commit()
+    s = "s" if len(formsemestres) > 1 else ""
+    if enable:
+        flash(f"{len(formsemestres)} semestre{s} publié{s}")
+    else:
+        flash(f"{len(formsemestres)} semestre{s} non publié{s}")
+
+    return flask.redirect(dest_url)