• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

openwisp / openwisp-monitoring / 21801692450

08 Feb 2026 04:44PM UTC coverage: 98.429% (-0.1%) from 98.561%
21801692450

Pull #738

github

web-flow
Merge 4e08f7c01 into 331fca2bf
Pull Request #738: [gsoc25] Add indoor maps, enhanced dashboard map UI, shareable URLs and real-time device position updates

3885 of 3947 relevant lines covered (98.43%)

10.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.43
openwisp_monitoring/device/apps.py
1
import json
11✔
2
from urllib.parse import urljoin
11✔
3

4
from django.apps import AppConfig
11✔
5
from django.conf import settings
11✔
6
from django.core.cache import cache
11✔
7
from django.db.models import Count
11✔
8
from django.db.models.signals import post_delete, post_save
11✔
9
from django.urls import reverse_lazy
11✔
10
from django.utils.translation import gettext_lazy as _
11✔
11
from swapper import get_model_name, load_model
11✔
12

13
from openwisp_controller.config.signals import (
11✔
14
    config_status_changed,
15
    device_activated,
16
    device_deactivated,
17
)
18
from openwisp_controller.connection import settings as connection_settings
11✔
19
from openwisp_controller.connection.signals import is_working_changed
11✔
20
from openwisp_utils.admin_theme import (
11✔
21
    register_dashboard_chart,
22
    register_dashboard_template,
23
)
24
from openwisp_utils.admin_theme.menu import register_menu_subitem
11✔
25

26
from ..check import settings as check_settings
11✔
27
from ..monitoring.signals import threshold_crossed
11✔
28
from ..settings import MONITORING_API_BASEURL, MONITORING_API_URLCONF
11✔
29
from ..utils import transaction_on_commit
11✔
30
from . import settings as app_settings
11✔
31
from .signals import device_metrics_received, health_status_changed
11✔
32
from .utils import (
11✔
33
    get_device_cache_key,
34
    manage_default_retention_policy,
35
    manage_short_retention_policy,
36
)
37

38

39
class DeviceMonitoringConfig(AppConfig):
11✔
40
    name = "openwisp_monitoring.device"
11✔
41
    label = "device_monitoring"
11✔
42
    verbose_name = _("Device Monitoring")
11✔
43

44
    def ready(self):
11✔
45
        manage_default_retention_policy()
11✔
46
        manage_short_retention_policy()
11✔
47
        self.connect_is_working_changed()
11✔
48
        self.connect_device_signals()
11✔
49
        self.connect_config_status_changed()
11✔
50
        self.connect_wifi_client_signals()
11✔
51
        self.connect_offline_device_close_wifisession()
11✔
52
        self.connect_check_signals()
11✔
53
        self.device_recovery_detection()
11✔
54
        self.set_update_config_model()
11✔
55
        self.register_dashboard_items()
11✔
56
        self.register_menu_groups()
11✔
57
        self.add_connection_ignore_notification_reasons()
11✔
58

59
    def connect_check_signals(self):
11✔
60
        from django.db.models.signals import post_delete, post_save
11✔
61
        from swapper import load_model
11✔
62

63
        Check = load_model("check", "Check")
11✔
64
        DeviceMonitoring = load_model("device_monitoring", "DeviceMonitoring")
11✔
65

66
        post_save.connect(
11✔
67
            DeviceMonitoring.handle_critical_metric,
68
            sender=Check,
69
            dispatch_uid="check_post_save_receiver",
70
        )
71
        post_delete.connect(
11✔
72
            DeviceMonitoring.handle_critical_metric,
73
            sender=Check,
74
            dispatch_uid="check_post_delete_receiver",
75
        )
76

77
    def connect_device_signals(self):
11✔
78
        from .api.views import DeviceMetricView
11✔
79

80
        Device = load_model("config", "Device")
11✔
81
        DeviceData = load_model("device_monitoring", "DeviceData")
11✔
82
        DeviceMonitoring = load_model("device_monitoring", "DeviceMonitoring")
11✔
83
        DeviceLocation = load_model("geo", "DeviceLocation")
11✔
84
        Metric = load_model("monitoring", "Metric")
11✔
85
        Chart = load_model("monitoring", "Chart")
11✔
86
        Organization = load_model("openwisp_users", "Organization")
11✔
87

88
        post_save.connect(
11✔
89
            self.device_post_save_receiver,
90
            sender=Device,
91
            dispatch_uid="device_post_save_receiver",
92
        )
93
        post_save.connect(
11✔
94
            DeviceData.invalidate_cache,
95
            sender=Device,
96
            dispatch_uid="post_save_device_invalidate_devicedata_cache",
97
        )
98
        post_save.connect(
11✔
99
            DeviceData.invalidate_cache,
100
            sender=DeviceLocation,
101
            dispatch_uid="post_save_devicelocation_invalidate_devicedata_cache",
102
        )
103
        post_save.connect(
11✔
104
            DeviceMetricView.invalidate_get_charts_cache,
105
            sender=Metric,
106
            dispatch_uid="metric_post_save_invalidate_view_charts_cache",
107
        )
108
        post_save.connect(
11✔
109
            DeviceMetricView.invalidate_get_charts_cache,
110
            sender=Chart,
111
            dispatch_uid="chart_post_save_invalidate_view_charts_cache",
112
        )
113
        post_delete.connect(
11✔
114
            self.device_post_delete_receiver,
115
            sender=Device,
116
            dispatch_uid="device_post_delete_receiver",
117
        )
118
        post_delete.connect(
11✔
119
            DeviceMetricView.invalidate_get_device_cache,
120
            sender=Device,
121
            dispatch_uid="device_post_delete_invalidate_view_device_cache",
122
        )
123
        post_delete.connect(
11✔
124
            DeviceMetricView.invalidate_get_charts_cache,
125
            sender=Device,
126
            dispatch_uid="device_post_delete_invalidate_view_charts_cache",
127
        )
128
        post_delete.connect(
11✔
129
            DeviceMetricView.invalidate_get_charts_cache,
130
            sender=Metric,
131
            dispatch_uid="metric_post_delete_invalidate_view_charts_cache",
132
        )
133
        post_delete.connect(
11✔
134
            DeviceMetricView.invalidate_get_charts_cache,
135
            sender=Chart,
136
            dispatch_uid="chart_post_delete_invalidate_view_charts_cache",
137
        )
138
        post_delete.connect(
11✔
139
            DeviceData.invalidate_cache,
140
            sender=Device,
141
            dispatch_uid="post_delete_device_invalidate_devicedata_cache",
142
        )
143
        post_delete.connect(
11✔
144
            DeviceData.invalidate_cache,
145
            sender=DeviceLocation,
146
            dispatch_uid="post_delete_devicelocation_invalidate_devicedata_cache",
147
        )
148
        post_save.connect(
11✔
149
            self.organization_post_save_receiver,
150
            sender=Organization,
151
            dispatch_uid="post_save_organization_disabled_monitoring",
152
        )
153
        device_deactivated.connect(
11✔
154
            DeviceMonitoring.handle_deactivated_device,
155
            sender=Device,
156
            dispatch_uid="device_deactivated_update_devicemonitoring",
157
        )
158
        device_deactivated.connect(
11✔
159
            DeviceMetricView.invalidate_get_device_cache,
160
            sender=Device,
161
            dispatch_uid="device_deactivated_invalidate_view_device_cache",
162
        )
163
        device_activated.connect(
11✔
164
            DeviceMonitoring.handle_activated_device,
165
            sender=Device,
166
            dispatch_uid="device_activated_update_devicemonitoring",
167
        )
168
        device_activated.connect(
11✔
169
            DeviceMetricView.invalidate_get_device_cache,
170
            sender=Device,
171
            dispatch_uid="device_activated_invalidate_view_device_cache",
172
        )
173

174
    @classmethod
11✔
175
    def device_post_save_receiver(cls, instance, created, **kwargs):
11✔
176
        if created:
11✔
177
            DeviceMonitoring = load_model("device_monitoring", "DeviceMonitoring")
11✔
178
            DeviceMonitoring.objects.create(device=instance)
11✔
179

180
    @classmethod
11✔
181
    def device_post_delete_receiver(cls, instance, **kwargs):
11✔
182
        DeviceData = load_model("device_monitoring", "DeviceData")
11✔
183
        instance.__class__ = DeviceData
11✔
184
        instance.checks.all().delete()
11✔
185
        instance.metrics.all().delete()
11✔
186

187
    @classmethod
11✔
188
    def organization_post_save_receiver(cls, instance, *args, **kwargs):
11✔
189
        if instance.is_active is False:
11✔
190
            from .tasks import handle_disabled_organization
11✔
191

192
            handle_disabled_organization.delay(str(instance.id))
11✔
193

194
    def device_recovery_detection(self):
11✔
195
        if not app_settings.DEVICE_RECOVERY_DETECTION:
11✔
196
            return
11✔
197

198
        DeviceData = load_model("device_monitoring", "DeviceData")
11✔
199
        DeviceMonitoring = load_model("device_monitoring", "DeviceMonitoring")
11✔
200
        health_status_changed.connect(
11✔
201
            self.manage_device_recovery_cache_key,
202
            sender=DeviceMonitoring,
203
            dispatch_uid="recovery_health_status_changed",
204
        )
205
        device_metrics_received.connect(
11✔
206
            self.trigger_device_recovery_checks,
207
            sender=DeviceData,
208
            dispatch_uid="recovery_device_metrics_received",
209
        )
210

211
    @classmethod
11✔
212
    def manage_device_recovery_cache_key(cls, instance, status, **kwargs):
11✔
213
        """Returns a cache key string.
214

215
        It sets the ``cache_key`` as 1 when device ``health_status`` goes
216
        to ``critical`` and deletes the ``cache_key`` when device recovers
217
        from ``critical`` state
218
        """
219
        cache_key = get_device_cache_key(device=instance.device)
11✔
220
        if status == "critical":
11✔
221
            cache.set(cache_key, 1, timeout=None)
11✔
222
        else:
223
            cache.delete(cache_key)
11✔
224

225
    @classmethod
11✔
226
    def trigger_device_recovery_checks(cls, instance, **kwargs):
11✔
227
        from .tasks import trigger_device_critical_checks
11✔
228

229
        # Cache is managed by "manage_device_recovery_cache_key".
230
        if cache.get(get_device_cache_key(device=instance), False):
11✔
231
            transaction_on_commit(
11✔
232
                lambda: trigger_device_critical_checks.delay(pk=instance.pk)
233
            )
234

235
    @classmethod
11✔
236
    def connect_is_working_changed(cls):
11✔
237
        is_working_changed.connect(
11✔
238
            cls.is_working_changed_receiver,
239
            sender=load_model("connection", "DeviceConnection"),
240
            dispatch_uid="is_working_changed_monitoring",
241
        )
242

243
    @classmethod
11✔
244
    def is_working_changed_receiver(
11✔
245
        cls, instance, is_working, old_is_working, failure_reason, **kwargs
246
    ):
247
        from .tasks import trigger_device_critical_checks
11✔
248

249
        Check = load_model("check", "Check")
11✔
250
        device = instance.device
11✔
251
        device_monitoring = device.monitoring
11✔
252
        # if old_is_working is None, it's a new device connection which wasn't
253
        # ever used yet, so nothing is really changing and we don't need to do anything
254
        if old_is_working is None and is_working:
11✔
255
            return
11✔
256
        # if device is down because of connectivity issues, it's probably due
257
        # to reboot caused by firmware upgrade, avoid notifications
258
        ignored_failures = ["Unable to connect", "timed out"]
11✔
259
        for message in ignored_failures:
11✔
260
            if message in failure_reason:
11✔
261
                device_monitoring.save()
11✔
262
                return
11✔
263
        initial_status = device_monitoring.status
11✔
264
        status = "ok" if is_working else "problem"
11✔
265
        # do not send notifications if recovery made after firmware upgrade
266
        if status == initial_status == "ok":
11✔
267
            device_monitoring.save()
11✔
268
            return
11✔
269
        if not is_working:
11✔
270
            if initial_status == "ok":
11✔
271
                transaction_on_commit(
11✔
272
                    lambda: trigger_device_critical_checks.delay(pk=device.pk)
273
                )
274
        else:
275
            # if checks exist trigger them else, set status as 'ok'
276
            if Check.objects.filter(object_id=instance.device.pk).exists():
11✔
277
                transaction_on_commit(
11✔
278
                    lambda: trigger_device_critical_checks.delay(pk=device.pk)
279
                )
280
            else:
281
                device_monitoring.update_status(status)
×
282

283
    @classmethod
11✔
284
    def connect_config_status_changed(cls):
11✔
285
        Config = load_model("config", "Config")
11✔
286

287
        if check_settings.AUTO_CONFIG_CHECK:
11✔
288
            config_status_changed.connect(
11✔
289
                cls.config_status_changed_receiver,
290
                sender=Config,
291
                dispatch_uid="monitoring.config_status_changed_receiver",
292
            )
293

294
    @classmethod
11✔
295
    def connect_wifi_client_signals(cls):
11✔
296
        if not app_settings.WIFI_SESSIONS_ENABLED:
11✔
297
            return
×
298
        WifiClient = load_model("device_monitoring", "WifiClient")
11✔
299
        post_save.connect(
11✔
300
            WifiClient.invalidate_cache,
301
            sender=WifiClient,
302
            dispatch_uid="post_save_invalidate_wificlient_cache",
303
        )
304
        post_delete.connect(
11✔
305
            WifiClient.invalidate_cache,
306
            sender=WifiClient,
307
            dispatch_uid="post_delete_invalidate_wificlient_cache",
308
        )
309

310
    @classmethod
11✔
311
    def connect_offline_device_close_wifisession(cls):
11✔
312
        if not app_settings.WIFI_SESSIONS_ENABLED:
11✔
313
            return
×
314

315
        Metric = load_model("monitoring", "Metric")
11✔
316
        WifiSession = load_model("device_monitoring", "WifiSession")
11✔
317
        threshold_crossed.connect(
11✔
318
            WifiSession.offline_device_close_session,
319
            sender=Metric,
320
            dispatch_uid="offline_device_close_session",
321
        )
322

323
    @classmethod
11✔
324
    def config_status_changed_receiver(cls, sender, instance, **kwargs):
11✔
325
        from ..check.tasks import perform_check
11✔
326

327
        DeviceData = load_model("device_monitoring", "DeviceData")
11✔
328
        device = DeviceData.objects.get(config=instance)
11✔
329
        check = device.checks.filter(check_type__contains="ConfigApplied").first()
11✔
330
        if check:
11✔
331
            transaction_on_commit(lambda: perform_check.delay(check.pk))
11✔
332

333
    def set_update_config_model(self):
11✔
334
        if not getattr(settings, "OPENWISP_UPDATE_CONFIG_MODEL", None):
11✔
335
            setattr(
11✔
336
                connection_settings,
337
                "UPDATE_CONFIG_MODEL",
338
                "device_monitoring.DeviceData",
339
            )
340

341
    def register_dashboard_items(self):
11✔
342
        WifiSession = load_model("device_monitoring", "WifiSession")
11✔
343
        Chart = load_model("monitoring", "Chart")
11✔
344
        register_dashboard_chart(
11✔
345
            position=0,
346
            config={
347
                "name": _("Monitoring Status"),
348
                "query_params": {
349
                    "app_label": "config",
350
                    "model": "device",
351
                    "group_by": "monitoring__status",
352
                },
353
                "colors": {
354
                    "ok": "#267126",
355
                    "problem": "#ffb442",
356
                    "critical": "#a72d1d",
357
                    "unknown": "#353c44",
358
                    "deactivated": "#000",
359
                },
360
                "labels": {
361
                    "ok": app_settings.HEALTH_STATUS_LABELS["ok"],
362
                    "problem": app_settings.HEALTH_STATUS_LABELS["problem"],
363
                    "critical": app_settings.HEALTH_STATUS_LABELS["critical"],
364
                    "unknown": app_settings.HEALTH_STATUS_LABELS["unknown"],
365
                    "deactivated": app_settings.HEALTH_STATUS_LABELS["deactivated"],
366
                },
367
            },
368
        )
369

370
        if app_settings.DASHBOARD_MAP:
11✔
371
            loc_geojson_url = reverse_lazy(
11✔
372
                "monitoring:api_location_geojson", urlconf=MONITORING_API_URLCONF
373
            )
374
            device_list_url = reverse_lazy(
11✔
375
                "monitoring:api_location_device_list",
376
                urlconf=MONITORING_API_URLCONF,
377
                args=["000"],
378
            )
379
            indoor_coordinates_list_url = reverse_lazy(
11✔
380
                "monitoring:api_indoor_coordinates_list",
381
                urlconf=MONITORING_API_URLCONF,
382
                args=["000"],
383
            )
384
            if MONITORING_API_BASEURL:
11✔
385
                device_list_url = urljoin(MONITORING_API_BASEURL, str(device_list_url))
11✔
386
                loc_geojson_url = urljoin(MONITORING_API_BASEURL, str(loc_geojson_url))
11✔
387
                indoor_coordinates_list_url = urljoin(
11✔
388
                    MONITORING_API_BASEURL, str(indoor_coordinates_list_url)
389
                )
390

391
            register_dashboard_template(
11✔
392
                position=0,
393
                config={
394
                    "template": "admin/dashboard/device_map.html",
395
                    "css": (
396
                        "monitoring/css/device-map.css",
397
                        "leaflet/leaflet.css",
398
                        "monitoring/css/leaflet.fullscreen.css",
399
                        "monitoring/css/netjsongraph.css",
400
                    ),
401
                    "js": (
402
                        "monitoring/js/lib/netjsongraph.min.js",
403
                        "monitoring/js/lib/leaflet.fullscreen.min.js",
404
                        "monitoring/js/device-map.js",
405
                        "monitoring/js/floorplan.js",
406
                    ),
407
                },
408
                extra_config={
409
                    "monitoring_device_list_url": device_list_url,
410
                    "monitoring_location_geojson_url": loc_geojson_url,
411
                    "monitoring_indoor_coordinates_list": indoor_coordinates_list_url,
412
                    "monitoring_labels": json.dumps(app_settings.HEALTH_STATUS_LABELS),
413
                },
414
            )
