diff --git a/docs/user/device-config-status.rst b/docs/user/device-config-status.rst index 68d6c540b..58c64882b 100644 --- a/docs/user/device-config-status.rst +++ b/docs/user/device-config-status.rst @@ -36,3 +36,11 @@ scheduled to be removed from the device. The device has been deactivated. The configuration applied through OpenWISP has been removed, and any other operation to manage the device will be prevented or rejected. + +.. note:: + + If a device becomes unreachable (e.g., lost, stolen, or + decommissioned) before it can be properly deactivated, you can still + force the deletion from OpenWISP by hitting the delete button in the + device detail page after having deactivated the device or by using the + bulk delete action from the device list page. diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index ddc3c84b5..339560b8c 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -1,11 +1,13 @@ import json import logging +from collections.abc import Iterable import reversion from django import forms from django.conf import settings from django.contrib import admin, messages from django.contrib.admin import helpers +from django.contrib.admin.actions import delete_selected from django.contrib.admin.models import ADDITION, LogEntry from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ( @@ -566,8 +568,6 @@ def has_delete_permission(self, request, obj=None): perm = super().has_delete_permission(request) if not obj: return perm - if obj._has_config(): - perm = perm and obj.config.is_deactivated() return perm and obj.is_deactivated() def save_form(self, request, form, change): @@ -765,22 +765,42 @@ def deactivate_device(self, request, queryset): def activate_device(self, request, queryset): self._change_device_status(request, queryset, 'activate') - def get_deleted_objects(self, objs, request, *args, **kwargs): - # Ensure that all selected devices can be deleted, i.e. - # the device should be flagged as deactivated and if it has - # a config object, it's status should be "deactivated". - active_devices = [] - for obj in objs: - if not self.has_delete_permission(request, obj): - active_devices.append(obj) - if active_devices: - return ( - active_devices, - {self.model._meta.verbose_name_plural: len(active_devices)}, - ['active_devices'], - [], + @admin.action(description=delete_selected.short_description, permissions=['delete']) + def delete_selected(self, request, queryset): + response = delete_selected(self, request, queryset) + if not response: + return response + if 'active_devices' in response.context_data.get('perms_lacking', {}): + active_devices = [] + for device in queryset.iterator(): + if not device.is_deactivated() or ( + device._has_config() and not device.config.is_deactivated() + ): + active_devices.append(self._get_device_path(device)) + response.context_data.update( + { + 'active_devices': active_devices, + 'perms_lacking': set(), + 'title': _('Are you sure?'), + } ) - return super().get_deleted_objects(objs, request, *args, **kwargs) + return response + + def get_deleted_objects(self, objs, request, *args, **kwargs): + to_delete, model_count, perms_needed, protected = super().get_deleted_objects( + objs, request, *args, **kwargs + ) + if ( + isinstance(perms_needed, Iterable) + and len(perms_needed) == 1 + and list(perms_needed)[0] == self.model._meta.verbose_name + and objs.filter(_is_deactivated=False).exists() + ): + if request.POST.get("post"): + perms_needed = set() + else: + perms_needed = {'active_devices'} + return to_delete, model_count, perms_needed, protected def get_fields(self, request, obj=None): """ @@ -900,6 +920,17 @@ def recover_view(self, request, version_id, extra_context=None): request._recover_view = True return super().recover_view(request, version_id, extra_context) + def delete_view(self, request, object_id, extra_context=None): + extra_context = extra_context or {} + obj = self.get_object(request, object_id) + if obj and obj._has_config() and not obj.config.is_deactivated(): + extra_context['deactivating_warning'] = True + return super().delete_view(request, object_id, extra_context) + + def delete_model(self, request, obj): + force_delete = request.POST.get('force_delete') == 'true' + obj.delete(check_deactivated=not force_delete) + def get_inlines(self, request, obj): inlines = super().get_inlines(request, obj) # this only makes sense in existing devices diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py index c56f1437c..5f6f205c0 100644 --- a/openwisp_controller/config/api/views.py +++ b/openwisp_controller/config/api/views.py @@ -107,6 +107,10 @@ class DeviceDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView): queryset = Device.objects.select_related('config', 'group', 'organization') permission_classes = ProtectedAPIMixin.permission_classes + (DevicePermission,) + def perform_destroy(self, instance): + force_deletion = self.request.query_params.get('force', None) == 'true' + instance.delete(check_deactivated=(not force_deletion)) + class DeviceActivateView(ProtectedAPIMixin, GenericAPIView): serializer_class = serializers.Serializer diff --git a/openwisp_controller/config/static/config/css/device-delete-confirmation.css b/openwisp_controller/config/static/config/css/device-delete-confirmation.css new file mode 100644 index 000000000..52065d658 --- /dev/null +++ b/openwisp_controller/config/static/config/css/device-delete-confirmation.css @@ -0,0 +1,11 @@ +#deactivating-warning .warning p { + margin-top: 0px; +} +#main ul.messagelist li.warning ul li { + display: list-item; + padding: 0px; + background: inherit; +} +ul.messagelist li { + font-size: unset; +} diff --git a/openwisp_controller/config/static/config/js/device-delete-confirmation.js b/openwisp_controller/config/static/config/js/device-delete-confirmation.js new file mode 100644 index 000000000..8960d5ea9 --- /dev/null +++ b/openwisp_controller/config/static/config/js/device-delete-confirmation.js @@ -0,0 +1,12 @@ +"use strict"; + +(function ($) { + $(document).ready(function () { + $("#warning-ack").click(function (event) { + event.preventDefault(); + $("#deactivating-warning").slideUp("fast"); + $("#delete-confirm-container").slideDown("fast"); + $('input[name="force_delete"]').val("true"); + }); + }); +})(django.jQuery); diff --git a/openwisp_controller/config/templates/admin/config/device/delete_confirmation.html b/openwisp_controller/config/templates/admin/config/device/delete_confirmation.html new file mode 100644 index 000000000..6917f224f --- /dev/null +++ b/openwisp_controller/config/templates/admin/config/device/delete_confirmation.html @@ -0,0 +1,81 @@ +{% extends "admin/delete_confirmation.html" %} +{% load i18n static %} + +{% block extrastyle %} +{{ block.super }} + +{% endblock extrastyle %} + +{% block content %} +{% if perms_lacking %} +
{% blocktranslate with escaped_object=object %}Deleting the {{ object_name }} '{{ escaped_object }}' would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}
+{% blocktranslate with escaped_object=object %}Deleting the {{ object_name }} '{{ escaped_object }}' would require deleting the following protected related objects:{% endblocktranslate %}
+{% blocktranslate with escaped_object=object %}Are you sure you want to delete the {{ object_name }} + "{{ escaped_object }}"? All of the following related items will be deleted:{% endblocktranslate %}
+ {% include "admin/includes/object_delete_summary.html" %} +{% blocktranslate %}You have selected the following active device{{ model_count | pluralize }} to delete:{% endblocktranslate %}
-{% blocktrans %}It is required to flag the device as deactivated before deleting the device. If the device has configuration, then wait till the configuration status changes to "deactivated" before deleting the device.{% endblocktrans %}
- {% else %} -{% blocktranslate %}Deleting the selected {{ objects_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}
-{% blocktranslate %}Deleting the selected {{ objects_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktranslate %}
+{% blocktranslate %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktranslate %}
{% blocktranslate %}Are you sure you want to delete the selected {{ objects_name }}? All of the following objects and their related items will be deleted:{% endblocktranslate %}
- {% include "admin/includes/object_delete_summary.html" %} -