feat: implement control management views, offline support, and associated unit tests

This commit is contained in:
Karl Determe 2026-08-27 16:40:48 +02:00
parent 6b8978254b
commit 4a2aaf9fd0
6 changed files with 305 additions and 64 deletions

View file

@ -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 = {

View file

@ -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,11 +261,17 @@ class OfflineManager {
}
// Synchroniser les données en attente
async syncPendingData() {
if (!this.isOnline) {
async syncPendingData(force = false) {
if (!force && !this.isOnline) {
return;
}
if (this.isSyncing) {
return;
}
this.isSyncing = true;
try {
const transaction = this.db.transaction(['pendingRemarks', 'pendingPhotos'], 'readonly');
const remarksStore = transaction.objectStore('pendingRemarks');
@ -267,13 +288,16 @@ class OfflineManager {
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++;
}
}
@ -284,10 +308,24 @@ class OfflineManager {
'success'
);
// Recharger la page pour afficher les nouvelles remarques
// 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();
}, 1500);
}, 1200);
}
}
if (failedCount > 0) {
@ -296,6 +334,9 @@ class OfflineManager {
'error'
);
}
} finally {
this.isSyncing = false;
}
}
async getAllRecords(store) {
@ -348,20 +389,53 @@ class OfflineManager {
formData.append('images', photo);
});
// Envoyer au serveur
const csrftoken = this.getCookie('csrftoken');
const currentUrl = window.location.pathname;
// 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)}/`;
const response = await fetch(currentUrl, {
// Envoyer au serveur
const csrftoken = this.getCookie('csrftoken') || document.querySelector('[name=csrfmiddlewaretoken]')?.value;
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 [];

View file

@ -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();
const syncBtn = document.getElementById('force-sync-btn');
const originalHtml = syncBtn ? syncBtn.innerHTML : '';
if (syncBtn) {
syncBtn.disabled = true;
syncBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
}
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();
});
</script>

View file

@ -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,10 +1189,29 @@ window.addEventListener('offline', () => {
// Bouton de synchronisation forcée
document.getElementById('force-sync-btn')?.addEventListener('click', async () => {
if (offlineManager) {
await offlineManager.syncPendingData();
const syncBtn = document.getElementById('force-sync-btn');
const originalHtml = syncBtn ? syncBtn.innerHTML : '';
if (syncBtn) {
syncBtn.disabled = true;
syncBtn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
}
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();
});
</script>

View file

@ -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"])

View file

@ -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: