+ {% if insp.is_cancelled %}
+
+
+
+ {% translate "Inspection annulée" %} — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
+
+
+ {% endif %}
{% if insp.asset %}
@@ -292,9 +311,18 @@
@@ -678,6 +706,48 @@ document.addEventListener('DOMContentLoaded', function() {
}, 150);
}
});
+
+ window.handleToggleCancelInspection = function(inspectionId, action) {
+ const confirmMsg = action === 'cancel'
+ ? "{% translate 'Êtes-vous sûr de vouloir annuler cette inspection ?' %}"
+ : "{% translate 'Êtes-vous sûr de vouloir rétablir cette inspection ?' %}";
+ if (!confirm(confirmMsg)) return;
+
+ let cookieValue = null;
+ if (document.cookie && document.cookie !== '') {
+ const cookies = document.cookie.split(';');
+ for (let i = 0; i < cookies.length; i++) {
+ const cookie = cookies[i].trim();
+ if (cookie.substring(0, 10) === 'csrftoken=') {
+ cookieValue = decodeURIComponent(cookie.substring(10));
+ break;
+ }
+ }
+ }
+
+ const formData = new FormData();
+ formData.append('action', action);
+
+ fetch(`/inspections/api/${inspectionId}/toggle-cancel/`, {
+ method: 'POST',
+ headers: {
+ 'X-CSRFToken': cookieValue
+ },
+ body: formData
+ })
+ .then(res => res.json())
+ .then(data => {
+ if (data.success) {
+ window.location.reload();
+ } else {
+ alert(data.error || "{% translate 'Une erreur est survenue.' %}");
+ }
+ })
+ .catch(err => {
+ console.error(err);
+ alert("{% translate 'Erreur de communication avec le serveur.' %}");
+ });
+ };
});
diff --git a/loko/inspections/tests.py b/loko/inspections/tests.py
index 90c2f5f..e8ab2a9 100644
--- a/loko/inspections/tests.py
+++ b/loko/inspections/tests.py
@@ -379,3 +379,120 @@ class InspectionModelAndViewsTestCase(TestCase):
self.assertIn(insp_parking, inspections_parking)
self.assertNotIn(insp_water, inspections_parking)
+ def test_toggle_cancel_inspection_api(self):
+ self.client.login(username='inspector_user', password='password123')
+ insp = Inspection.objects.create(
+ asset_content_type=self.spot_ct,
+ asset_object_id=self.spot.id,
+ inspector=self.user,
+ result_status='compliant'
+ )
+ self.assertFalse(insp.is_cancelled)
+
+ # Cancel inspection
+ url = reverse('inspections:toggle_cancel_inspection_api', kwargs={'inspection_id': insp.id})
+ resp = self.client.post(url, {'action': 'cancel'})
+ self.assertEqual(resp.status_code, 200)
+ data = resp.json()
+ self.assertTrue(data['success'])
+ self.assertTrue(data['is_cancelled'])
+ self.assertEqual(data['result_status'], 'cancelled')
+
+ insp.refresh_from_db()
+ self.assertTrue(insp.is_cancelled)
+ self.assertEqual(insp.result_status, 'cancelled')
+ self.assertEqual(insp.status_badge_class, 'bg-secondary text-white')
+
+ # Restore inspection
+ resp_restore = self.client.post(url, {'action': 'restore'})
+ self.assertEqual(resp_restore.status_code, 200)
+ data_restore = resp_restore.json()
+ self.assertTrue(data_restore['success'])
+ self.assertFalse(data_restore['is_cancelled'])
+ self.assertEqual(data_restore['result_status'], 'compliant')
+
+ insp.refresh_from_db()
+ self.assertFalse(insp.is_cancelled)
+ self.assertEqual(insp.result_status, 'compliant')
+
+ def test_toggle_cancel_inspection_permissions(self):
+ other_user = User.objects.create_user(
+ username='stranger_user',
+ password='password123'
+ )
+ insp = Inspection.objects.create(
+ asset_content_type=self.spot_ct,
+ asset_object_id=self.spot.id,
+ inspector=self.user,
+ result_status='compliant'
+ )
+
+ url = reverse('inspections:toggle_cancel_inspection_api', kwargs={'inspection_id': insp.id})
+
+ # Stranger cannot cancel
+ self.client.login(username='stranger_user', password='password123')
+ resp = self.client.post(url, {'action': 'cancel'})
+ self.assertEqual(resp.status_code, 403)
+ insp.refresh_from_db()
+ self.assertEqual(insp.result_status, 'compliant')
+
+ # Superuser can cancel
+ other_user.is_superuser = True
+ other_user.save()
+ resp_admin = self.client.post(url, {'action': 'cancel'})
+ self.assertEqual(resp_admin.status_code, 200)
+ insp.refresh_from_db()
+ self.assertEqual(insp.result_status, 'cancelled')
+
+ def test_asset_compliance_recalculation_on_cancellation(self):
+ self.client.login(username='inspector_user', password='password123')
+
+ # 1st inspection (compliant)
+ date1 = timezone.now() - timezone.timedelta(days=2)
+ insp1 = Inspection.objects.create(
+ asset_content_type=self.spot_ct,
+ asset_object_id=self.spot.id,
+ inspector=self.user,
+ inspection_date=date1,
+ result_status='compliant',
+ apply_changes_to_asset=True
+ )
+ self.spot.refresh_from_db()
+ self.assertEqual(self.spot.inspection_status, 'compliant')
+ self.assertTrue(self.spot.is_compliant)
+
+ # 2nd inspection (damaged)
+ date2 = timezone.now()
+ insp2 = Inspection.objects.create(
+ asset_content_type=self.spot_ct,
+ asset_object_id=self.spot.id,
+ inspector=self.user,
+ inspection_date=date2,
+ result_status='damaged',
+ notes="Arceau cassé",
+ apply_changes_to_asset=True
+ )
+ self.spot.refresh_from_db()
+ self.assertEqual(self.spot.inspection_status, 'damaged')
+ self.assertFalse(self.spot.is_compliant)
+ self.assertEqual(self.spot.non_compliance_reason, "Arceau cassé")
+
+ # Cancel 2nd inspection -> asset should fall back to 1st inspection (compliant)
+ url2 = reverse('inspections:toggle_cancel_inspection_api', kwargs={'inspection_id': insp2.id})
+ self.client.post(url2, {'action': 'cancel'})
+
+ self.spot.refresh_from_db()
+ self.assertEqual(self.spot.inspection_status, 'compliant')
+ self.assertTrue(self.spot.is_compliant)
+ self.assertIsNone(self.spot.non_compliance_reason)
+ self.assertEqual(self.spot.last_inspection_date, date1)
+
+ # Cancel 1st inspection -> no active inspections remain, reset to default
+ url1 = reverse('inspections:toggle_cancel_inspection_api', kwargs={'inspection_id': insp1.id})
+ self.client.post(url1, {'action': 'cancel'})
+
+ self.spot.refresh_from_db()
+ self.assertEqual(self.spot.inspection_status, 'unknown')
+ self.assertIsNone(self.spot.last_inspection_date)
+
+
diff --git a/loko/inspections/urls.py b/loko/inspections/urls.py
index acb369d..c1502d3 100644
--- a/loko/inspections/urls.py
+++ b/loko/inspections/urls.py
@@ -9,6 +9,7 @@ urlpatterns = [
path('api/create/', views.create_inspection_api, name='create_inspection_api'),
path('api//detail/', views.get_inspection_detail_api, name='get_inspection_detail_api'),
path('api//update/', views.update_inspection_api, name='update_inspection_api'),
+ path('api//toggle-cancel/', views.toggle_cancel_inspection_api, name='toggle_cancel_inspection_api'),
path('api/document//delete/', views.delete_inspection_document_api, name='delete_inspection_document_api'),
path('api/geojson/', views.inspections_geojson, name='inspections_geojson'),
diff --git a/loko/inspections/views.py b/loko/inspections/views.py
index 38e00e2..cb21875 100644
--- a/loko/inspections/views.py
+++ b/loko/inspections/views.py
@@ -460,6 +460,7 @@ def get_inspection_detail_api(request, inspection_id):
'notes': leaf.notes or '',
'verified_obsolescence': leaf.verified_obsolescence or '',
'apply_changes_to_asset': leaf.apply_changes_to_asset,
+ 'is_cancelled': leaf.is_cancelled,
'inspection_date': leaf.inspection_date.strftime('%d/%m/%Y %H:%M') if leaf.inspection_date else '',
'created_at': leaf.created_at.strftime('%d/%m/%Y %H:%M') if leaf.created_at else '',
'updated_at': leaf.updated_at.strftime('%d/%m/%Y %H:%M') if leaf.updated_at else '',
@@ -600,6 +601,42 @@ def delete_inspection_document_api(request, document_id):
return JsonResponse({'success': True, 'document_id': document_id})
+@login_required
+@require_POST
+def toggle_cancel_inspection_api(request, inspection_id):
+ """
+ API endpoint pour annuler ou rétablir une inspection.
+ Seuls l'auteur et les administrateurs ont le droit d'annuler/rétablir une inspection.
+ """
+ inspection_obj = get_object_or_404(Inspection, pk=inspection_id)
+ inspection = inspection_obj.detailed_inspection
+
+ if not inspection.can_edit(request.user):
+ return JsonResponse({
+ 'success': False,
+ 'error': str(_("Permission refusée. Seuls l'auteur de l'inspection et les administrateurs peuvent la modifier."))
+ }, status=403)
+
+ action = request.POST.get('action') # 'cancel', 'restore', ou toggle automatique si non spécifié
+ if action == 'cancel' or (action is None and not inspection.is_cancelled):
+ inspection.result_status = 'cancelled'
+ elif action == 'restore' or (action is None and inspection.is_cancelled):
+ target_status = request.POST.get('result_status', 'compliant')
+ inspection.result_status = target_status if target_status != 'cancelled' else 'compliant'
+
+ inspection.save()
+
+ return JsonResponse({
+ 'success': True,
+ 'inspection_id': inspection.id,
+ 'is_cancelled': inspection.is_cancelled,
+ 'result_status': inspection.result_status,
+ 'result_status_display': str(inspection.get_result_status_display()),
+ 'status_badge_class': inspection.status_badge_class,
+ 'status_icon': inspection.status_icon,
+ 'status_btn_class': inspection.status_btn_class,
+ 'message': str(_("L'inspection a été annulée avec succès.")) if inspection.is_cancelled else str(_("L'inspection a été rétablie avec succès."))
+ })
@login_required
@@ -709,8 +746,9 @@ def inspections_geojson(request):
if not geojson_str or not insp_list:
continue
- latest_insp = insp_list[0]
- target_obj = latest_insp.asset
+ active_insps = [i for i in insp_list if i.result_status != 'cancelled']
+ top_insp = active_insps[0] if active_insps else insp_list[0]
+ target_obj = top_insp.asset
inspections_data = []
for insp in insp_list:
@@ -729,6 +767,8 @@ def inspections_geojson(request):
'result_status': insp.result_status,
'result_status_display': insp.get_result_status_display(),
'status_badge_class': insp.status_badge_class,
+ 'status_icon': insp.status_icon,
+ 'is_cancelled': insp.is_cancelled,
'nature_display': insp.get_nature_display(),
'inspector_name': get_short_name(insp.inspector) if insp.inspector else '-',
'inspector_role_display': insp.get_inspector_role_display() if insp.inspector_role else '',
@@ -740,7 +780,6 @@ def inspections_geojson(request):
'documents': docs_data,
})
-
feature = {
'type': 'Feature',
'geometry': json.loads(geojson_str),
@@ -749,10 +788,11 @@ def inspections_geojson(request):
'object_id': asset_id,
'model_name': ct.model,
'spot_type': getattr(target_obj, 'spot_type', None) if target_obj else None,
- 'target_display': latest_insp.target_display,
- 'latest_result_status': latest_insp.result_status,
- 'latest_result_status_display': latest_insp.get_result_status_display(),
- 'latest_status_badge_class': latest_insp.status_badge_class,
+ 'target_display': top_insp.target_display,
+ 'latest_result_status': top_insp.result_status,
+ 'latest_result_status_display': top_insp.get_result_status_display(),
+ 'latest_status_badge_class': top_insp.status_badge_class,
+ 'is_cancelled': top_insp.is_cancelled,
'inspections_count': len(inspections_data),
'inspections': inspections_data,
}