From 27056b61faa2e50f544ec02e9fc52c02a49fd1b3 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Fri, 28 Aug 2026 10:45:17 +0200 Subject: [PATCH] feat: add cancel/restore functionality for controls with granular permissions and API support --- loko/controls/admin.py | 15 +++++ loko/controls/models.py | 12 ++++ loko/controls/permissions.py | 38 ++++++++++++ .../static/controls/controls_detail.js | 48 ++++++++++++++- .../controls/controls_detail_content.html | 44 ++++++++++---- .../controls/controls_detail_mobile.html | 42 +++++++++++--- .../templatetags/controls_custom_filters.py | 11 +++- loko/controls/tests.py | 58 +++++++++++++++++++ loko/controls/urls.py | 1 + loko/controls/views.py | 45 +++++++++++++- 10 files changed, 291 insertions(+), 23 deletions(-) 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 "Contrôle annulé" %} — {% translate "Ce contrôle a été annulé." %} +
+
+ {% endif %} +
- {% if can_change_control == True %} - - -
-
- {% endif %} +
+ {% if can_cancel_control %} + {% if control.is_cancelled %} + + {% else %} + + {% endif %} + {% endif %} + {% if can_change_control == True %} + + +
+
+ {% endif %} +

{% 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 "Contrôle annulé" %} — {% translate "Ce contrôle a été annulé." %} +
+
+ {% endif %} +
- - -
-
+
+ {% if can_cancel_control %} + {% if control.is_cancelled %} + + {% else %} + + {% endif %} + {% endif %} + {% if can_change_control == True %} + + +
+
+ {% endif %} +

{% 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('/', views.controls_detail, name='controls_detail'), path('/change/', views.change_control, name='change_control'), + path('api//toggle-cancel/', views.toggle_cancel_control_api, name='toggle_cancel_control_api'), path('add/', views.controls_add, name='controls_add'), path('quick-create/', views.control_quick_create_from_intervention, name='control_quick_create_from_intervention'), path("remark//change/", views.change_remark, name="change_remark"), diff --git a/loko/controls/views.py b/loko/controls/views.py index e58253b..e2fcdc5 100644 --- a/loko/controls/views.py +++ b/loko/controls/views.py @@ -38,7 +38,11 @@ from .models import ( CONTROL_STATUS_CHOICES, CONTROL_TYPE_CHOICES ) from .forms import ControlForm, RemarkForm, ControlReferencePlanForm, ControlReportTemplateForm, ControlQuantityForm, QuantityFormSet -from .permissions import control_view_required, control_add_required, user_can_add_control, control_change_required, user_can_change_control +from .permissions import ( + control_view_required, control_add_required, user_can_add_control, + control_change_required, user_can_change_control, + control_cancel_required, user_can_cancel_control +) from projects.models import Project, ProjectUserAccess, ProjectPhase from projects.permissions import filter_viewable_projects_for_user @@ -1203,6 +1207,7 @@ def controls_detail(request, control_id): upload_plan_form = ControlReferencePlanForm() report_template = control.templates.first() can_change_control = user_can_change_control(request.user, control) + can_cancel_control = user_can_cancel_control(request.user, control) # Préparer les données des plans pour JavaScript plans_data = [ @@ -1259,6 +1264,7 @@ def controls_detail(request, control_id): "remarks_data": remarks_data, "report_template": report_template, "can_change_control": can_change_control, + "can_cancel_control": can_cancel_control, } return render(request, template_name, context) @@ -1597,6 +1603,43 @@ def change_template(request, template_id): return render(request, template_name, {'form': form, 'template': template}) +@login_required +@require_POST +def toggle_cancel_control_api(request, control_id): + """ + API endpoint pour annuler ou rétablir un contrôle. + Seuls l'auteur et les administrateurs ont le droit d'annuler/rétablir un contrôle. + """ + control = get_object_or_404(Control, pk=control_id) + + if not user_can_cancel_control(request.user, control): + return JsonResponse({ + 'success': False, + 'error': str(_("Permission refusée. Seuls l'auteur du contrôle et les administrateurs peuvent l'annuler ou le rétablir.")) + }, 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 control.is_cancelled): + control.status = 'canceled' + elif action == 'restore' or (action is None and control.is_cancelled): + target_status = request.POST.get('status', 'in_progress') + control.status = target_status if target_status != 'canceled' else 'in_progress' + + control.save() + + return JsonResponse({ + 'success': True, + 'control_id': control.id, + 'is_cancelled': control.is_cancelled, + 'status': control.status, + 'status_display': str(control.get_status_display()), + 'status_badge': _CONTROL_STATUS_BADGE_MAP.get(control.status, 'secondary'), + 'status_color': _CONTROL_STATUS_COLOR_MAP.get(control.status, '#6c757d'), + 'message': str(_("Le contrôle a été annulé avec succès.")) if control.is_cancelled else str(_("Le contrôle a été rétabli avec succès.")) + }) + + +