diff --git a/loko/controls/static/controls/controls_detail.js b/loko/controls/static/controls/controls_detail.js index 271ef30..d97e587 100644 --- a/loko/controls/static/controls/controls_detail.js +++ b/loko/controls/static/controls/controls_detail.js @@ -391,9 +391,12 @@ document.querySelector("#remark-form").addEventListener("submit", async (e) => { // Vérifier si on est en mode édition d'une remarque offline const editingRemarkId = form.dataset.editingRemarkId; - // Récupérer l'ID du contrôle depuis l'URL + // Récupérer l'ID du contrôle depuis le champ caché ou l'URL + const hiddenControlId = document.getElementById('control_id')?.value; const pathParts = window.location.pathname.split('/'); - const controlId = pathParts[pathParts.indexOf('controls') + 1]; + const controlsIndex = pathParts.indexOf('controls'); + const urlControlId = controlsIndex !== -1 ? pathParts[controlsIndex + 1] : null; + const controlId = hiddenControlId || urlControlId; // Collecter les données du formulaire const formData = { diff --git a/loko/controls/static/controls/offline-manager.js b/loko/controls/static/controls/offline-manager.js index 9632e58..b0c2637 100644 --- a/loko/controls/static/controls/offline-manager.js +++ b/loko/controls/static/controls/offline-manager.js @@ -9,12 +9,13 @@ class OfflineManager { this.isReady = false; // Seuils de qualité réseau en-dessous desquels le mode hors ligne est activé. - // Équivalent 4G faible : effectiveType != '4g', downlink < 0.5 Mbps ou rtt > 500 ms. + // Seuls 'slow-2g' déclenchent le mode dégradé, laissant 2G, 3G et 4G fonctionner. this.connectionThreshold = { - minEffectiveType: '4g', // '3g' et inférieurs déclenchent le mode offline - minDownlink: 0.5, // Mbps - maxRtt: 500 // ms + minEffectiveType: '2g', // 'slow-2g' déclenche le mode offline + minDownlink: 0.1, // Mbps (100 kbps) + maxRtt: 2000 // ms }; + this.isSyncing = false; this.setupEventListeners(); this._evaluateConnectionQuality(); @@ -107,6 +108,20 @@ class OfflineManager { } }); } + + // Retry périodique toutes les 30s si des remarques sont en attente et que navigator.onLine est vrai + setInterval(async () => { + if (navigator.onLine && this.db && this.isReady && !this.isSyncing) { + try { + const count = await this.getPendingCount(); + if (count > 0) { + this.syncPendingData(); + } + } catch (e) { + // Ignorer les erreurs silencieuses du timer + } + } + }, 30000); } /** @@ -117,7 +132,7 @@ class OfflineManager { const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; if (!conn) return false; // API non disponible : conserver le comportement par défaut - const poorTypes = ['slow-2g', '2g', '3g']; + const poorTypes = ['slow-2g']; if (poorTypes.includes(conn.effectiveType)) return true; if (conn.rtt > 0 && conn.rtt > this.connectionThreshold.maxRtt) return true; if (conn.downlink > 0 && conn.downlink < this.connectionThreshold.minDownlink) return true; @@ -246,55 +261,81 @@ class OfflineManager { } // Synchroniser les données en attente - async syncPendingData() { - if (!this.isOnline) { + async syncPendingData(force = false) { + if (!force && !this.isOnline) { return; } - const transaction = this.db.transaction(['pendingRemarks', 'pendingPhotos'], 'readonly'); - const remarksStore = transaction.objectStore('pendingRemarks'); - - const remarks = await this.getAllRecords(remarksStore); - - if (remarks.length === 0) { + if (this.isSyncing) { return; } + this.isSyncing = true; - this.showNotification( - `Synchronisation de ${remarks.length} remarque(s) en cours...`, - 'info' - ); - - let syncedCount = 0; - let failedCount = 0; - - for (const remark of remarks) { - try { - await this.uploadRemark(remark); - await this.deleteRemark(remark.id); - syncedCount++; - } catch (error) { - failedCount++; - } - } - - if (syncedCount > 0) { - this.showNotification( - `${syncedCount} remarque(s) synchronisée(s) avec succès`, - 'success' - ); + try { + const transaction = this.db.transaction(['pendingRemarks', 'pendingPhotos'], 'readonly'); + const remarksStore = transaction.objectStore('pendingRemarks'); - // Recharger la page pour afficher les nouvelles remarques - setTimeout(() => { - window.location.reload(); - }, 1500); - } - - if (failedCount > 0) { + const remarks = await this.getAllRecords(remarksStore); + + if (remarks.length === 0) { + return; + } + this.showNotification( - `${failedCount} remarque(s) n'ont pas pu être synchronisées`, - 'error' + `Synchronisation de ${remarks.length} remarque(s) en cours...`, + 'info' ); + + let syncedCount = 0; + let failedCount = 0; + const syncedControlIds = new Set(); + + for (const remark of remarks) { + try { + await this.uploadRemark(remark); + await this.deleteRemark(remark.id); + syncedCount++; + syncedControlIds.add(String(remark.controlId)); + } catch (error) { + console.error('Erreur lors de la synchronisation de la remarque:', error); + failedCount++; + } + } + + if (syncedCount > 0) { + this.showNotification( + `${syncedCount} remarque(s) synchronisée(s) avec succès`, + 'success' + ); + + // Notifier les écouteurs de l'événement de synchronisation + window.dispatchEvent(new CustomEvent('pendingRemarksSynced', { + detail: { + syncedCount, + syncedControlIds: Array.from(syncedControlIds) + } + })); + + // Vérifier si le contrôle actuel affiché fait partie de ceux qui ont été synchronisés + const currentPathParts = window.location.pathname.split('/'); + const controlsIndex = currentPathParts.indexOf('controls'); + const currentControlId = controlsIndex !== -1 ? currentPathParts[controlsIndex + 1] : null; + + if (currentControlId && syncedControlIds.has(String(currentControlId))) { + setTimeout(() => { + window.location.reload(); + }, 1200); + } + } + + if (failedCount > 0) { + this.showNotification( + `${failedCount} remarque(s) n'ont pas pu être synchronisées`, + 'error' + ); + } + } finally { + this.isSyncing = false; } } @@ -348,20 +389,53 @@ class OfflineManager { formData.append('images', photo); }); + // Déterminer l'URL cible selon le contexte (mobile ou desktop) et l'ID du contrôle de la remarque + const isMobile = window.location.pathname.startsWith('/mobile/') || window.location.pathname.includes('/mobile'); + const targetUrl = isMobile + ? `/mobile/controls/${encodeURIComponent(remark.controlId)}/` + : `/controls/${encodeURIComponent(remark.controlId)}/`; + // Envoyer au serveur - const csrftoken = this.getCookie('csrftoken'); - const currentUrl = window.location.pathname; + const csrftoken = this.getCookie('csrftoken') || document.querySelector('[name=csrfmiddlewaretoken]')?.value; - const response = await fetch(currentUrl, { + const response = await fetch(targetUrl, { method: 'POST', headers: { 'X-CSRFToken': csrftoken, + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json, text/javascript, */*; q=0.01' }, body: formData }); if (!response.ok) { - throw new Error(`Erreur HTTP: ${response.status} - ${response.statusText}`); + let errorDetail = `Erreur HTTP: ${response.status} - ${response.statusText}`; + try { + const errorJson = await response.json(); + if (errorJson && errorJson.message) { + errorDetail += ` (${errorJson.message})`; + } else if (errorJson && errorJson.error) { + errorDetail += ` (${errorJson.error})`; + } + } catch (e) { + // Pas du JSON, conserver le message d'erreur HTTP standard + } + throw new Error(errorDetail); + } + + // Si le serveur a répondu en JSON, vérifier le statut + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + try { + const data = await response.clone().json(); + if (data.status === 'error') { + throw new Error(data.message || 'Erreur lors de la sauvegarde côté serveur'); + } + } catch (e) { + if (e.message && e.message.includes('sauvegarde')) { + throw e; + } + } } // Supprimer les photos associées @@ -452,7 +526,7 @@ class OfflineManager { return container; } - // Obtenir le nombre de remarques en attente + // Obtenir le nombre de remarques en attente (total) async getPendingCount() { if (!this.db) return 0; const transaction = this.db.transaction('pendingRemarks', 'readonly'); @@ -464,6 +538,15 @@ class OfflineManager { }); } + // Obtenir le nombre de remarques en attente pour un contrôle spécifique + async getPendingCountForControl(controlId) { + if (!this.db) return 0; + const transaction = this.db.transaction('pendingRemarks', 'readonly'); + const remarksStore = transaction.objectStore('pendingRemarks'); + const allRemarks = await this.getAllRecords(remarksStore); + return allRemarks.filter(r => String(r.controlId) === String(controlId)).length; + } + // Obtenir toutes les remarques en attente pour un contrôle async getPendingRemarks(controlId) { if (!this.db) return []; diff --git a/loko/controls/templates/controls/controls_detail_content.html b/loko/controls/templates/controls/controls_detail_content.html index eedef42..3521fcb 100644 --- a/loko/controls/templates/controls/controls_detail_content.html +++ b/loko/controls/templates/controls/controls_detail_content.html @@ -862,18 +862,32 @@ async function updatePendingBadge() { if (typeof offlineManager === 'undefined' || !offlineManager) return; try { - const count = await offlineManager.getPendingCount(); + const totalCount = await offlineManager.getPendingCount(); + const controlId = document.getElementById('control_id')?.value; + const currentCount = controlId ? await offlineManager.getPendingCountForControl(controlId) : totalCount; + const pendingBadge = document.getElementById('pending-count'); const pendingNumber = document.getElementById('pending-number'); const forceSyncBtn = document.getElementById('force-sync-btn'); + const syncStatus = document.getElementById('sync-status'); - if (count > 0) { + if (totalCount > 0) { pendingBadge.classList.remove('d-none'); - pendingNumber.textContent = count; + if (currentCount === totalCount) { + pendingNumber.textContent = totalCount; + pendingBadge.title = ''; + } else { + pendingNumber.textContent = `${currentCount} (${totalCount} total)`; + pendingBadge.title = '{% translate "Remarques en attente sur ce contrôle et d’autres contrôles" %}'; + } forceSyncBtn.classList.remove('d-none'); + if (syncStatus) syncStatus.classList.remove('d-none'); } else { pendingBadge.classList.add('d-none'); forceSyncBtn.classList.add('d-none'); + if (navigator.onLine && syncStatus) { + syncStatus.classList.add('d-none'); + } } } catch (error) { console.error('Erreur lors de la mise à jour du badge:', error); @@ -934,8 +948,26 @@ document.addEventListener('DOMContentLoaded', () => { // Bouton de synchronisation forcée document.getElementById('force-sync-btn')?.addEventListener('click', async () => { if (offlineManager) { - await offlineManager.syncPendingData(); - await updatePendingBadge(); + const syncBtn = document.getElementById('force-sync-btn'); + const originalHtml = syncBtn ? syncBtn.innerHTML : ''; + if (syncBtn) { + syncBtn.disabled = true; + syncBtn.innerHTML = ''; + } + try { + await offlineManager.syncPendingData(true); + } finally { + if (syncBtn) { + syncBtn.disabled = false; + syncBtn.innerHTML = originalHtml; + } + await updatePendingBadge(); + } } }); + +// Écouter les synchronisations réussies pour rafraîchir l'affichage +window.addEventListener('pendingRemarksSynced', async () => { + await updatePendingBadge(); +}); diff --git a/loko/controls/templates/controls/controls_detail_mobile.html b/loko/controls/templates/controls/controls_detail_mobile.html index e6b3051..468389f 100644 --- a/loko/controls/templates/controls/controls_detail_mobile.html +++ b/loko/controls/templates/controls/controls_detail_mobile.html @@ -887,15 +887,24 @@ async function updatePendingBadge() { if (!offlineManager) return; try { - const count = await offlineManager.getPendingCount(); + const totalCount = await offlineManager.getPendingCount(); + const controlId = document.getElementById('control_id')?.value; + const currentCount = controlId ? await offlineManager.getPendingCountForControl(controlId) : totalCount; + const pendingBadge = document.getElementById('pending-count'); const pendingNumber = document.getElementById('pending-number'); const forceSyncBtn = document.getElementById('force-sync-btn'); const syncStatus = document.getElementById('sync-status'); - if (count > 0) { + if (totalCount > 0) { pendingBadge.classList.remove('d-none'); - pendingNumber.textContent = count; + if (currentCount === totalCount) { + pendingNumber.textContent = totalCount; + pendingBadge.title = ''; + } else { + pendingNumber.textContent = `${currentCount} (${totalCount} total)`; + pendingBadge.title = '{% translate "Remarques en attente sur ce contrôle et d’autres contrôles" %}'; + } forceSyncBtn.classList.remove('d-none'); // Afficher le badge s'il y a des remarques en attente syncStatus.classList.remove('d-none'); @@ -1180,11 +1189,30 @@ window.addEventListener('offline', () => { // Bouton de synchronisation forcée document.getElementById('force-sync-btn')?.addEventListener('click', async () => { if (offlineManager) { - await offlineManager.syncPendingData(); - await updatePendingBadge(); - await displayPendingRemarks(); + const syncBtn = document.getElementById('force-sync-btn'); + const originalHtml = syncBtn ? syncBtn.innerHTML : ''; + if (syncBtn) { + syncBtn.disabled = true; + syncBtn.innerHTML = ''; + } + try { + await offlineManager.syncPendingData(true); + } finally { + if (syncBtn) { + syncBtn.disabled = false; + syncBtn.innerHTML = originalHtml; + } + await updatePendingBadge(); + await displayPendingRemarks(); + } } }); + +// Écouter les synchronisations réussies pour rafraîchir l'affichage +window.addEventListener('pendingRemarksSynced', async () => { + await updatePendingBadge(); + await displayPendingRemarks(); +}); diff --git a/loko/controls/tests.py b/loko/controls/tests.py index 7fc3dde..9d7936f 100644 --- a/loko/controls/tests.py +++ b/loko/controls/tests.py @@ -261,4 +261,67 @@ class ControlsIndexTests(TestCase): self.assertEqual(len(data["markers"]), 1) self.assertEqual(data["markers"][0]["code"], "CTRL-0001") + def test_submit_remark_ajax_success(self): + self.client.login(username="admin_user", password="password123") + url = reverse("controls:controls_detail", kwargs={"control_id": self.ctrl1.id}) + data = { + "submit_remark": "true", + "control": str(self.ctrl1.id), + "description": "Nouvelle remarque test AJAX", + "status": "to_be_processed", + "reference_plan": "map", + "categories": "", + "qty-TOTAL_FORMS": "0", + "qty-INITIAL_FORMS": "0", + "qty-MIN_NUM_FORMS": "0", + "qty-MAX_NUM_FORMS": "1000", + } + response = self.client.post(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(response.status_code, 200) + res_json = response.json() + self.assertEqual(res_json["status"], "success") + self.assertEqual(res_json["control_id"], self.ctrl1.id) + self.assertTrue(Remark.objects.filter(control=self.ctrl1, description="Nouvelle remarque test AJAX").exists()) + def test_submit_remark_ajax_control_id_mismatch(self): + self.client.login(username="admin_user", password="password123") + # Envoi d'une remarque destinée au contrôle 2 sur l'URL du contrôle 1 + url = reverse("controls:controls_detail", kwargs={"control_id": self.ctrl1.id}) + data = { + "submit_remark": "true", + "control": str(self.ctrl2.id), + "description": "Remarque avec mauvais control_id", + "status": "to_be_processed", + "reference_plan": "map", + "categories": "", + "qty-TOTAL_FORMS": "0", + "qty-INITIAL_FORMS": "0", + "qty-MIN_NUM_FORMS": "0", + "qty-MAX_NUM_FORMS": "1000", + } + response = self.client.post(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(response.status_code, 400) + res_json = response.json() + self.assertEqual(res_json["status"], "error") + self.assertIn("Incohérence", res_json["message"]) + self.assertFalse(Remark.objects.filter(description="Remarque avec mauvais control_id").exists()) + + def test_submit_remark_ajax_validation_error(self): + self.client.login(username="admin_user", password="password123") + url = reverse("controls:controls_detail", kwargs={"control_id": self.ctrl1.id}) + # Description vide / statut manquant + data = { + "submit_remark": "true", + "control": str(self.ctrl1.id), + "description": "", + "status": "", + "qty-TOTAL_FORMS": "0", + "qty-INITIAL_FORMS": "0", + "qty-MIN_NUM_FORMS": "0", + "qty-MAX_NUM_FORMS": "1000", + } + response = self.client.post(url, data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + self.assertEqual(response.status_code, 400) + res_json = response.json() + self.assertEqual(res_json["status"], "error") + self.assertIn("remark_form", res_json["errors"]) diff --git a/loko/controls/views.py b/loko/controls/views.py index 588c182..e58253b 100644 --- a/loko/controls/views.py +++ b/loko/controls/views.py @@ -1036,7 +1036,19 @@ def controls_detail(request, control_id): if request.method == "POST": if 'submit_remark' in request.POST: - if remark_form.is_valid() and qty_formset.is_valid(): + is_ajax = request.headers.get('x-requested-with') == 'XMLHttpRequest' or 'application/json' in request.headers.get('Accept', '') + + # Vérifier la cohérence de l'ID du contrôle si fourni dans le formulaire + posted_control_id = request.POST.get('control') + if posted_control_id and str(posted_control_id) != str(control.id): + if is_ajax: + return JsonResponse({ + "status": "error", + "message": f"Incohérence d'identifiant de contrôle (reçu {posted_control_id}, attendu {control.id})" + }, status=400) + messages.error(request, _("Incohérence lors de l'enregistrement de la remarque.")) + + elif remark_form.is_valid() and qty_formset.is_valid(): remark = remark_form.save(commit=False) remark.control = control remark.updated_by = request.user @@ -1096,11 +1108,31 @@ def controls_detail(request, control_id): qty_formset.save() + if is_ajax: + return JsonResponse({ + "status": "success", + "remark_id": remark.id, + "control_id": control.id, + "message": str(_("Remarque sauvegardée avec succès !")) + }) + messages.success(request, _("Remarque sauvegardée avec succès !")) #return redirect("controls:control_detail", control_id=control.id) else: + if is_ajax: + errors = {} + if not remark_form.is_valid(): + errors['remark_form'] = remark_form.errors.get_json_data() + if not qty_formset.is_valid(): + errors['qty_formset'] = qty_formset.errors + return JsonResponse({ + "status": "error", + "message": str(_("Erreur lors de la sauvegarde de la remarque.")), + "errors": errors + }, status=400) + messages.error(request, _("Erreur lors de la sauvegarde de la remarque.")) elif 'submit_plan' in request.POST: