Skip to content

Commit 6a99044

Browse files
pandafynemesifier
andauthored
[feature] Added support for device deactivation #560
- Do not collect device metrics if device is deactivated - Disable monitoring checks for deactivated devices - Set DeviceMonitoring status to unknown on device activation - Documented "deactivated" status Closes #560 --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io>
1 parent 242e67e commit 6a99044

File tree

22 files changed

+208
-24
lines changed

22 files changed

+208
-24
lines changed

.github/workflows/ci.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ jobs:
6565
pip install -r requirements-test.txt
6666
pip install -U -I -e .
6767
pip uninstall -y django
68-
pip install ${{ matrix.django-version }}
68+
pip install -U ${{ matrix.django-version }}
6969
sudo npm install -g jshint stylelint
7070
7171
# start influxdb

docs/user/device-health-status.rst

+5
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,8 @@ been crossed).
3636

3737
Example: ping is by default a critical metric which is expected to be
3838
always 1 (reachable).
39+
40+
``DEACTIVATED``
41+
---------------
42+
43+
The device is deactivated. All active and passive checks are disabled.

docs/user/settings.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ By default, if devices are not reachable by pings they are flagged as
337337
============ ==========================================================
338338
**type**: ``dict``
339339
**default**: ``{'unknown': 'unknown', 'ok': 'ok', 'problem': 'problem',
340-
'critical': 'critical'}``
340+
'critical': 'critical', 'deactivated': 'deactivated'}``
341341
============ ==========================================================
342342

343343
This setting allows to change the health status labels, for example, if we

openwisp_monitoring/check/base/models.py

+3
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ def check_instance(self):
9393
def perform_check(self, store=True):
9494
"""Initializes check instance and calls the check method."""
9595
if (
96+
hasattr(self.content_object, 'is_deactivated')
97+
and self.content_object.is_deactivated()
98+
) or (
9699
hasattr(self.content_object, 'organization_id')
97100
and self.content_object.organization.is_active is False
98101
):

openwisp_monitoring/check/tests/test_models.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def test_device_deleted(self):
142142
self.assertEqual(Check.objects.count(), 0)
143143
d = self._create_device(organization=self._create_org())
144144
self.assertEqual(Check.objects.count(), 3)
145-
d.delete()
145+
d.delete(check_deactivated=False)
146146
self.assertEqual(Check.objects.count(), 0)
147147

148148
def test_config_modified_device_problem(self):
@@ -290,6 +290,16 @@ def test_device_organization_disabled_check_not_performed(self):
290290
check.perform_check()
291291
mocked_check.assert_not_called()
292292

293+
def test_deactivated_device_check_not_performed(self):
294+
config = self._create_config(status='modified', organization=self._create_org())
295+
self.assertEqual(Check.objects.filter(is_active=True).count(), 3)
296+
config.device.deactivate()
297+
config.set_status_deactivated()
298+
check = Check.objects.filter(check_type=self._CONFIG_APPLIED).first()
299+
with patch(f'{self._CONFIG_APPLIED}.check') as mocked_check:
300+
check.perform_check()
301+
mocked_check.assert_not_called()
302+
293303
def test_config_check_problem_with_interval(self):
294304
self._create_admin()
295305
d = self._create_device(organization=self._create_org())

openwisp_monitoring/device/admin.py

+52-11
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
)
2222
from swapper import load_model
2323

24+
from openwisp_controller.config.admin import DeactivatedDeviceReadOnlyMixin
2425
from openwisp_controller.config.admin import DeviceAdmin as BaseDeviceAdmin
2526
from openwisp_users.multitenancy import MultitenantAdminMixin
2627
from openwisp_utils.admin import ReadOnlyAdmin
@@ -58,30 +59,68 @@ def full_clean(self):
5859

5960

6061
class InlinePermissionMixin:
62+
"""
63+
Manages permissions for the inline objects.
64+
65+
This mixin class handles the permissions for the inline objects.
66+
It checks if the user has permission to modify the main object
67+
(e.g., view_model, add_model, change_model, or delete_model),
68+
and if not, it checks if the user has permission to modify the
69+
inline object (e.g., view_model_inline, add_model_inline,
70+
change_model_inline, or delete_model_inline).
71+
"""
72+
6173
def has_add_permission(self, request, obj=None):
62-
# User will be able to add objects from inline even
63-
# if it only has permission to add a model object
64-
return super().has_add_permission(request, obj) or request.user.has_perm(
74+
return super(admin.options.InlineModelAdmin, self).has_add_permission(
75+
request
76+
) or request.user.has_perm(
6577
f'{self.model._meta.app_label}.add_{self.inline_permission_suffix}'
6678
)
6779

6880
def has_change_permission(self, request, obj=None):
69-
return super().has_change_permission(request, obj) or request.user.has_perm(
81+
return super(admin.options.InlineModelAdmin, self).has_change_permission(
82+
request, obj
83+
) or request.user.has_perm(
7084
f'{self.model._meta.app_label}.change_{self.inline_permission_suffix}'
7185
)
7286

7387
def has_view_permission(self, request, obj=None):
74-
return super().has_view_permission(request, obj) or request.user.has_perm(
88+
return super(admin.options.InlineModelAdmin, self).has_view_permission(
89+
request, obj
90+
) or request.user.has_perm(
7591
f'{self.model._meta.app_label}.view_{self.inline_permission_suffix}'
7692
)
7793

7894
def has_delete_permission(self, request, obj=None):
79-
return super().has_delete_permission(request, obj) or request.user.has_perm(
95+
return super(admin.options.InlineModelAdmin, self).has_delete_permission(
96+
request, obj
97+
) or request.user.has_perm(
8098
f'{self.model._meta.app_label}.delete_{self.inline_permission_suffix}'
8199
)
82100

83101

84-
class CheckInline(InlinePermissionMixin, GenericStackedInline):
102+
class DeactivatedDeviceReadOnlyInlinePermissionMixin(
103+
DeactivatedDeviceReadOnlyMixin,
104+
InlinePermissionMixin,
105+
):
106+
def has_add_permission(self, request, obj=None):
107+
perm = super().has_add_permission(request, obj)
108+
return self._has_permission(request, obj, perm)
109+
110+
def has_change_permission(self, request, obj=None):
111+
perm = super().has_change_permission(request, obj)
112+
return self._has_permission(request, obj, perm)
113+
114+
def has_view_permission(self, request, obj=None):
115+
perm = super().has_view_permission(request, obj)
116+
return self._has_permission(request, obj, perm)
117+
118+
def has_delete_permission(self, request, obj=None):
119+
perm = super().has_delete_permission(request, obj)
120+
return self._has_permission(request, obj, perm)
121+
122+
123+
class CheckInline(DeactivatedDeviceReadOnlyInlinePermissionMixin, GenericStackedInline):
85124
model = Check
86125
extra = 0
87126
formset = CheckInlineFormSet
@@ -152,7 +191,9 @@ def get_queryset(self, request):
152191
return super().get_queryset(request).order_by('created')
153192

154193

155-
class MetricInline(InlinePermissionMixin, NestedGenericStackedInline):
194+
class MetricInline(
195+
DeactivatedDeviceReadOnlyInlinePermissionMixin, NestedGenericStackedInline
196+
):
156197
model = Metric
157198
extra = 0
158199
inlines = [AlertSettingsInline]
@@ -205,7 +246,7 @@ def has_delete_permission(self, request, obj=None):
205246

206247

207248
class DeviceAdmin(BaseDeviceAdmin, NestedModelAdmin):
208-
change_form_template = 'admin/config/device/change_form.html'
249+
change_form_template = 'admin/monitoring/device/change_form.html'
209250
list_filter = ['monitoring__status'] + BaseDeviceAdmin.list_filter
210251
list_select_related = ['monitoring'] + list(BaseDeviceAdmin.list_select_related)
211252
list_display = list(BaseDeviceAdmin.list_display)
@@ -302,7 +343,7 @@ def get_fields(self, request, obj=None):
302343
fields = list(super().get_fields(request, obj))
303344
if obj and not obj._state.adding:
304345
fields.insert(fields.index('last_ip'), 'health_status')
305-
if not obj or obj.monitoring.status in ['ok', 'unknown']:
346+
if not obj or obj.monitoring.status in ['ok', 'unknown', 'deactivated']:
306347
return fields
307348
fields.insert(fields.index('health_status') + 1, 'health_checks')
308349
return fields
@@ -415,7 +456,7 @@ class WiFiSessionInline(WifiSessionAdminHelperMixin, admin.TabularInline):
415456
readonly_fields = fields
416457
can_delete = False
417458
extra = 0
418-
template = 'admin/config/device/wifisession_tabular.html'
459+
template = 'admin/monitoring/device/wifisession_tabular.html'
419460

420461
class Media:
421462
css = {'all': ('monitoring/css/wifi-sessions.css',)}

openwisp_monitoring/device/api/views.py

+7
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from django.core.exceptions import ValidationError
99
from django.db.models import Count, Q
1010
from django.db.models.functions import Round
11+
from django.http import Http404
1112
from django.utils.timezone import now
1213
from django.utils.translation import gettext_lazy as _
1314
from django_filters.rest_framework import DjangoFilterBackend
@@ -113,6 +114,7 @@ class DeviceMetricView(
113114
queryset = (
114115
DeviceData.objects.filter(organization__is_active=True)
115116
.only(
117+
'_is_deactivated',
116118
'id',
117119
'key',
118120
)
@@ -173,6 +175,11 @@ def get_object(self, pk):
173175

174176
def post(self, request, pk):
175177
self.instance = self.get_object(pk)
178+
if self.instance._is_deactivated:
179+
# If the device is deactivated, do not accept data.
180+
# We don't use "Device.is_deactivated()" to avoid
181+
# generating query for the related config.
182+
raise Http404
176183
self.instance.data = request.data
177184
# validate incoming data
178185
try:

openwisp_monitoring/device/apps.py

+29-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
from django.utils.translation import gettext_lazy as _
1010
from swapper import get_model_name, load_model
1111

12-
from openwisp_controller.config.signals import checksum_requested, config_status_changed
12+
from openwisp_controller.config.signals import (
13+
checksum_requested,
14+
config_status_changed,
15+
device_activated,
16+
device_deactivated,
17+
)
1318
from openwisp_controller.connection import settings as connection_settings
1419
from openwisp_controller.connection.signals import is_working_changed
1520
from openwisp_utils.admin_theme import (
@@ -74,6 +79,7 @@ def connect_device_signals(self):
7479

7580
Device = load_model('config', 'Device')
7681
DeviceData = load_model('device_monitoring', 'DeviceData')
82+
DeviceMonitoring = load_model('device_monitoring', 'DeviceMonitoring')
7783
DeviceLocation = load_model('geo', 'DeviceLocation')
7884
Metric = load_model('monitoring', 'Metric')
7985
Chart = load_model('monitoring', 'Chart')
@@ -145,6 +151,26 @@ def connect_device_signals(self):
145151
sender=Organization,
146152
dispatch_uid='post_save_organization_disabled_monitoring',
147153
)
154+
device_deactivated.connect(
155+
DeviceMonitoring.handle_deactivated_device,
156+
sender=Device,
157+
dispatch_uid='device_deactivated_update_devicemonitoring',
158+
)
159+
device_deactivated.connect(
160+
DeviceMetricView.invalidate_get_device_cache,
161+
sender=Device,
162+
dispatch_uid=('device_deactivated_invalidate_view_device_cache'),
163+
)
164+
device_activated.connect(
165+
DeviceMonitoring.handle_activated_device,
166+
sender=Device,
167+
dispatch_uid='device_activated_update_devicemonitoring',
168+
)
169+
device_activated.connect(
170+
DeviceMetricView.invalidate_get_device_cache,
171+
sender=Device,
172+
dispatch_uid=('device_activated_invalidate_view_device_cache'),
173+
)
148174

149175
@classmethod
150176
def device_post_save_receiver(cls, instance, created, **kwargs):
@@ -330,12 +356,14 @@ def register_dashboard_items(self):
330356
'problem': '#ffb442',
331357
'critical': '#a72d1d',
332358
'unknown': '#353c44',
359+
'deactivated': '#000',
333360
},
334361
'labels': {
335362
'ok': app_settings.HEALTH_STATUS_LABELS['ok'],
336363
'problem': app_settings.HEALTH_STATUS_LABELS['problem'],
337364
'critical': app_settings.HEALTH_STATUS_LABELS['critical'],
338365
'unknown': app_settings.HEALTH_STATUS_LABELS['unknown'],
366+
'deactivated': app_settings.HEALTH_STATUS_LABELS['deactivated'],
339367
},
340368
},
341369
)

openwisp_monitoring/device/base/models.py

+30-1
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ class AbstractDeviceMonitoring(TimeStampedEditableModel):
338338
('ok', _(app_settings.HEALTH_STATUS_LABELS['ok'])),
339339
('problem', _(app_settings.HEALTH_STATUS_LABELS['problem'])),
340340
('critical', _(app_settings.HEALTH_STATUS_LABELS['critical'])),
341+
('deactivated', _(app_settings.HEALTH_STATUS_LABELS['deactivated'])),
341342
)
342343
status = StatusField(
343344
_('health status'),
@@ -346,12 +347,14 @@ class AbstractDeviceMonitoring(TimeStampedEditableModel):
346347
'"{0}" means the device has been recently added; \n'
347348
'"{1}" means the device is operating normally; \n'
348349
'"{2}" means the device is having issues but it\'s still reachable; \n'
349-
'"{3}" means the device is not reachable or in critical conditions;'
350+
'"{3}" means the device is not reachable or in critical conditions;\n'
351+
'"{4}" means the device is deactivated;'
350352
).format(
351353
app_settings.HEALTH_STATUS_LABELS['unknown'],
352354
app_settings.HEALTH_STATUS_LABELS['ok'],
353355
app_settings.HEALTH_STATUS_LABELS['problem'],
354356
app_settings.HEALTH_STATUS_LABELS['critical'],
357+
app_settings.HEALTH_STATUS_LABELS['deactivated'],
355358
),
356359
)
357360

@@ -444,6 +447,32 @@ def handle_disabled_organization(cls, organization_id):
444447
status='unknown'
445448
)
446449

450+
@classmethod
451+
def handle_deactivated_device(cls, instance, **kwargs):
452+
"""Handles the deactivation of a device
453+
454+
Sets the device's monitoring status to 'deactivated'
455+
456+
Parameters: - instance (Device): The device object
457+
which is deactivated
458+
459+
Returns: - None
460+
"""
461+
cls.objects.filter(device_id=instance.id).update(status='deactivated')
462+
463+
@classmethod
464+
def handle_activated_device(cls, instance, **kwargs):
465+
"""Handles the activation of a deactivated device
466+
467+
Sets the device's monitoring status to 'unknown'
468+
469+
Parameters: - instance (Device): The device object
470+
which is deactivated
471+
472+
Returns: - None
473+
"""
474+
cls.objects.filter(device_id=instance.id).update(status='unknown')
475+
447476
@classmethod
448477
def _get_critical_metric_keys(cls):
449478
return [metric['key'] for metric in get_critical_device_metrics()]

openwisp_monitoring/device/migrations/0001_squashed_0002_devicemonitoring.py

+4
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,10 @@ class Migration(migrations.Migration):
8888
'critical',
8989
_(app_settings.HEALTH_STATUS_LABELS['critical']),
9090
),
91+
(
92+
'deactivated',
93+
_(app_settings.HEALTH_STATUS_LABELS['deactivated']),
94+
),
9195
],
9296
default='unknown',
9397
db_index=True,

openwisp_monitoring/device/settings.py

+2
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,15 @@ def get_health_status_labels():
2727
'ok': 'ok',
2828
'problem': 'problem',
2929
'critical': 'critical',
30+
'deactivated': 'deactivated',
3031
},
3132
)
3233
try:
3334
assert 'unknown' in labels
3435
assert 'ok' in labels
3536
assert 'problem' in labels
3637
assert 'critical' in labels
38+
assert 'deactivated' in labels
3739
except AssertionError as e: # pragma: no cover
3840
raise ImproperlyConfigured(
3941
'OPENWISP_MONITORING_HEALTH_STATUS_LABELS must contain the following '

openwisp_monitoring/device/static/monitoring/css/device-map.css

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
.health-deactivated {
2+
color: #000;
3+
}
14
.health-unknown {
25
color: grey;
36
}

openwisp_monitoring/device/static/monitoring/css/monitoring.css

+3
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ td.field-health_status,
3838
font-weight: bold;
3939
text-transform: uppercase;
4040
}
41+
.health-deactivated {
42+
color: #000;
43+
}
4144
.health-unknown {
4245
color: grey;
4346
}

openwisp_monitoring/device/static/monitoring/js/device-map.js

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
const loadingOverlay = $('#device-map-container .ow-loading-spinner');
66
const localStorageKey = 'ow-map-shown';
77
const mapContainer = $('#device-map-container');
8-
const statuses = ['critical', 'problem', 'ok', 'unknown'];
8+
const statuses = ['critical', 'problem', 'ok', 'unknown', 'deactivated'];
99
const colors = {
1010
ok: '#267126',
1111
problem: '#ffb442',
1212
critical: '#a72d1d',
1313
unknown: '#353c44',
14+
deactivated: '#0000',
1415
};
1516
const getLocationDeviceUrl = function (pk) {
1617
return window._owGeoMapConfig.locationDeviceUrl.replace('000', pk);

openwisp_monitoring/device/templates/admin/config/device/change_form.html openwisp_monitoring/device/templates/admin/monitoring/device/change_form.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{% extends "admin/config/change_form.html" %}
1+
{% extends "admin/config/device/change_form.html" %}
22
{% load i18n static %}
33
{% block after_field_sets %}
44
{% if not add and device_data %}

0 commit comments

Comments
 (0)