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 %}

+ +{% elif protected %} +

{% blocktranslate with escaped_object=object %}Deleting the {{ object_name }} '{{ escaped_object }}' would require deleting the following protected related objects:{% endblocktranslate %}

+ +{% else %} + {% if deactivating_warning %} +
+ +
+ {% endif %} +
+

{% 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" %} +

{% translate "Objects" %}

+ +
{% csrf_token %} +
+ + {% if is_popup %}{% endif %} + {% if to_field %}{% endif %} + + {% if deactivating_warning %}{% endif %} + {% translate "No, take me back" %} +
+
+
+{% endif %} +{% endblock %} + +{% block footer %} +{{ block.super }} + +{% endblock %} diff --git a/openwisp_controller/config/templates/admin/config/device/delete_selected_confirmation.html b/openwisp_controller/config/templates/admin/config/device/delete_selected_confirmation.html index a66130f86..e88f8cd33 100644 --- a/openwisp_controller/config/templates/admin/config/device/delete_selected_confirmation.html +++ b/openwisp_controller/config/templates/admin/config/device/delete_selected_confirmation.html @@ -1,36 +1,87 @@ {% extends "admin/delete_selected_confirmation.html" %} {% load i18n l10n admin_urls static %} +{% block extrastyle %} +{{ block.super }} + +{% endblock extrastyle %} + {% block content %} {% if perms_lacking %} - {% if perms_lacking|first == 'active_devices' %} -

{% 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 %}

- - {% endif %} +

{% 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 %}

+ {% elif protected %}

{% blocktranslate %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktranslate %}

{% else %} -

{% 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" %} -

{% translate "Objects" %}

- {% for deletable_object in deletable_objects %} - - {% endfor %} -
{% csrf_token %} -
- {% for obj in queryset %} - - {% endfor %} - - - - {% translate "No, take me back" %} + {% if active_devices %} +
+
    +
  • +

    + + {% blocktranslate count counter=active_devices|length %} + Warning: Device is not fully deactivated. + {% plural %} + Warning: Some devices are not fully deactivated. + {% endblocktranslate %} + +

    +

    + {% blocktranslate count counter=active_devices|length %} + The device below is either still active or + in the process of being deactivated: + {% plural %} + The devices listed below are either still active + or in the process of being deactivated: + {% endblocktranslate %} +

    +
      {{ active_devices|unordered_list }}
    +

    + {% blocktranslate count counter=active_devices|length %} + To ensure its configuration is removed, please + wait until its status changes to "deactivated".
    + If you proceed now, the device will be deleted, + but its configuration will remain active. + {% plural %} + To ensure their configurations are removed, please + wait until their status changes to "deactivated".
    + If you proceed now, the devices will be deleted, + but their configurations will remain active. + {% endblocktranslate %} +

    + + + {% translate 'No, take me back' %} +
  • + +
+
+ {% endif %} +
+

{% 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" %} +

{% translate "Objects" %}

+ {% for deletable_object in deletable_objects %} +
    {{ deletable_object|unordered_list }}
+ {% endfor %} +
{% csrf_token %} +
+ {% for obj in queryset %} + + {% endfor %} + + + + {% translate "No, take me back" %} +
+
- {% endif %} {% endblock %} + +{% block footer %} +{{ block.super }} + +{% endblock %} diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index f2da37829..62c28b313 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -2120,9 +2120,8 @@ def test_device_with_config_change_deactivate_deactivate(self): ) # Save buttons are absent on deactivated device self.assertNotContains(response, self._save_btn_html) - # Delete button is not present if config status is deactivating self.assertEqual(device.config.status, 'deactivating') - self.assertNotContains(response, delete_btn_html) + self.assertContains(response, delete_btn_html) self.assertNotContains(response, self._deactivate_btn_html) self.assertContains(response, self._activate_btn_html) # Verify adding a new DeviceLocation and DeviceConnection is not allowed diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index 13ae381b4..a5a508d1f 100644 --- a/openwisp_controller/config/tests/test_api.py +++ b/openwisp_controller/config/tests/test_api.py @@ -539,6 +539,22 @@ def test_device_delete_api(self): self.assertEqual(response.status_code, 204) self.assertEqual(Device.objects.count(), 0) + def test_deactivating_device_force_deletion(self): + self._create_template(required=True) + device = self._create_device() + config = self._create_config(device=device) + device.deactivate() + path = reverse('config_api:device_detail', args=[device.pk]) + + with self.subTest( + 'Test force deleting device with config in deactivating state' + ): + self.assertEqual(device.is_deactivated(), True) + self.assertEqual(config.is_deactivating(), True) + response = self.client.delete(f'{path}?force=true') + self.assertEqual(response.status_code, 204) + self.assertEqual(Device.objects.count(), 0) + def test_template_create_no_org_api(self): self.assertEqual(Template.objects.count(), 0) path = reverse('config_api:template_list') diff --git a/openwisp_controller/config/tests/test_selenium.py b/openwisp_controller/config/tests/test_selenium.py index becb85339..dfce1b8e2 100644 --- a/openwisp_controller/config/tests/test_selenium.py +++ b/openwisp_controller/config/tests/test_selenium.py @@ -12,11 +12,14 @@ from selenium.webdriver.common.keys import Keys from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import Select, WebDriverWait +from swapper import load_model from openwisp_utils.test_selenium_mixins import SeleniumTestMixin from .utils import CreateConfigTemplateMixin, TestWireguardVpnMixin +Device = load_model('config', 'Device') + class SeleniumBaseMixin(CreateConfigTemplateMixin, SeleniumTestMixin): def setUp(self): @@ -321,6 +324,77 @@ def test_template_context_variables(self): alert.accept() self.fail('Unsaved changes alert displayed without any change') + def test_force_delete_device_with_deactivating_config(self): + self._create_template(default=True) + config = self._create_config(organization=self._get_org()) + device = config.device + self.assertEqual(device.is_deactivated(), False) + self.assertEqual(config.status, 'modified') + + self.login() + self.open(reverse('admin:config_device_change', args=[device.id])) + self.web_driver.find_elements( + by=By.CSS_SELECTOR, value='input.deletelink[type="submit"]' + )[-1].click() + device.refresh_from_db() + config.refresh_from_db() + self.assertEqual(device.is_deactivated(), True) + self.assertEqual(config.is_deactivating(), True) + + self.open(reverse('admin:config_device_change', args=[device.id])) + self.web_driver.find_elements(by=By.CSS_SELECTOR, value='a.deletelink')[ + -1 + ].click() + WebDriverWait(self.web_driver, 5).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, '#deactivating-warning .messagelist .warning p') + ) + ) + self.web_driver.find_element(by=By.CSS_SELECTOR, value='#warning-ack').click() + delete_confirm = WebDriverWait(self.web_driver, 2).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, 'form[method="post"] input[type="submit"]') + ) + ) + delete_confirm.click() + self.assertEqual(Device.objects.count(), 0) + + def test_force_delete_multiple_devices_with_deactivating_config(self): + self._create_template(default=True) + org = self._get_org() + device1 = self._create_device(organization=org) + config1 = self._create_config(device=device1) + device2 = self._create_device( + organization=org, name='test2', mac_address='22:22:22:22:22:22' + ) + config2 = self._create_config(device=device2) + self.assertEqual(device1.is_deactivated(), False) + self.assertEqual(config1.status, 'modified') + self.assertEqual(device2.is_deactivated(), False) + self.assertEqual(config2.status, 'modified') + + self.login() + self.open(reverse('admin:config_device_changelist')) + self.web_driver.find_element(by=By.CSS_SELECTOR, value='#action-toggle').click() + select = Select(self.web_driver.find_element(by=By.NAME, value='action')) + select.select_by_value('delete_selected') + self.web_driver.find_element( + by=By.CSS_SELECTOR, value='button[type="submit"][name="index"][value="0"]' + ).click() + WebDriverWait(self.web_driver, 5).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, '#deactivating-warning .messagelist .warning p') + ) + ) + self.web_driver.find_element(by=By.CSS_SELECTOR, value='#warning-ack').click() + delete_confirm = WebDriverWait(self.web_driver, 2).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, 'form[method="post"] input[type="submit"]') + ) + ) + delete_confirm.click() + self.assertEqual(Device.objects.count(), 0) + class TestVpnAdmin(SeleniumBaseMixin, TestWireguardVpnMixin, StaticLiveServerTestCase): def test_vpn_edit(self): diff --git a/openwisp_controller/tests/test_selenium.py b/openwisp_controller/tests/test_selenium.py index 13b52f5de..5a16bc789 100644 --- a/openwisp_controller/tests/test_selenium.py +++ b/openwisp_controller/tests/test_selenium.py @@ -63,7 +63,7 @@ def test_restoring_deleted_device(self, *args): reverse(f'admin:{self.config_app_label}_device_delete', args=[device.id]) ) self.web_driver.find_element( - by=By.XPATH, value='//*[@id="content"]/form/div/input[2]' + by=By.CSS_SELECTOR, value='#content form input[type="submit"]' ).click() # Delete location object location.delete() diff --git a/pyproject.toml b/pyproject.toml index c1f5a1ad4..23aa7c344 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.coverage.run] source = ["openwisp_controller"] parallel = true -concurrency = ["multiprocessing"] +concurrency = ["multiprocessing", "thread"] omit = [ "openwisp_controller/__init__.py", "*/tests/*",