diff --git a/app/static/js/assiduites.js b/app/static/js/assiduites.js
index a9ded03c84087cbfcc29fe6cc3e3f06e1368ba14..089469eb039242b0834ac05b77cd7d8be383d09d 100644
--- a/app/static/js/assiduites.js
+++ b/app/static/js/assiduites.js
@@ -68,7 +68,13 @@ async function async_post(path, data, success, errors) {
       const responseData = await response.json();
       success(responseData);
     } else {
-      throw new Error("Network response was not ok.");
+      if (response.status == 404) {
+        response.json().then((data) => {
+          if (errors) errors(data);
+        });
+      } else {
+        throw new Error("Network response was not ok.");
+      }
     }
   } catch (error) {
     console.error(error);
@@ -615,7 +621,10 @@ function erreurModuleImpl(message) {
 
     openAlertModal("Sélection du module", content);
   }
-  if (message == "L'étudiant n'est pas inscrit au module") {
+  if (
+    message == "L'étudiant n'est pas inscrit au module" ||
+    message == "param 'moduleimpl_id': etud non inscrit"
+  ) {
     const HTML = `
   <p>Attention, l'étudiant n'est pas inscrit à ce module.</p>
   <p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
@@ -822,7 +831,7 @@ function dateCouranteEstTravaillee() {
     const nouvelleDate = retourJourTravail(date);
     $("#date").datepicker("setDate", nouvelleDate);
     let msg = "Le jour sélectionné";
-    if ((new Date()).format("YYYY-MM-DD") == date.format("YYYY-MM-DD")) {
+    if (new Date().format("YYYY-MM-DD") == date.format("YYYY-MM-DD")) {
       msg = "Aujourd'hui";
     }
     const att = document.createTextNode(
diff --git a/app/templates/assiduites/pages/signal_assiduites_hebdo.j2 b/app/templates/assiduites/pages/signal_assiduites_hebdo.j2
index 5b2a7f28f32ad6d2b7727a63f097890d6a5df30b..09a0e750b159c37acccaab4f0d82235e9b22928e 100644
--- a/app/templates/assiduites/pages/signal_assiduites_hebdo.j2
+++ b/app/templates/assiduites/pages/signal_assiduites_hebdo.j2
@@ -108,6 +108,11 @@
         background-color: var(--color-conflit);
     }
 
+    .conflit_calendar{
+        font-size: 1.5em;
+        cursor: pointer;
+    }
+
 
 </style>
 
@@ -185,6 +190,9 @@
 
 <script>
 
+    const readonly = "{{readonly | safe}}" == "True";
+    const non_present = "{{non_present | safe}}" == "True";
+
     const etuds = [
         {% for etud in etudiants %}
         {
@@ -231,6 +239,8 @@
             date_fin: fin.toFakeIso(),
         }
 
+        let cancelEvent = false;
+
         if (assiduite_id != "") {
             if (same) {
                 // Suppression
@@ -243,13 +253,13 @@
                             td.setAttribute("assiduite_id", "");
                         } else {
                             console.error(data.errors["0"].message);
+                            cancelEvent = true;
                             erreurModuleImpl(data.errors["0"].message);
-
                         }
                     },
                     (error) => {
                         console.error("Erreur lors de la suppression de l'assiduité", error);
-
+                        cancelEvent = true;
                     }
                 );
             } else {
@@ -262,6 +272,8 @@
                     },
                     (error) => {
                         console.error("Erreur lors de la modification de l'assiduité", error);
+                        cancelEvent = true;
+                        erreurModuleImpl(error.message);
                     }
                 );
             }
@@ -278,7 +290,7 @@
                     } else {
                         console.error(data.errors["0"].message);
                         erreurModuleImpl(data.errors["0"].message);
-
+                        cancelEvent = true;
                     }
                 },
                 (error) => {
@@ -288,6 +300,8 @@
             );
         }
 
+        return cancelEvent;
+
     }
 
     async function recupAssiduitesHebdo(callback) {
@@ -324,6 +338,14 @@
 
     function updateTable(assiduites) {
 
+        const img_conflit = `
+    <a
+        class="conflit_calendar"
+        title="Des assiduités existent déjà pour cette période. Cliquez ici pour voir le calendrier de l'assiduité de l'étudiant"
+        data-tooltip
+        target="_blank"
+    >📅</a>`
+
         // Suppression existant
         document.querySelectorAll("td.btns").forEach((el) => {
             el.remove();
@@ -395,14 +417,17 @@
 
 
                 // Peuplement des boutons en fonction des assiduités
-                boutons = `
-                    <input type="checkbox" name="matin-${etudid}" id="matin-${etudid}" 
-                    class="rbtn present" value="present">
-                    <input type="checkbox" name="matin-${etudid}" id="matin-${etudid}" 
-                    class="rbtn retard" value="retard">
-                    <input type="checkbox" name="matin-${etudid}" id="matin-${etudid}" 
-                    class="rbtn absent" value="absent">
-                    `
+                let boutons = `
+                <input type="checkbox" name="matin-${etudid}" id="matin-${etudid}" 
+                class="rbtn retard" value="retard">
+                <input type="checkbox" name="matin-${etudid}" id="matin-${etudid}" 
+                class="rbtn absent" value="absent">
+                `
+
+                if (!non_present) {
+                    boutons = `<input type="checkbox" name="matin-${etudid}" id="matin-${etudid}" 
+                class="rbtn present" value="present">`+boutons;
+                }
 
                 // matin
                 tdMatin.innerHTML = boutons
@@ -417,13 +442,15 @@
 
                     if (deb.isSame(morningPeriod.deb, "minutes") && fin.isSame(morningPeriod.fin, "minutes")) {
                         let etat = assi.etat.toLowerCase();
-                        tdMatin.querySelector(`[value="${etat}"]`).checked = true;
+                        const input = tdMatin.querySelector(`[value="${etat}"]`)
+                        if (input) {
+                            input.checked = true;
+                        }
                         tdMatin.setAttribute("assiduite_id", assi.assiduite_id);
                     } else {
-                        tdMatin.innerHTML = ""
+                        tdMatin.innerHTML = img_conflit;
+                        tdMatin.querySelector(".conflit_calendar").href = `calendrier_assi_etud?etudid=${etudid}`;
                         tdMatin.classList.add("conflit");
-                        tdMatin.title = "Des assiduités existent déjà pour cette période"
-                        tdMatin.setAttribute("data-tooltip", "");
                     }
                 }
 
@@ -440,13 +467,15 @@
 
                     if (deb.isSame(afternoonPeriod.deb, "minutes") && fin.isSame(afternoonPeriod.fin, "minutes")) {
                         let etat = assi.etat.toLowerCase();
-                        tdApresmidi.querySelector(`[value="${etat}"]`).checked = true;
+                        const input = tdApresmidi.querySelector(`[value="${etat}"]`)
+                        if (input) {
+                            input.checked = true;
+                        }
                         tdApresmidi.setAttribute("assiduite_id", assi.assiduite_id);
                     } else {
-                        tdApresmidi.innerHTML = ""
+                        tdApresmidi.innerHTML = img_conflit;
+                        tdApresmidi.querySelector(".conflit_calendar").href = `calendrier_assi_etud?etudid=${etudid}`;
                         tdApresmidi.classList.add("conflit");
-                        tdApresmidi.title = "Des assiduités existent déjà pour cette période"
-                        tdApresmidi.setAttribute("data-tooltip", "");
                     }
                 }
 
@@ -454,16 +483,29 @@
         }
 
         document.querySelectorAll("td .rbtn").forEach((el) => {
-            el.addEventListener("click", (e) => {
+            el.addEventListener("click", async (e) => {
+
+                if (readonly) {
+                    e.preventDefault();
+                    return;
+                }
+
                 let target = e.target;
                 let parent = target.parentElement;
+                
+                let isCancelled = await actionButton(target, !target.checked);
+                if (isCancelled) {
+                    e.preventDefault();
+                    target.checked = !target.checked;
+                    return;
+                }
+
                 let inputs = parent.querySelectorAll(".rbtn");
                 inputs.forEach((input) => {
                     if (input != target) {
                         input.checked = false;
                     }
                 });
-                actionButton(target, !target.checked);
             });
         });
 
@@ -507,10 +549,109 @@
     function allPresent(day, time) {
         // Version naive : coche tous les boutons de la colonne
         // TODO - Optimiser avec une seule requête API
-        let inputs = document.querySelectorAll(`td[day="${day}"][time="${time}"] .rbtn[value="present"]`);
-        inputs.forEach((input) => {
-            input.click();
+        let tds = document.querySelectorAll(`td[day="${day}"][time="${time}"]`);
+        const real_time = time == "am" ? "matin" : "apresmidi";
+        const assi = {
+            etat: "present",
+            moduleimpl_id: document.getElementById("moduleimpl_select").value,
+            date_debut: new Date(days[day].date.format('YYYY-MM-DD') + "T" + temps[real_time].debut).toFakeIso(),
+            date_fin: new Date(days[day].date.format('YYYY-MM-DD') + "T" + temps[real_time].fin).toFakeIso(),
+        }
+
+        let toCreate = []; // [{etudid:<int>}]
+        let toEdit = [];// [{etudid:<int>, assiduite_id:<int>}]
+
+        tds.forEach((td) => {
+            // on ne touche pas aux conflits
+            if (td.classList.contains("conflit")) {
+                return;
+            }
+
+            const tr = td.parentElement;
+            const etudid = Number(tr.getAttribute("etudid"));
+
+            const assiduite_id = td.getAttribute("assiduite_id");
+            if (assiduite_id == "") {
+                toCreate.push({ etudid: etudid });
+            } else {
+                toEdit.push({ etudid: etudid, assiduite_id: Number(assiduite_id) });
+            }
+        })
+
+        // Création
+        toCreate = toCreate.map((el) => {
+            return {
+                ...assi,
+                etudid: el.etudid,
+            }
+        });
+
+        // Modification
+        toEdit = toEdit.map((el) => {
+            return {
+                ...assi,
+                etudid: el.etudid,
+                assiduite_id: el.assiduite_id,
+            }
         });
+
+        // Appel API
+        let counts = {
+            create: toCreate.length,
+            edit: toEdit.length
+        }
+        const promiseCreate = async_post(
+            `../../api/assiduites/create`,
+            toCreate,
+            async (data) => {
+                if (data.errors.length > 0) {
+                    console.error(data.errors);
+                    data.errors.forEach((err) => {
+                        let obj = toCreate[err.indice];
+                        let etu = etuds.find((el) => el.id == obj.etudid);
+
+                        const text = document.createTextNode(`Erreur pour ${etu.nom} ${etu.prenom} : ${err.message}`);
+                        const toast = generateToast(text, "var(--color-error)", 10);
+                        pushToast(toast);
+                    });
+                }
+                counts.create = data.success.length;
+            },
+            (error) => {
+                console.error("Erreur lors de la création de l'assiduité", error);
+            }
+        );
+        const promiseEdit = async_post(
+            `../../api/assiduites/edit`,
+            toEdit,
+            async (data) => {
+                if (data.errors.length > 0) {
+                    console.error(data.errors);
+                    data.errors.forEach((err) => {
+                        let obj = toEdit[err.indice];
+                        let etu = etuds.find((el) => el.id == obj.etudid);
+
+                        const text = document.createTextNode(`Erreur pour ${etu.nom} ${etu.prenom} : ${err.message}`);
+                        const toast = generateToast(text, "var(--color-error)");
+                        pushToast(toast);
+                    });
+                }
+                counts.edit = data.success.length;
+            },
+            (error) => {
+                console.error("Erreur lors de l'édition de l'assiduité", error);
+            }
+        );
+
+        // Affiche un loader
+        afficheLoader();
+
+        Promise.all([promiseCreate, promiseEdit]).then(async () => {
+            retirerLoader();
+            await recupAssiduitesHebdo(updateTable);
+            envoiToastTous("present", counts.create + counts.edit);
+        });
+
     }
 
 </script>
@@ -654,6 +795,15 @@ document.addEventListener("DOMContentLoaded", ()=>{
     Le matin <a href="#" id="text-matin" title="Cliquer pour modifier les horaires">9h à 12h</a> et l'après-midi de <a href="#" id="text-apresmidi" title="Cliquer pour modifier les horaires">13h à 17h</a>
 </h3>
 
+{% if readonly %}
+<h4
+    title="Vous n'avez pas les permissions nécessaires afin de modifier les assiduités"
+    data-tooltip
+>
+    Ouvert en mode <span class="rouge">lecture seule</span>.
+</h4>
+
+{% endif %}
 <table id="table">
     <thead>
         <tr class="premier">
@@ -676,8 +826,9 @@ document.addEventListener("DOMContentLoaded", ()=>{
             {% endif %}
             {% endfor %}
         </tr>
+        {% if not readonly and not non_present %}
         <tr>
-            {# Ne pas afficher si preference "non presences" #}
+            {# Ne pas afficher si preference "non presences" / "readonly" #}
             <th></th>
             {% for jour in hebdo_jours %}
             {% if not jour[0] or jour[1][0]  not in ['Samedi', 'Dimanche']  %}
@@ -689,13 +840,13 @@ document.addEventListener("DOMContentLoaded", ()=>{
             </th>
             {% endif %}
             {% endfor %}
-            
         </tr>
+        {% endif %}
     </thead>
     <tbody>
         {% for etud in etudiants %}
         <tr etudid="{{etud.etudid}}" id="row-{{etud.etudid}}">
-            <td class="etudinfo" id="etud-{{etud.etudid}}">{{ etud.nomprenom }}</td>
+            <td class="etudinfo" id="etud-{{etud.etudid}}">{{ etud.nom_prenom() }}</td>
             {# Sera rempli en JS #}
             {# Ne pas afficher bouton présent si pref "non présences" #}
             {# <td>
diff --git a/app/views/assiduites.py b/app/views/assiduites.py
index f84330b6404a57fb10982370c770d886f9585ed0..99ce6ab6f62cbc1d5c0900e807dff6fd7f3c6467 100644
--- a/app/views/assiduites.py
+++ b/app/views/assiduites.py
@@ -2070,8 +2070,6 @@ def signal_assiduites_hebdo():
             grp + ' <span class="fontred">' + groups_infos.groups_titles + "</span>"
         )
 
-    # TODO vérif perm AbsChange -> readonly
-
     # Gestion des jours
     jours: dict[str, list[str]] = {
         "lun": [
@@ -2113,12 +2111,19 @@ def signal_assiduites_hebdo():
 
     return render_template(
         "assiduites/pages/signal_assiduites_hebdo.j2",
+        title="Assiduité: saisie hebdomadaire",
         gr=gr_tit,
         etudiants=etudiants,
         moduleimpl_select=_module_selector(
             formsemestre=formsemestre, moduleimpl_id=moduleimpl_id
         ),
         hebdo_jours=hebdo_jours,
+        readonly=not current_user.has_permission(Permission.AbsChange),
+        non_present=sco_preferences.get_preference(
+            "non_present",
+            formsemestre_id=formsemestre_id,
+            dept_id=g.scodoc_dept_id,
+        ),
     )