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: