Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature] Implemented device deactivation and reactivation #625 #840

Open
wants to merge 41 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
d0ee324
[feature] Implemented device deactivation and reactivation #625
pandafy Feb 29, 2024
a8aa0b5
[feature] Do not delete related Certs when device is deactivated
pandafy Feb 29, 2024
6bdffdc
[feature] Set device status to deactivated if current status is deact…
pandafy Feb 29, 2024
259f31f
[fix] Fixed readonlyfields for ConfigInline
pandafy Feb 29, 2024
f47edd2
[feature] Return 404 checksum for deactivated devices
pandafy Feb 29, 2024
8c60f7d
[tests] Added test for device controller views
pandafy Mar 1, 2024
778a729
[feature] Added activate and deactivate button on the device page
pandafy Mar 1, 2024
a08c85e
[chores] Added migrations for Config.status
pandafy Mar 1, 2024
0af9d2d
[chores] Added migrations for sample app
pandafy Mar 1, 2024
834c822
[tests] Fixed tests
pandafy Mar 1, 2024
df822ce
[qa] Fixed QA issues
pandafy Mar 1, 2024
5f2a0a3
[fix] Fixed issues with DeviceAdmin.get_extra_context
pandafy Mar 1, 2024
318b169
[feature] Added "config_deactivating" signal
pandafy Mar 1, 2024
02395be
[admin] Added error handling to activate and deactivate actions
pandafy Mar 4, 2024
332cf50
[change] Show delete action after deactivate action
pandafy Mar 5, 2024
8bc75f1
[feature] Emit device_deactivated signal when device is deactivated
pandafy Mar 5, 2024
eb363d1
[fix] Fixed activate button on top submitting device-form
pandafy Mar 5, 2024
b2e8325
[req-changes] Added deactivated warning message to device's change page
pandafy Mar 7, 2024
091de5e
[change] Upadted Config.status helptext
pandafy Mar 7, 2024
6da3756
[fix] Don't show any extra form on deactivated devices
pandafy Mar 7, 2024
eb6c36d
[tests] Added admin tests for device change page
pandafy Mar 7, 2024
7b6ab6b
[refactor] Refactored logic for sending activate/deactivate message t…
pandafy Mar 7, 2024
8dc6096
[tests] Added test for device changelist admin action
pandafy Mar 7, 2024
acde704
[temp] Upgraded openwisp-utils
pandafy Mar 7, 2024
8df3e0b
[chores] Added migrations
pandafy Mar 7, 2024
a6148b4
[chores] Upgraded openwisp-utils
pandafy Mar 7, 2024
5339254
[chores] Fixed formatting
pandafy Mar 7, 2024
2710b46
[req-changes] Updated config status on device admin
pandafy Apr 4, 2024
b6b3fac
[req-changes] Refactored code for device status message
pandafy Apr 4, 2024
6942cac
[req-changes] Display warning when user delete active devices from ad…
pandafy Apr 5, 2024
3e8bcf7
[chores] Miscellaneous uppdates
pandafy Aug 1, 2024
3c6b551
[fix] Fixed selenium test
pandafy Aug 1, 2024
de5a90a
WIP: 6eea7bc2 [fix] Fixed selenium test
pandafy Aug 2, 2024
bdb4485
[fix] Fixed implementation of submit inline buttons
pandafy Aug 2, 2024
26f058a
[tests] Fixed tests
pandafy Aug 2, 2024
e2edfb6
[req-changes] Formatted code and updated docs
pandafy Aug 8, 2024
ecccf77
[change] Clear management IP when device is deactivated
pandafy Aug 8, 2024
1d43fb5
[change] Added API endpoints for activating/deactivating device
pandafy Aug 8, 2024
56e801c
[change] Updated device delete API endpoint
pandafy Aug 8, 2024
5d7f122
[change] Disable API operations on deactivated devices
pandafy Aug 9, 2024
13c6783
[tests] Fixed tests
pandafy Aug 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ jobs:
pip install -U pip wheel setuptools
pip install -U -r requirements-test.txt
pip install -U -e .
pip install -U --force-reinstall --no-deps https://github.com/openwisp/openwisp-utils/tarball/extendable-submit-line
pip install ${{ matrix.django-version }}

- name: QA checks
Expand Down
37 changes: 37 additions & 0 deletions docs/developer/utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,43 @@ 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 deactivated.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this emitted right when the device is flagged for deactivation or when the deactivation is complete?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is sent when the device is flagged for deactivation.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, please clarify this in the text when you can.


.. _config_backend_changed:

``config_backend_changed``
Expand Down
200 changes: 198 additions & 2 deletions openwisp_controller/config/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -390,6 +416,7 @@ class Meta(BaseForm.Meta):


class ConfigInline(
DeactivatedDeviceReadOnlyMixin,
MultitenantAdminMixin,
TimeReadonlyAdminMixin,
SystemDefinedVariableMixin,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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(
'<a href="{}">{}</a>',
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
Expand All @@ -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')

Expand Down Expand Up @@ -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(
{
'html': mark_safe(
'<input class="default" type="submit"'
f' value="{_("Activate")}" form="act_deact_device_form">'
)
}
)
else:
ctx['additional_buttons'].append(
{
'html': mark_safe(
'<p class="deletelink-box">'
'<input class="deletelink" type="submit"'
f' value="{_("Deactivate")}" form="act_deact_device_form">'
'</p>'
)
}
)
ctx.update(
{
'relevant_template_url': reverse(
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions openwisp_controller/config/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -261,6 +262,7 @@ class Meta(BaseMeta):
'key',
'last_ip',
'management_ip',
'is_deactivated',
'model',
'os',
'system',
Expand Down
10 changes: 10 additions & 0 deletions openwisp_controller/config/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ def get_api_urls(api_views):
api_views.device_detail,
name='device_detail',
),
path(
'controller/device/<str:pk>/activate/',
api_views.device_activate,
name='device_activate',
),
path(
'controller/device/<str:pk>/deactivate/',
api_views.device_deactivate,
name='device_deactivate',
),
path(
'controller/group/',
api_views.devicegroup_list,
Expand Down
Loading