415

416
        general_wifi_client_quick_links = {}
11✔
417
        if app_settings.WIFI_SESSIONS_ENABLED:
11✔
418
            register_dashboard_chart(
11✔
419
                position=13,
420
                config={
421
                    "name": _("Currently Active WiFi Sessions"),
422
                    "query_params": {
423
                        "app_label": WifiSession._meta.app_label,
424
                        "model": WifiSession._meta.model_name,
425
                        "filter": {"stop_time__isnull": True},
426
                        "aggregate": {
427
                            "active__count": Count("id"),
428
                        },
429
                        "organization_field": "device__organization_id",
430
                    },
431
                    "filters": {
432
                        "key": "stop_time__isnull",
433
                        "active__count": "true",
434
                    },
435
                    "colors": {
436
                        "active__count": "#267126",
437
                    },
438
                    "labels": {
439
                        "active__count": _("Currently Active WiFi Sessions"),
440
                    },
441
                    "quick_link": {
442
                        "url": reverse_lazy(
443
                            "admin:{app_label}_{model_name}_changelist".format(
444
                                app_label=WifiSession._meta.app_label,
445
                                model_name=WifiSession._meta.model_name,
446
                            )
447
                        ),
448
                        "label": _("Open WiFi session list"),
449
                        "title": _("View full history of WiFi Sessions"),
450
                        "custom_css_classes": ["negative-top-20"],
451
                    },
452
                },
453
            )
454

455
            general_wifi_client_quick_links = {
11✔
456
                "General WiFi Clients": {
457
                    "url": reverse_lazy(
458
                        "admin:{app_label}_{model_name}_changelist".format(
459
                            app_label=WifiSession._meta.app_label,
460
                            model_name=WifiSession._meta.model_name,
461
                        )
462
                    ),
463
                    "label": _("Open WiFi session list"),
464
                    "title": _("View full history of WiFi Sessions"),
465
                }
466
            }
467

468
        dashboard_chart_url = reverse_lazy(
11✔
469
            "monitoring_general:api_dashboard_timeseries",
470
            urlconf=MONITORING_API_URLCONF,
471
        )
472
        if MONITORING_API_BASEURL:
11✔
473
            dashboard_chart_url = urljoin(
11✔
474
                MONITORING_API_BASEURL, str(dashboard_chart_url)
475
            )
476
        register_dashboard_template(
11✔
477
            position=55,
478
            config={
479
                "template": "monitoring/paritals/chart.html",
480
                "css": (
481
                    "monitoring/css/daterangepicker.css",
482
                    "monitoring/css/percircle.min.css",
483
                    "monitoring/css/chart.css",
484
                    "monitoring/css/dashboard-chart.css",
485
                    "admin/css/vendor/select2/select2.min.css",
486
                    "admin/css/autocomplete.css",
487
                ),
488
                "js": (
489
                    "admin/js/vendor/select2/select2.full.min.js",
490
                    "monitoring/js/lib/moment.min.js",
491
                    "monitoring/js/lib/daterangepicker.min.js",
492
                    "monitoring/js/lib/percircle.min.js",
493
                    "monitoring/js/chart.js",
494
                    "monitoring/js/chart-utils.js",
495
                    "monitoring/js/dashboard-chart.js",
496
                ),
497
            },
498
            extra_config={
499
                "api_url": dashboard_chart_url,
500
                "default_time": Chart.DEFAULT_TIME,
501
                "chart_quick_links": general_wifi_client_quick_links,
502
            },
503
            after_charts=True,
504
        )
505

506
    def register_menu_groups(self):
11✔
507
        if app_settings.WIFI_SESSIONS_ENABLED:
11✔
508
            register_menu_subitem(
11✔
509
                group_position=80,
510
                item_position=0,
511
                config={
512
                    "label": _("WiFi Sessions"),
513
                    "model": get_model_name("device_monitoring", "WifiSession"),
514
                    "name": "changelist",
515
                    "icon": "ow-monitoring-wifi",
516
                },
517
            )
518

519
    def add_connection_ignore_notification_reasons(self):
11✔
520
        from openwisp_controller.connection.apps import ConnectionConfig
11✔
521

522
        ConnectionConfig._ignore_connection_notification_reasons.extend(
11✔
523
            ["timed out", "Unable to connect"]
524
        )
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc