feat: add cancel/restore functionality for controls with granular permissions and API support

This commit is contained in:
kdeterme 2026-08-28 10:45:17 +02:00
parent 10942c81af
commit 27056b61fa
10 changed files with 291 additions and 23 deletions

View file

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

View file

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

View file

@ -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)
# ============================================================================

View file

@ -2222,3 +2222,49 @@ function showImageModal(imageUrl) {
}];
showRemarkCarouselModal(0);
}
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);
});
};

View file

@ -15,23 +15,45 @@
{% endfor %}
{% endif %}
{% if control.is_cancelled %}
<div class="alert alert-secondary py-2 px-3 small d-flex align-items-center mb-3">
<i class="bi bi-slash-circle me-2 fs-6"></i>
<div>
<strong>{% translate "Contrôle annulé" %}</strong> — {% translate "Ce contrôle a été annulé." %}
</div>
</div>
{% endif %}
<!-- Informations principales -->
<!-- <div class="card shadow-sm"> -->
<div class="card-body position-relative">
<!-- <h5 class="card-title ">{% translate 'Contrôle' %} {{ control.control_code }}</h5> -->
{% if can_change_control == True %}
<a id="control-edit-btn"
href="{% url 'controls:change_control' control.id %}?v={{ request.GET.v }}"
class="btn btn-sm btn-light border position-absolute top-0 end-0 m-2"
title="{% translate 'Modifier le template' %}"
onclick="showControlEditSpinner(event)">
<i id="control-edit-icon" class="bi bi-pencil"></i>
<div id="control-edit-spinner" class="spinner-border spinner-border-sm text-primary d-none" role="status"></div>
</a>
{% endif %}
<div class="position-absolute top-0 end-0 m-2 d-flex align-items-center gap-1">
{% if can_cancel_control %}
{% if control.is_cancelled %}
<button type="button" class="btn btn-sm btn-outline-success fw-semibold" onclick="handleToggleCancelControl({{ control.id }}, 'restore')" title="{% translate 'Rétablir le contrôle' %}">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir" %}
</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-danger fw-semibold" onclick="handleToggleCancelControl({{ control.id }}, 'cancel')" title="{% translate 'Annuler le contrôle' %}">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler" %}
</button>
{% endif %}
{% endif %}
{% if can_change_control == True %}
<a id="control-edit-btn"
href="{% url 'controls:change_control' control.id %}?v={{ request.GET.v }}"
class="btn btn-sm btn-light border"
title="{% translate 'Modifier le contrôle' %}"
onclick="showControlEditSpinner(event)">
<i id="control-edit-icon" class="bi bi-pencil"></i>
<div id="control-edit-spinner" class="spinner-border spinner-border-sm text-primary d-none" role="status"></div>
</a>
{% endif %}
</div>
<p class="mb-1"><strong>{% translate 'Statut' %} :</strong>
<span class="badge bg-info">{{ control.get_status_display }}</span>
<span class="badge {{ control.status|status_badge }}"><i class="bi {{ control.status|status_icon }} me-1"></i>{{ control.get_status_display }}</span>
</p>
<p class="mb-1"><strong>{% translate 'Type' %} :</strong>

View file

@ -70,19 +70,43 @@
{% endfor %}
{% endif %}
{% if control.is_cancelled %}
<div class="alert alert-secondary py-2 px-3 small d-flex align-items-center mb-3">
<i class="bi bi-slash-circle me-2 fs-6"></i>
<div>
<strong>{% translate "Contrôle annulé" %}</strong> — {% translate "Ce contrôle a été annulé." %}
</div>
</div>
{% endif %}
<!-- Informations principales -->
<div class="card-body position-relative">
<a id="control-edit-btn"
href="{% url 'controls:change_control' control.id %}?v=mobile"
class="btn btn-sm btn-light border position-absolute top-0 end-0 m-2"
title="{% translate 'Modifier le template' %}"
onclick="showControlEditSpinner(event)">
<i id="control-edit-icon" class="bi bi-pencil"></i>
<div id="control-edit-spinner" class="spinner-border spinner-border-sm text-primary d-none" role="status"></div>
</a>
<div class="position-absolute top-0 end-0 m-2 d-flex align-items-center gap-1">
{% if can_cancel_control %}
{% if control.is_cancelled %}
<button type="button" class="btn btn-sm btn-outline-success fw-semibold" onclick="handleToggleCancelControl({{ control.id }}, 'restore')" title="{% translate 'Rétablir le contrôle' %}">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir" %}
</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-danger fw-semibold" onclick="handleToggleCancelControl({{ control.id }}, 'cancel')" title="{% translate 'Annuler le contrôle' %}">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler" %}
</button>
{% endif %}
{% endif %}
{% if can_change_control == True %}
<a id="control-edit-btn"
href="{% url 'controls:change_control' control.id %}?v=mobile"
class="btn btn-sm btn-light border"
title="{% translate 'Modifier le contrôle' %}"
onclick="showControlEditSpinner(event)">
<i id="control-edit-icon" class="bi bi-pencil"></i>
<div id="control-edit-spinner" class="spinner-border spinner-border-sm text-primary d-none" role="status"></div>
</a>
{% endif %}
</div>
<p class="mb-1"><strong>{% translate 'Statut' %} :</strong>
<span class="badge bg-info">{{ control.get_status_display }}</span>
<span class="badge {{ control.status|status_badge }}"><i class="bi {{ control.status|status_icon }} me-1"></i>{{ control.get_status_display }}</span>
</p>
<p class="mb-1"><strong>{% translate 'Type' %} :</strong>

View file

@ -49,3 +49,12 @@ def private_file_url(instance, field_name="file"):
instance.pk,
field_name,
)
@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)

View file

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

View file

@ -13,6 +13,7 @@ urlpatterns = [
path("offline/", views.controls_offline, name="controls_offline"),
path('<int:control_id>/', views.controls_detail, name='controls_detail'),
path('<int:control_id>/change/', views.change_control, name='change_control'),
path('api/<int:control_id>/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/<int:remark_id>/change/", views.change_remark, name="change_remark"),

View file

@ -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."))
})