diff --git a/docs/developer/utils.rst b/docs/developer/utils.rst
index 996fe4fd8..8cd9a47b8 100644
--- a/docs/developer/utils.rst
+++ b/docs/developer/utils.rst
@@ -146,6 +146,55 @@ object are changed, but only on ``post_add`` or ``post_remove`` actions,
``post_clear`` is ignored for the same reason explained in the previous
section.
+``config_deactivating``
+~~~~~~~~~~~~~~~~~~~~~~~
+
+**Path**: ``openwisp_controller.config.signals.config_deactivating``
+
+**Arguments**:
+
+- ``instance``: instance of the object being deactivated
+- ``previous_status``: previous status of the object before deactivation
+
+This signal is emitted when a configuration status of device is set to
+``deactivating``.
+
+``config_deactivated``
+~~~~~~~~~~~~~~~~~~~~~~
+
+**Path**: ``openwisp_controller.config.signals.config_deactivated``
+
+**Arguments**:
+
+- ``instance``: instance of the object being deactivated
+- ``previous_status``: previous status of the object before deactivation
+
+This signal is emitted when a configuration status of device is set to
+``deactivated``.
+
+``device_deactivated``
+~~~~~~~~~~~~~~~~~~~~~~
+
+**Path**: ``openwisp_controller.config.signals.device_deactivated``
+
+**Arguments**:
+
+- ``instance``: instance of the device being deactivated
+
+This signal is emitted when a device is flagged for deactivation.
+
+``device_activated``
+~~~~~~~~~~~~~~~~~~~~
+
+**Path**: ``openwisp_controller.config.signals.device_activated``
+
+**Arguments**:
+
+- ``instance``: instance of the device being activated
+
+This signal is emitted when a device is flagged for activation (after
+deactivation).
+
.. _config_backend_changed:
``config_backend_changed``
diff --git a/docs/index.rst b/docs/index.rst
index 40d4e9301..a708bd0ff 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -35,6 +35,7 @@ the OpenWISP architecture.
:maxdepth: 1
user/intro.rst
+ user/device-config-status.rst
user/templates.rst
user/variables.rst
user/device-groups.rst
diff --git a/docs/user/device-config-status.rst b/docs/user/device-config-status.rst
new file mode 100644
index 000000000..68d6c540b
--- /dev/null
+++ b/docs/user/device-config-status.rst
@@ -0,0 +1,38 @@
+Device Configuration Status
+===========================
+
+The device's configuration status (`Device.config.status`) indicates the
+current state of the configuration as managed by OpenWISP. The possible
+statuses and their meanings are explained below.
+
+``modified``
+------------
+
+The device configuration has been updated in OpenWISP, but these changes
+have not yet been applied to the device. The device is pending an update.
+
+``applied``
+-----------
+
+The device has successfully applied the configuration changes made in
+OpenWISP. The current configuration on the device matches the latest
+changes.
+
+``error``
+---------
+
+An issue occurred while applying the configuration to the device, causing
+the device to revert to its previous working configuration.
+
+``deactivating``
+----------------
+
+The device is in the process of being deactivated. The configuration is
+scheduled to be removed from the device.
+
+``deactivated``
+---------------
+
+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.
diff --git a/docs/user/rest-api.rst b/docs/user/rest-api.rst
index d4deba46d..a66519a7e 100644
--- a/docs/user/rest-api.rst
+++ b/docs/user/rest-api.rst
@@ -233,10 +233,29 @@ from the config of a device,
Delete Device
~~~~~~~~~~~~~
+.. note::
+
+ A device must be deactivated before it can be deleted. Otherwise, an
+ ``HTTP 403 Forbidden`` response will be returned.
+
.. code-block:: text
DELETE /api/v1/controller/device/{id}/
+Deactivate Device
+~~~~~~~~~~~~~~~~~
+
+.. code-block:: text
+
+ POST /api/v1/controller/device/{id}/deactivate/
+
+Activate Device
+~~~~~~~~~~~~~~~
+
+.. code-block:: text
+
+ POST /api/v1/controller/device/{id}/activate/
+
List Device Connections
~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py
index f51194af5..ddc3c84b5 100644
--- a/openwisp_controller/config/admin.py
+++ b/openwisp_controller/config/admin.py
@@ -19,7 +19,9 @@
from django.template.loader import get_template
from django.template.response import TemplateResponse
from django.urls import path, re_path, reverse
+from django.utils.html import format_html, mark_safe
from django.utils.translation import gettext_lazy as _
+from django.utils.translation import ngettext_lazy
from flat_json_widget.widgets import FlatJsonWidget
from import_export.admin import ImportExportMixin
from openwisp_ipam.filters import SubnetFilter
@@ -73,6 +75,30 @@ class BaseAdmin(TimeReadonlyAdminMixin, ModelAdmin):
history_latest_first = True
+class DeactivatedDeviceReadOnlyMixin(object):
+ def _has_permission(self, request, obj, perm):
+ if not obj or getattr(request, '_recover_view', False):
+ return perm
+ return perm and not obj.is_deactivated()
+
+ def has_add_permission(self, request, obj):
+ perm = super().has_add_permission(request, obj)
+ return self._has_permission(request, obj, perm)
+
+ def has_change_permission(self, request, obj=None):
+ perm = super().has_change_permission(request, obj)
+ return self._has_permission(request, obj, perm)
+
+ def has_delete_permission(self, request, obj=None):
+ perm = super().has_delete_permission(request, obj)
+ return self._has_permission(request, obj, perm)
+
+ def get_extra(self, request, obj=None, **kwargs):
+ if obj and obj.is_deactivated():
+ return 0
+ return super().get_extra(request, obj, **kwargs)
+
+
class BaseConfigAdmin(BaseAdmin):
change_form_template = 'admin/config/change_form.html'
preview_template = None
@@ -390,6 +416,7 @@ class Meta(BaseForm.Meta):
class ConfigInline(
+ DeactivatedDeviceReadOnlyMixin,
MultitenantAdminMixin,
TimeReadonlyAdminMixin,
SystemDefinedVariableMixin,
@@ -452,6 +479,10 @@ def __init__(self, org_id, **kwargs):
class DeviceAdmin(MultitenantAdminMixin, BaseConfigAdmin, UUIDAdmin):
+ change_form_template = 'admin/config/device/change_form.html'
+ delete_selected_confirmation_template = (
+ 'admin/config/device/delete_selected_confirmation.html'
+ )
list_display = [
'name',
'backend',
@@ -499,7 +530,12 @@ class DeviceAdmin(MultitenantAdminMixin, BaseConfigAdmin, UUIDAdmin):
]
inlines = [ConfigInline]
conditional_inlines = []
- actions = ['change_group']
+ actions = [
+ 'change_group',
+ 'deactivate_device',
+ 'activate_device',
+ 'delete_selected',
+ ]
org_position = 1 if not app_settings.HARDWARE_ID_ENABLED else 2
list_display.insert(org_position, 'organization')
_state_adding = False
@@ -520,6 +556,20 @@ class Media(BaseConfigAdmin.Media):
f'{prefix}js/relevant_templates.js',
]
+ def has_change_permission(self, request, obj=None):
+ perm = super().has_change_permission(request)
+ if not obj or getattr(request, '_recover_view', False):
+ return perm
+ return perm and not obj.is_deactivated()
+
+ 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):
self._state_adding = form.instance._state.adding
return super().save_form(request, form, change)
@@ -624,6 +674,114 @@ def change_group(self, request, queryset):
request, 'admin/config/change_device_group.html', context
)
+ def _get_device_path(self, device):
+ app_label = self.opts.app_label
+ model_name = self.model._meta.model_name
+ return format_html(
+ '{}',
+ reverse(
+ f'admin:{app_label}_{model_name}_change',
+ args=[device.id],
+ ),
+ device,
+ )
+
+ _device_status_messages = {
+ 'deactivate': {
+ messages.SUCCESS: ngettext_lazy(
+ 'The device %(devices_html)s was deactivated successfully.',
+ (
+ 'The following devices were deactivated successfully:'
+ ' %(devices_html)s.'
+ ),
+ 'devices',
+ ),
+ messages.ERROR: ngettext_lazy(
+ 'An error occurred while deactivating the device %(devices_html)s.',
+ (
+ 'An error occurred while deactivating the following devices:'
+ ' %(devices_html)s.'
+ ),
+ 'devices',
+ ),
+ },
+ 'activate': {
+ messages.SUCCESS: ngettext_lazy(
+ 'The device %(devices_html)s was activated successfully.',
+ 'The following devices were activated successfully: %(devices_html)s.',
+ 'devices',
+ ),
+ messages.ERROR: ngettext_lazy(
+ 'An error occurred while activating the device %(devices_html)s.',
+ (
+ 'An error occurred while activating the following devices:'
+ ' %(devices_html)s.'
+ ),
+ 'devices',
+ ),
+ },
+ }
+
+ def _message_user_device_status(self, request, devices, method, message_level):
+ if not devices:
+ return
+ if len(devices) == 1:
+ devices_html = devices[0]
+ else:
+ devices_html = ', '.join(devices[:-1]) + ' and ' + devices[-1]
+ message = self._device_status_messages[method][message_level]
+ self.message_user(
+ request,
+ mark_safe(
+ message % {'devices_html': devices_html, 'devices': len(devices)}
+ ),
+ message_level,
+ )
+
+ def _change_device_status(self, request, queryset, method):
+ """
+ This helper method provides re-usability of code for
+ device activation and deactivation actions.
+ """
+ success_devices = []
+ error_devices = []
+ for device in queryset.iterator():
+ try:
+ getattr(device, method)()
+ except Exception:
+ error_devices.append(self._get_device_path(device))
+ else:
+ success_devices.append(self._get_device_path(device))
+ self._message_user_device_status(
+ request, success_devices, method, messages.SUCCESS
+ )
+ self._message_user_device_status(request, error_devices, method, messages.ERROR)
+
+ @admin.action(description=_('Deactivate selected devices'), permissions=['change'])
+ def deactivate_device(self, request, queryset):
+ self._change_device_status(request, queryset, 'deactivate')
+
+ @admin.action(description=_('Activate selected devices'), permissions=['change'])
+ 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'],
+ [],
+ )
+ return super().get_deleted_objects(objs, request, *args, **kwargs)
+
def get_fields(self, request, obj=None):
"""
Do not show readonly fields in add form
@@ -642,7 +800,12 @@ def ip(self, obj):
ip.short_description = _('IP address')
def config_status(self, obj):
- return obj.config.status
+ if obj._has_config():
+ return obj.config.status
+ # The device does not have a related config object
+ if obj.is_deactivated():
+ return _('deactivated')
+ return _('unknown')
config_status.short_description = _('config status')
@@ -687,6 +850,35 @@ def get_urls(self):
def get_extra_context(self, pk=None):
ctx = super().get_extra_context(pk)
+ if pk:
+ device = self.model.objects.select_related('config').get(id=pk)
+ ctx.update(
+ {
+ 'show_deactivate': not device.is_deactivated(),
+ 'show_activate': device.is_deactivated(),
+ 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
+ }
+ )
+ if device.is_deactivated():
+ ctx['additional_buttons'].append(
+ {
+ 'raw_html': mark_safe(
+ ''
+ )
+ }
+ )
+ else:
+ ctx['additional_buttons'].append(
+ {
+ 'raw_html': mark_safe(
+ '
'
+ ''
+ '
'
+ )
+ }
+ )
ctx.update(
{
'relevant_template_url': reverse(
@@ -704,6 +896,10 @@ def add_view(self, request, form_url='', extra_context=None):
extra_context = self.get_extra_context()
return super().add_view(request, form_url, extra_context)
+ def recover_view(self, request, version_id, extra_context=None):
+ request._recover_view = True
+ return super().recover_view(request, version_id, extra_context)
+
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/serializers.py b/openwisp_controller/config/api/serializers.py
index 701dfb943..50033f2b2 100644
--- a/openwisp_controller/config/api/serializers.py
+++ b/openwisp_controller/config/api/serializers.py
@@ -249,6 +249,7 @@ class DeviceDetailConfigSerializer(BaseConfigSerializer):
class DeviceDetailSerializer(DeviceConfigMixin, BaseSerializer):
config = DeviceDetailConfigSerializer(allow_null=True)
+ is_deactivated = serializers.BooleanField(read_only=True)
class Meta(BaseMeta):
model = Device
@@ -261,6 +262,7 @@ class Meta(BaseMeta):
'key',
'last_ip',
'management_ip',
+ 'is_deactivated',
'model',
'os',
'system',
diff --git a/openwisp_controller/config/api/urls.py b/openwisp_controller/config/api/urls.py
index 89020b7e6..ca5a1de16 100644
--- a/openwisp_controller/config/api/urls.py
+++ b/openwisp_controller/config/api/urls.py
@@ -53,6 +53,16 @@ def get_api_urls(api_views):
api_views.device_detail,
name='device_detail',
),
+ path(
+ 'controller/device//activate/',
+ api_views.device_activate,
+ name='device_activate',
+ ),
+ path(
+ 'controller/device//deactivate/',
+ api_views.device_deactivate,
+ name='device_deactivate',
+ ),
path(
'controller/group/',
api_views.devicegroup_list,
diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py
index dd06aaf72..c56f1437c 100644
--- a/openwisp_controller/config/api/views.py
+++ b/openwisp_controller/config/api/views.py
@@ -4,14 +4,18 @@
from django.http import Http404
from django.urls.base import reverse
from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework import pagination
+from rest_framework import pagination, serializers, status
from rest_framework.generics import (
+ GenericAPIView,
ListCreateAPIView,
RetrieveAPIView,
RetrieveUpdateDestroyAPIView,
)
+from rest_framework.response import Response
from swapper import load_model
+from openwisp_users.api.permissions import DjangoModelPermissions
+
from ...mixins import ProtectedAPIMixin
from .filters import (
DeviceGroupListFilter,
@@ -70,6 +74,14 @@ class VpnDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
queryset = Vpn.objects.all()
+class DevicePermission(DjangoModelPermissions):
+ def has_object_permission(self, request, view, obj):
+ perm = super().has_object_permission(request, view, obj)
+ if request.method not in ['PUT', 'PATCH']:
+ return perm
+ return perm and not obj.is_deactivated()
+
+
class DeviceListCreateView(ProtectedAPIMixin, ListCreateAPIView):
"""
Templates: Templates flagged as required will be added automatically
@@ -93,6 +105,33 @@ class DeviceDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
serializer_class = DeviceDetailSerializer
queryset = Device.objects.select_related('config', 'group', 'organization')
+ permission_classes = ProtectedAPIMixin.permission_classes + (DevicePermission,)
+
+
+class DeviceActivateView(ProtectedAPIMixin, GenericAPIView):
+ serializer_class = serializers.Serializer
+ queryset = Device.objects.filter(_is_deactivated=True)
+
+ def post(self, request, *args, **kwargs):
+ device = self.get_object()
+ device.activate()
+ serializer = DeviceDetailSerializer(
+ device, context=self.get_serializer_context()
+ )
+ return Response(serializer.data, status=status.HTTP_200_OK)
+
+
+class DeviceDeactivateView(ProtectedAPIMixin, GenericAPIView):
+ serializer_class = serializers.Serializer
+ queryset = Device.objects.filter(_is_deactivated=False)
+
+ def post(self, request, *args, **kwargs):
+ device = self.get_object()
+ device.deactivate()
+ serializer = DeviceDetailSerializer(
+ device, context=self.get_serializer_context()
+ )
+ return Response(serializer.data, status=status.HTTP_200_OK)
class DeviceGroupListCreateView(ProtectedAPIMixin, ListCreateAPIView):
@@ -240,6 +279,8 @@ def certificate_delete_invalidates_cache(cls, organization_id, common_name):
vpn_detail = VpnDetailView.as_view()
device_list = DeviceListCreateView.as_view()
device_detail = DeviceDetailView.as_view()
+device_activate = DeviceActivateView.as_view()
+device_deactivate = DeviceDeactivateView.as_view()
devicegroup_list = DeviceGroupListCreateView.as_view()
devicegroup_detail = DeviceGroupDetailView.as_view()
devicegroup_commonname = DeviceGroupCommonName.as_view()
diff --git a/openwisp_controller/config/apps.py b/openwisp_controller/config/apps.py
index 846725c2e..84dbc3ba3 100644
--- a/openwisp_controller/config/apps.py
+++ b/openwisp_controller/config/apps.py
@@ -22,6 +22,8 @@
from . import settings as app_settings
from .signals import (
config_backend_changed,
+ config_deactivated,
+ config_deactivating,
config_modified,
device_group_changed,
device_name_changed,
@@ -305,6 +307,18 @@ def enable_cache_invalidation(self):
sender=self.device_model,
dispatch_uid='invalidate_get_device_cache',
)
+ config_deactivated.connect(
+ self.device_model.config_deactivated_clear_management_ip,
+ dispatch_uid='config_deactivated_clear_management_ip',
+ )
+ config_deactivated.connect(
+ DeviceChecksumView.invalidate_get_device_cache_on_config_deactivated,
+ dispatch_uid='config_deactivated_invalidate_get_device_cache',
+ )
+ config_deactivating.connect(
+ DeviceChecksumView.invalidate_checksum_cache,
+ dispatch_uid='config_deactivated_invalidate_get_device_cache',
+ )
config_modified.connect(
DeviceChecksumView.invalidate_checksum_cache,
dispatch_uid='invalidate_checksum_cache',
@@ -359,11 +373,15 @@ def register_dashboard_charts(self):
'applied': '#267126',
'modified': '#ffb442',
'error': '#a72d1d',
+ 'deactivating': '#353c44',
+ 'deactivated': '#000',
},
'labels': {
'applied': _('applied'),
'modified': _('modified'),
'error': _('error'),
+ 'deactivating': _('deactivating'),
+ 'deactivated': _('deactivated'),
},
},
)
diff --git a/openwisp_controller/config/base/config.py b/openwisp_controller/config/base/config.py
index 181cc566a..1aa5e6382 100644
--- a/openwisp_controller/config/base/config.py
+++ b/openwisp_controller/config/base/config.py
@@ -14,7 +14,13 @@
from swapper import get_model_name, load_model
from .. import settings as app_settings
-from ..signals import config_backend_changed, config_modified, config_status_changed
+from ..signals import (
+ config_backend_changed,
+ config_deactivated,
+ config_deactivating,
+ config_modified,
+ config_status_changed,
+)
from ..sortedm2m.fields import SortedManyToManyField
from ..utils import get_default_templates_queryset
from .base import BaseConfig
@@ -62,13 +68,16 @@ class AbstractConfig(BaseConfig):
blank=True,
)
- STATUS = Choices('modified', 'applied', 'error')
+ STATUS = Choices('modified', 'applied', 'error', 'deactivating', 'deactivated')
status = StatusField(
_('configuration status'),
help_text=_(
'"modified" means the configuration is not applied yet; \n'
'"applied" means the configuration is applied successfully; \n'
- '"error" means the configuration caused issues and it was rolled back;'
+ '"error" means the configuration caused issues and it was rolled back; \n'
+ '"deactivating" means the device has been deactivated and the'
+ ' configuration is being removed; \n'
+ '"deactivated" means the configuration has been removed from the device;'
),
)
error_reason = models.CharField(
@@ -105,6 +114,8 @@ def __init__(self, *args, **kwargs):
self._just_created = False
self._initial_status = self.status
self._send_config_modified_after_save = False
+ self._send_config_deactivated = False
+ self._send_config_deactivating = False
self._send_config_status_changed = False
def __str__(self):
@@ -236,9 +247,20 @@ def manage_vpn_clients(cls, action, instance, pk_set, **kwargs):
This method is called from a django signal (m2m_changed)
see config.apps.ConfigConfig.connect_signals
"""
- # execute only after a config has been saved or deleted
- if action not in ['post_add', 'post_remove'] or instance._state.adding:
+ if instance._state.adding or action not in [
+ 'post_add',
+ 'post_remove',
+ 'post_clear',
+ ]:
+ return
+
+ if action == 'post_clear':
+ if instance.is_deactivating_or_deactivated():
+ # If the device is deactivated or in the process of deactivatiing, then
+ # delete all vpn clients and return.
+ instance.vpnclient_set.all().delete()
return
+
vpn_client_model = cls.vpn.through
# coming from signal
if isinstance(pk_set, set):
@@ -331,6 +353,8 @@ def enforce_required_templates(
"""
if action not in ['pre_remove', 'post_clear']:
return False
+ if instance.is_deactivating_or_deactivated():
+ return
raw_data = raw_data or {}
template_query = models.Q(required=True, backend=instance.backend)
# trying to remove a required template will raise PermissionDenied
@@ -467,9 +491,7 @@ def save(self, *args, **kwargs):
result = super().save(*args, **kwargs)
# add default templates if config has just been created
if created:
- default_templates = self.get_default_templates()
- if default_templates:
- self.templates.add(*default_templates)
+ self.add_default_templates()
if self._old_backend and self._old_backend != self.backend:
self._send_config_backend_changed_signal()
self._old_backend = None
@@ -480,9 +502,27 @@ def save(self, *args, **kwargs):
if self._send_config_status_changed:
self._send_config_status_changed_signal()
self._send_config_status_changed = False
+ if self._send_config_deactivating and self.is_deactivating():
+ self._send_config_deactivating_signal()
+ if self._send_config_deactivated and self.is_deactivated():
+ self._send_config_deactivated_signal()
self._initial_status = self.status
return result
+ def add_default_templates(self):
+ default_templates = self.get_default_templates()
+ if default_templates:
+ self.templates.add(*default_templates)
+
+ def is_deactivating_or_deactivated(self):
+ return self.status in ['deactivating', 'deactivated']
+
+ def is_deactivating(self):
+ return self.status == 'deactivating'
+
+ def is_deactivated(self):
+ return self.status == 'deactivated'
+
def _check_changes(self):
current = self._meta.model.objects.only(
'backend', 'config', 'context', 'status'
@@ -520,6 +560,27 @@ def _send_config_modified_signal(self, action):
device=self.device,
)
+ def _send_config_deactivating_signal(self):
+ """
+ Emits ``config_deactivating`` signal.
+ """
+ config_deactivating.send(
+ sender=self.__class__,
+ instance=self,
+ device=self.device,
+ previous_status=self._initial_status,
+ )
+
+ def _send_config_deactivated_signal(self):
+ """
+ Emits ``config_deactivated`` signal.
+ """
+ config_deactivated.send(
+ sender=self.__class__,
+ instance=self,
+ previous_status=self._initial_status,
+ )
+
def _send_config_backend_changed_signal(self):
"""
Emits ``config_backend_changed`` signal.
@@ -539,9 +600,10 @@ def _send_config_status_changed_signal(self):
"""
config_status_changed.send(sender=self.__class__, instance=self)
- def _set_status(self, status, save=True, reason=None):
+ def _set_status(self, status, save=True, reason=None, extra_update_fields=None):
self._send_config_status_changed = True
- update_fields = ['status']
+ extra_update_fields = extra_update_fields or []
+ update_fields = ['status'] + extra_update_fields
# The error reason should be updated when
# 1. the configuration is in "error" status
# 2. the configuration has changed from error status
@@ -563,6 +625,58 @@ def set_status_applied(self, save=True):
def set_status_error(self, save=True, reason=None):
self._set_status('error', save, reason)
+ def set_status_deactivating(self, save=True):
+ """
+ Set Config status as deactivating and
+ clears configuration and templates.
+ """
+ self._send_config_deactivating = True
+ self._set_status('deactivating', save, extra_update_fields=['config'])
+
+ def set_status_deactivated(self, save=True):
+ self._send_config_deactivated = True
+ self._set_status('deactivated', save)
+
+ def deactivate(self):
+ """
+ Clears configuration and templates and set status as deactivating.
+ """
+ # Invalidate cached property before checking checksum.
+ self._invalidate_backend_instance_cache()
+ old_checksum = self.checksum
+ self.config = {}
+ self.set_status_deactivating()
+ self.templates.clear()
+ del self.backend_instance
+ if old_checksum == self.checksum:
+ # Accelerate deactivation if the configuration remains
+ # unchanged (i.e. empty configuration)
+ self.set_status_deactivated()
+
+ def activate(self):
+ """
+ Applies required, default and group templates when device is activated.
+ """
+ # Invalidate cached property before checking checksum.
+ self._invalidate_backend_instance_cache()
+ old_checksum = self.checksum
+ self.add_default_templates()
+ if self.device._get_group():
+ self.device.manage_devices_group_templates(
+ device_ids=self.device.id,
+ old_group_ids=None,
+ group_id=self.device.group_id,
+ )
+ del self.backend_instance
+ if old_checksum == self.checksum:
+ # Accelerate activation if the configuration remains
+ # unchanged (i.e. empty configuration)
+ self.set_status_applied()
+
+ def _invalidate_backend_instance_cache(self):
+ if hasattr(self, 'backend_instance'):
+ del self.backend_instance
+
def _has_device(self):
return hasattr(self, 'device')
diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py
index f849987bc..e3892a08b 100644
--- a/openwisp_controller/config/base/device.py
+++ b/openwisp_controller/config/base/device.py
@@ -1,7 +1,7 @@
from hashlib import md5
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
-from django.db import models
+from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError
+from django.db import models, transaction
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from swapper import get_model_name, load_model
@@ -10,7 +10,13 @@
from openwisp_utils.base import KeyField
from .. import settings as app_settings
-from ..signals import device_group_changed, device_name_changed, management_ip_changed
+from ..signals import (
+ device_activated,
+ device_deactivated,
+ device_group_changed,
+ device_name_changed,
+ management_ip_changed,
+)
from ..validators import device_name_validator, mac_address_validator
from .base import BaseModel
@@ -96,6 +102,10 @@ class AbstractDevice(OrgMixin, BaseModel):
),
)
hardware_id = models.CharField(**(app_settings.HARDWARE_ID_OPTIONS))
+ # This is an internal field which is used to track if
+ # the device has been deactivated. This field should not be changed
+ # directly, use the deactivate() method instead.
+ _is_deactivated = models.BooleanField(default=False)
class Meta:
unique_together = (
@@ -163,6 +173,35 @@ def _get_organization__config_settings(self):
organization=self.organization if hasattr(self, 'organization') else None
)
+ def is_deactivated(self):
+ return self._is_deactivated
+
+ def deactivate(self):
+ if self.is_deactivated():
+ # The device has already been deactivated.
+ # No further operation is required.
+ return
+ with transaction.atomic():
+ if self._has_config():
+ self.config.deactivate()
+ else:
+ self.management_ip = ''
+ self._is_deactivated = True
+ self.save()
+ device_deactivated.send(sender=self.__class__, instance=self)
+
+ def activate(self):
+ if not self.is_deactivated():
+ # The device is already active.
+ # No further operation is required.
+ return
+ with transaction.atomic():
+ if self._has_config():
+ self.config.activate()
+ self._is_deactivated = False
+ self.save()
+ device_activated.send(sender=self.__class__, instance=self)
+
def get_context(self):
config = self._get_config()
return config.get_context()
@@ -247,6 +286,14 @@ def save(self, *args, **kwargs):
if not state_adding:
self._check_changed_fields()
+ def delete(self, using=None, keep_parents=False, check_deactivated=True):
+ if check_deactivated and (
+ not self.is_deactivated()
+ or (self._has_config() and not self.config.is_deactivated())
+ ):
+ raise PermissionDenied('The device must be deactivated prior to deletion')
+ return super().delete(using, keep_parents)
+
def _check_changed_fields(self):
self._get_initial_values_for_checked_fields()
# Execute method for checked for each field in self._changed_checked_fields
@@ -434,3 +481,11 @@ def manage_devices_group_templates(cls, device_ids, old_group_ids, group_id):
old_group = DeviceGroup.objects.get(pk=old_group_id)
old_group_templates = old_group.templates.all()
device.config.manage_group_templates(group_templates, old_group_templates)
+
+ @classmethod
+ def config_deactivated_clear_management_ip(cls, instance, *args, **kwargs):
+ """
+ Clear management IP of the device when the device's config status
+ is changed to 'deactivated'.
+ """
+ cls.objects.filter(pk=instance.device_id).update(management_ip='')
diff --git a/openwisp_controller/config/controller/views.py b/openwisp_controller/config/controller/views.py
index dfbcc40ec..9059d1141 100644
--- a/openwisp_controller/config/controller/views.py
+++ b/openwisp_controller/config/controller/views.py
@@ -54,8 +54,10 @@ def get_object(self, *args, **kwargs):
'organization__created',
'organization__modified',
)
- queryset = self.model.objects.select_related('organization', 'config').defer(
- *defer
+ queryset = (
+ self.model.objects.select_related('organization', 'config')
+ .defer(*defer)
+ .exclude(config__status='deactivated')
)
return get_object_or_404(queryset, *args, **kwargs)
@@ -168,6 +170,14 @@ def invalidate_get_device_cache(cls, instance, **kwargs):
view.get_device.invalidate(view)
logger.debug(f'invalidated view cache for device ID {pk}')
+ @classmethod
+ def invalidate_get_device_cache_on_config_deactivated(cls, instance, **kwargs):
+ """
+ Called from signal receiver which performs cache invalidation
+ when the configuration status is set to "deactivated".
+ """
+ cls.invalidate_get_device_cache(instance=instance.device, **kwargs)
+
@classmethod
def invalidate_checksum_cache(cls, instance, device, **kwargs):
"""
@@ -247,6 +257,11 @@ def post(self, request, *args, **kwargs):
# mantain backward compatibility with old agents
# ("running" was changed to "applied")
status = status if status != 'running' else 'applied'
+ # If the Config.status is "deactivating", then set the
+ # status to "deactivated". This will stop the device
+ # from receiving new configurations.
+ if config.status == 'deactivating':
+ status = 'deactivated'
# call set_status_{status} method on Config model
method_name = f'set_status_{status}'
if status == 'error':
diff --git a/openwisp_controller/config/migrations/0054_device__is_deactivated.py b/openwisp_controller/config/migrations/0054_device__is_deactivated.py
new file mode 100644
index 000000000..3cb2739f6
--- /dev/null
+++ b/openwisp_controller/config/migrations/0054_device__is_deactivated.py
@@ -0,0 +1,17 @@
+# Generated by Django 3.2.20 on 2024-02-29 11:56
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ('config', '0053_vpnclient_secret'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='device',
+ name='_is_deactivated',
+ field=models.BooleanField(default=False),
+ ),
+ ]
diff --git a/openwisp_controller/config/migrations/0055_alter_config_status.py b/openwisp_controller/config/migrations/0055_alter_config_status.py
new file mode 100644
index 000000000..c9ac1c600
--- /dev/null
+++ b/openwisp_controller/config/migrations/0055_alter_config_status.py
@@ -0,0 +1,39 @@
+# Generated by Django 4.2.10 on 2024-03-01 16:35
+
+import model_utils.fields
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("config", "0054_device__is_deactivated"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="config",
+ name="status",
+ field=model_utils.fields.StatusField(
+ choices=[
+ ("modified", "modified"),
+ ("applied", "applied"),
+ ("error", "error"),
+ ("deactivating", "deactivating"),
+ ("deactivated", "deactivated"),
+ ],
+ default="modified",
+ help_text=(
+ '"modified" means the configuration is not applied yet; \n'
+ '"applied" means the configuration is applied successfully; \n'
+ '"error" means the configuration caused issues and it was'
+ ' rolled back; \n"deactivating" means the device has been'
+ ' deactivated and the configuration is being removed; \n'
+ '"deactivated" means the configuration has been removed '
+ 'from the device;'
+ ),
+ max_length=100,
+ no_check_for_status=True,
+ verbose_name="configuration status",
+ ),
+ ),
+ ]
diff --git a/openwisp_controller/config/signals.py b/openwisp_controller/config/signals.py
index 57a661d76..325cfb628 100644
--- a/openwisp_controller/config/signals.py
+++ b/openwisp_controller/config/signals.py
@@ -17,10 +17,26 @@
config_modified.__doc__ = """
Providing arguments: ['instance', 'device', 'config', 'previous_status', 'action']
"""
+config_deactivated = Signal()
+config_deactivated.__doc__ = """
+Providing arguments: ['instance', 'previous_status']
+"""
+config_deactivating = Signal()
+config_deactivating.__doc__ = """
+Providing arguments: ['instance', 'previous_status']
+"""
device_registered = Signal()
device_registered.__doc__ = """
Providing arguments: ['instance', 'is_new']
"""
+device_deactivated = Signal()
+device_deactivated.__doc__ = """
+Providing arguments: ['instance']
+"""
+device_activated = Signal()
+device_activated.__doc__ = """
+Providing arguments: ['instance']
+"""
management_ip_changed = Signal()
management_ip_changed.__doc__ = """
Providing arguments: ['instance', 'management_ip', 'old_management_ip']
diff --git a/openwisp_controller/config/templates/admin/config/device/change_form.html b/openwisp_controller/config/templates/admin/config/device/change_form.html
new file mode 100644
index 000000000..e97999f75
--- /dev/null
+++ b/openwisp_controller/config/templates/admin/config/device/change_form.html
@@ -0,0 +1,34 @@
+{% extends "admin/config/change_form.html" %}
+{% load admin_urls i18n l10n %}
+
+{% block messages %}
+ {{ block.super }}
+ {% if original and original.is_deactivated %}
+
+
{% trans "This device has been deactivated." %}
+
+ {% endif %}
+{% endblock messages %}
+
+{% block content %}
+ {% comment %}
+ Due to HTML's limitation in supporting nested forms, we employ a
+ workaround for activating and deactivating device operations within
+ the change form.
+
+ We utilize a distinct form element (id="act_deact_device_form")
+ specifically for these actions. The form attribute of the submit buttons (Acivate/Deactivate)
+ within the submit-row div references this form. By doing so, we ensure that
+ these actions can be submitted independently without causing any
+ disruption to the device form.
+
+ For further information, refer to: https://www.impressivewebs.com/html5-form-attribute/
+ {% endcomment %}
+ {% url opts|admin_urlname:'changelist' as changelist_url %}
+
+ {{ block.super }}
+{% endblock content %}
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
new file mode 100644
index 000000000..a66130f86
--- /dev/null
+++ b/openwisp_controller/config/templates/admin/config/device/delete_selected_confirmation.html
@@ -0,0 +1,36 @@
+{% extends "admin/delete_selected_confirmation.html" %}
+{% load i18n l10n admin_urls static %}
+
+{% 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 %}
+
{{ deletable_objects|first|unordered_list }}
+
{% 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 %}
+
{{ perms_lacking|unordered_list }}
+ {% endif %}
+{% elif protected %}
+
{% blocktranslate %}Deleting the selected {{ objects_name }} would require deleting the following protected related objects:{% endblocktranslate %}
+
{{ protected|unordered_list }}
+{% 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 %}
+
'
+ )
+
+ def test_device_with_config_change_deactivate_deactivate(self):
+ """
+ This test checks the following things
+ - deactivate button is shown on device's change page
+ - all the fields become readonly on deactivated device
+ - deleting a device is possible once device's config.status is deactivated
+ - activate button is shown on deactivated device
+ """
+ self._create_template(required=True)
+ device = self._create_config(organization=self._get_org()).device
+ path = reverse(f'admin:{self.app_label}_device_change', args=[device.pk])
+ delete_btn_html = self._get_delete_btn_html(device)
+ # Deactivate button is shown instead of delete button
+ response = self.client.get(path)
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(
+ response,
+ self._deactivate_btn_html,
+ )
+ # Verify the inline objects can be added and deleted
+ self.assertContains(response, 'TOTAL_FORMS" value="1"', count=3)
+ self.assertContains(response, '',
+ 22,
+ )
+ # 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.assertNotContains(response, self._deactivate_btn_html)
+ self.assertContains(response, self._activate_btn_html)
+ # Verify adding a new DeviceLocation and DeviceConnection is not allowed
+ self.assertContains(response, '-TOTAL_FORMS" value="0"', count=2)
+ # Verify deleting existing Inline objects is not allowed
+ self.assertNotContains(response, 'The device {device_name}'
+ f' was {html_method}ed successfully.'
+ )
+ multiple_success_message_html = (
+ f'