diff --git a/loko/controls/admin.py b/loko/controls/admin.py index 0f1cee8..51c198f 100644 --- a/loko/controls/admin.py +++ b/loko/controls/admin.py @@ -7,6 +7,20 @@ from .models import ( ) from common.private_files.helpers import build_private_url +@admin.action(description="Marquer comme annulé(s)") +def make_canceled(modeladmin, request, queryset): + for obj in queryset: + obj.status = 'canceled' + obj.save() + + +@admin.action(description="Rétablir comme en cours") +def make_in_progress(modeladmin, request, queryset): + for obj in queryset: + obj.status = 'in_progress' + obj.save() + + @admin.register(Control) class ControlAdmin(admin.ModelAdmin): list_display = ("control_code", "control_type", "status", "project", "phase", "control_author", "updated_at") @@ -14,6 +28,7 @@ class ControlAdmin(admin.ModelAdmin): search_fields = ("control_code", "description", "location", "control_author__username") ordering = ("-updated_at",) autocomplete_fields = ["project", "phase", "interventions", "control_author"] + actions = [make_canceled, make_in_progress] @admin.register(Category) diff --git a/loko/controls/models.py b/loko/controls/models.py index baacfa2..7b188b2 100644 --- a/loko/controls/models.py +++ b/loko/controls/models.py @@ -64,6 +64,18 @@ class Control(models.Model): def __str__(self): return self.control_code + @property + def is_cancelled(self): + return self.status == 'canceled' + + def can_cancel(self, user): + from .permissions import user_can_cancel_control + return user_can_cancel_control(user, self) + + def can_edit(self, user): + from .permissions import user_can_change_control + return user_can_change_control(user, self) + def get_absolute_url(self): """ Returns the URL to access a specific project instance. diff --git a/loko/controls/permissions.py b/loko/controls/permissions.py index 11ce6b4..351c8fb 100644 --- a/loko/controls/permissions.py +++ b/loko/controls/permissions.py @@ -110,6 +110,44 @@ def control_change_required(view_func): return _wrapped_view +def user_can_cancel_control(user, control): + """ + Vérifie si un utilisateur a le droit d'annuler ou rétablir un contrôle. + Seuls l'auteur du contrôle et les administrateurs ont ce droit. + """ + if not user or not user.is_authenticated: + return False + + # Administrateur Django superuser + if getattr(user, 'is_superuser', False): + return True + + # Administrateur au sens configuration de rôle (admin) + user_config = getattr(user, 'config', None) + if user_config and hasattr(user_config, 'has_role'): + if user_config.has_role('admin'): + return True + + # Auteur du contrôle + if control and control.control_author_id == user.id: + return True + + return False + + +def control_cancel_required(view_func): + @wraps(view_func) + def _wrapped_view(request, control_id, *args, **kwargs): + control = get_object_or_404(Control, id=control_id) + + if user_can_cancel_control(request.user, control): + return view_func(request, control_id, *args, **kwargs) + + return HttpResponseForbidden(_("Vous n'êtes pas autorisé à annuler ou rétablir ce contrôle.")) + + return _wrapped_view + + # ============================================================================ # Guards pour les fichiers privés (private_media) # ============================================================================ diff --git a/loko/controls/static/controls/controls_detail.js b/loko/controls/static/controls/controls_detail.js index d97e587..f79b007 100644 --- a/loko/controls/static/controls/controls_detail.js +++ b/loko/controls/static/controls/controls_detail.js @@ -2221,4 +2221,50 @@ function showImageModal(imageUrl) { element: null }]; showRemarkCarouselModal(0); -} \ No newline at end of file +} + +window.handleToggleCancelControl = function(controlId, action) { + const defaultCancelMsg = "Êtes-vous sûr de vouloir annuler ce contrôle ?"; + const defaultRestoreMsg = "Êtes-vous sûr de vouloir rétablir ce contrôle ?"; + const confirmMsg = action === 'cancel' + ? (typeof gettext === 'function' ? gettext(defaultCancelMsg) : defaultCancelMsg) + : (typeof gettext === 'function' ? gettext(defaultRestoreMsg) : defaultRestoreMsg); + 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(`/controls/api/${controlId}/toggle-cancel/`, { + method: 'POST', + headers: { + 'X-CSRFToken': cookieValue || (typeof csrftoken !== 'undefined' ? csrftoken : '') + }, + body: formData + }) + .then(res => res.json()) + .then(data => { + if (data.success) { + window.location.reload(); + } else { + const defaultErrMsg = "Une erreur est survenue."; + alert(data.error || (typeof gettext === 'function' ? gettext(defaultErrMsg) : defaultErrMsg)); + } + }) + .catch(err => { + console.error(err); + const defaultNetworkErrMsg = "Erreur de communication avec le serveur."; + alert(typeof gettext === 'function' ? gettext(defaultNetworkErrMsg) : defaultNetworkErrMsg); + }); +}; \ No newline at end of file diff --git a/loko/controls/templates/controls/controls_detail_content.html b/loko/controls/templates/controls/controls_detail_content.html index 3521fcb..f07c6bb 100644 --- a/loko/controls/templates/controls/controls_detail_content.html +++ b/loko/controls/templates/controls/controls_detail_content.html @@ -15,23 +15,45 @@ {% endfor %} {% endif %} + {% if control.is_cancelled %} +
{% translate 'Statut' %} : - {{ control.get_status_display }} + {{ control.get_status_display }}
{% translate 'Type' %} : diff --git a/loko/controls/templates/controls/controls_detail_mobile.html b/loko/controls/templates/controls/controls_detail_mobile.html index 468389f..2745859 100644 --- a/loko/controls/templates/controls/controls_detail_mobile.html +++ b/loko/controls/templates/controls/controls_detail_mobile.html @@ -70,19 +70,43 @@ {% endfor %} {% endif %} + {% if control.is_cancelled %} +
{% translate 'Statut' %} : - {{ control.get_status_display }} + {{ control.get_status_display }}
{% translate 'Type' %} :
diff --git a/loko/controls/templatetags/controls_custom_filters.py b/loko/controls/templatetags/controls_custom_filters.py
index 46c7365..8492287 100644
--- a/loko/controls/templatetags/controls_custom_filters.py
+++ b/loko/controls/templatetags/controls_custom_filters.py
@@ -48,4 +48,13 @@ def private_file_url(instance, field_name="file"):
instance._meta.object_name,
instance.pk,
field_name,
- )
\ No newline at end of file
+ )
+
+
+@register.simple_tag
+def can_cancel_control(control, user):
+ """
+ Vérifie si un utilisateur a le droit d'annuler/rétablir un contrôle.
+ """
+ from controls.permissions import user_can_cancel_control
+ return user_can_cancel_control(user, control)
\ No newline at end of file
diff --git a/loko/controls/tests.py b/loko/controls/tests.py
index 9d7936f..14ef393 100644
--- a/loko/controls/tests.py
+++ b/loko/controls/tests.py
@@ -325,3 +325,61 @@ class ControlsIndexTests(TestCase):
res_json = response.json()
self.assertEqual(res_json["status"], "error")
self.assertIn("remark_form", res_json["errors"])
+
+ def test_toggle_cancel_control_api(self):
+ self.client.login(username="internal_user", password="password123")
+ self.assertFalse(self.ctrl1.is_cancelled)
+
+ url = reverse("controls:toggle_cancel_control_api", kwargs={"control_id": self.ctrl1.id})
+
+ # Annuler le contrôle
+ 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["status"], "canceled")
+
+ self.ctrl1.refresh_from_db()
+ self.assertTrue(self.ctrl1.is_cancelled)
+ self.assertEqual(self.ctrl1.status, "canceled")
+
+ # Rétablir le contrôle
+ 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["status"], "in_progress")
+
+ self.ctrl1.refresh_from_db()
+ self.assertFalse(self.ctrl1.is_cancelled)
+ self.assertEqual(self.ctrl1.status, "in_progress")
+
+ def test_toggle_cancel_control_permissions(self):
+ url = reverse("controls:toggle_cancel_control_api", kwargs={"control_id": self.ctrl1.id})
+
+ # Un utilisateur non-auteur et non-admin ne peut pas annuler
+ self.client.login(username="other_user", password="password123")
+ resp = self.client.post(url, {"action": "cancel"})
+ self.assertEqual(resp.status_code, 403)
+ self.ctrl1.refresh_from_db()
+ self.assertFalse(self.ctrl1.is_cancelled)
+
+ # Un administrateur peut annuler
+ self.client.login(username="admin_user", password="password123")
+ resp_admin = self.client.post(url, {"action": "cancel"})
+ self.assertEqual(resp_admin.status_code, 200)
+ self.ctrl1.refresh_from_db()
+ self.assertTrue(self.ctrl1.is_cancelled)
+ self.assertEqual(self.ctrl1.status, "canceled")
+
+ def test_control_model_cancel_properties(self):
+ self.assertFalse(self.ctrl1.is_cancelled)
+ self.assertTrue(self.ctrl1.can_cancel(self.internal_user))
+ self.assertTrue(self.ctrl1.can_cancel(self.admin_user))
+ self.assertFalse(self.ctrl1.can_cancel(self.other_user))
+
+ self.ctrl1.status = "canceled"
+ self.assertTrue(self.ctrl1.is_cancelled)
+
diff --git a/loko/controls/urls.py b/loko/controls/urls.py
index 8888818..7c3f0da 100644
--- a/loko/controls/urls.py
+++ b/loko/controls/urls.py
@@ -13,6 +13,7 @@ urlpatterns = [
path("offline/", views.controls_offline, name="controls_offline"),
path('