Repository: openwisp/openwisp-controller (language: python)

Issue:
[feature] Allow importing / exporting devices
Allow importing and exporting devices via [django-import-export](https://github.com/django-import-export/django-import-export).

![import](https://user-images.githubusercontent.com/841044/164088278-5d8f4c69-a62c-4905-b940-c50454099ff5.png)

![export](https://user-images.githubusercontent.com/841044/164088272-b5d12370-b28c-409d-b292-aa350702aa2a.png)

We shall mention this feature in the README.


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index f4dee3fb5..a62c5c598 100644
--- a/README.rst
+++ b/README.rst
@@ -109,6 +109,7 @@ Config App
 * **VPN management**: `automatically provision VPN tunnels <#openwisp-controller-default-auto-cert>`_,
   including cryptographic keys, IP addresses
 * `REST API <#rest-api-reference>`_
+* `Export/Import devices <#exportimport-device-data>`_
 
 PKI App
 ~~~~~~~
@@ -375,6 +376,7 @@ Add ``openwisp_controller`` applications to ``INSTALLED_APPS``:
         'openwisp_utils.admin_theme',
         'django.contrib.admin',
         'django.forms',
+        'import_export',
         ...
     ]
     EXTENDED_APPS = ('django_x509', 'django_loci')
@@ -931,6 +933,31 @@ Device Groups provide the following features:
 .. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/device-groups.png
   :alt: Device Group example
 
+Export/Import Device data
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/import-export/device-list.png
+  :alt: Import / Export
+
+The device list page offers two buttons to export and import device data in
+different formats.
+
+The export feature respects any filters selected in the device list.
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/import-export/export-page.png
+  :alt: Export
+
+For importing devices into the system, only the required fields are needed,
+for example, the following CSV file will import a device named
+``TestImport`` with mac address ``00:11:22:09:44:55`` in the organization with
+UUID ``3cb5e18c-0312-48ab-8dbd-038b8415bd6f``::
+
+    organization,name,mac_address
+    3cb5e18c-0312-48ab-8dbd-038b8415bd6f,TestImport,00:11:22:09:44:55
+
+.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/docs/docs/1.1/import-export/import-page.png
+  :alt: Import / Export
+
 How to setup WireGuard tunnels
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -3150,6 +3177,8 @@ Now you need to add ``mycontroller.sample_config``,
         'rest_framework_gis',
         # channels
         'channels',
+        # django-import-export
+        'import_export',
     ]
 
 Substitute ``mycontroller``, ``sample_config``, ``sample_pki``, ``sample_connection``,
diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py
index d9e8cc844..d29f8ad13 100644
--- a/openwisp_controller/config/admin.py
+++ b/openwisp_controller/config/admin.py
@@ -20,6 +20,8 @@
 from django.urls import path, re_path, reverse
 from django.utils.translation import gettext_lazy as _
 from flat_json_widget.widgets import FlatJsonWidget
+from import_export import resources
+from import_export.admin import ImportExportMixin
 from swapper import load_model
 
 from openwisp_controller.config.views import (
@@ -627,6 +629,34 @@ def add_reversion_following(cls, follow):
         )
 
 
+class DeviceResource(resources.ModelResource):
+    class Meta:
+        model = Device
+        fields = [
+            'name',
+            'mac_address',
+            'organization__name',
+            'group__name',
+            'config__status',
+            'config__backend',
+            'last_ip',
+            'management_ip',
+            'created',
+            'modified',
+            'id',
+            'key',
+            'organization',
+            'group',
+        ]
+        export_order = fields
+
+
+class DeviceAdminExportable(ImportExportMixin, DeviceAdmin):
+    resource_class = DeviceResource
+    # needed to support both reversion and import-export
+    change_list_template = 'admin/config/change_list_device.html'
+
+
 class CloneOrganizationForm(forms.Form):
     organization = forms.ModelChoiceField(
         queryset=Organization.objects.none(),
@@ -886,7 +916,7 @@ def schema_view(self, request):
         return JsonResponse(app_settings.DEVICE_GROUP_SCHEMA)
 
 
-admin.site.register(Device, DeviceAdmin)
+admin.site.register(Device, DeviceAdminExportable)
 admin.site.register(Template, TemplateAdmin)
 admin.site.register(Vpn, VpnAdmin)
 admin.site.register(DeviceGroup, DeviceGroupAdmin)
diff --git a/openwisp_controller/config/static/import_export/import-openwisp.css b/openwisp_controller/config/static/import_export/import-openwisp.css
new file mode 100644
index 000000000..ad254382c
--- /dev/null
+++ b/openwisp_controller/config/static/import_export/import-openwisp.css
@@ -0,0 +1,6 @@
+body { overflow-y: auto }
+ins { padding: 5px; white-space: nowrap; }
+#main-content .results tbody td, #main-content .results tbody th,
+div#main-content table tbody th, div#main-content table tbody td {
+  padding: 0.2rem 10px
+}
diff --git a/openwisp_controller/config/templates/admin/config/change_list_device.html b/openwisp_controller/config/templates/admin/config/change_list_device.html
new file mode 100644
index 000000000..990f3e001
--- /dev/null
+++ b/openwisp_controller/config/templates/admin/config/change_list_device.html
@@ -0,0 +1,13 @@
+{% extends "admin/change_list.html" %}
+{% load i18n admin_urls %}
+
+{% block object-tools-items %}
+    {# django-import-export #}
+    {% include "admin/import_export/change_list_import_item.html" %}
+    {% include "admin/import_export/change_list_export_item.html" %}
+    {# django-reversion #}
+    {% if not is_popup and has_add_permission and has_change_permission %}
+        <li><a href="{% url opts|admin_urlname:'recoverlist' %}" class="recoverlink">{% blocktrans with cl.opts.verbose_name_plural|escape as name %}Recover deleted {{name}}{% endblocktrans %}</a></li>
+    {% endif %}
+    {{ block.super }}
+{% endblock %}
diff --git a/openwisp_controller/config/templates/admin/import_export/import.html b/openwisp_controller/config/templates/admin/import_export/import.html
new file mode 100644
index 000000000..b6e2b5bdf
--- /dev/null
+++ b/openwisp_controller/config/templates/admin/import_export/import.html
@@ -0,0 +1,7 @@
+{% extends "admin/import_export/import.html" %}
+{% load static %}
+
+{% block extrastyle %}
+{{ block.super }}
+<link rel="stylesheet" type="text/css" href="{% static 'import_export/import-openwisp.css' %}" />
+{% endblock %}
diff --git a/requirements.txt b/requirements.txt
index 808bfde4b..061b816a2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -16,3 +16,4 @@ celery~=5.2.3
 django-cache-memoize~=0.1
 shortuuid~=1.0.1
 netaddr~=0.8.0
+django-import-export~=2.8.0


Test patch:
diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py
index 4d3ce0326..c7bab9c65 100644
--- a/openwisp_controller/config/tests/test_admin.py
+++ b/openwisp_controller/config/tests/test_admin.py
@@ -1,3 +1,4 @@
+import io
 import json
 import os
 from unittest.mock import patch
@@ -5,6 +6,7 @@
 from django.contrib.admin.models import LogEntry
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
+from django.core.files.base import ContentFile
 from django.test import TestCase
 from django.urls import reverse
 from swapper import load_model
@@ -36,7 +38,65 @@
 Group = load_model('openwisp_users', 'Group')
 
 
+class TestImportExportMixin:
+    """
+    Reused in OpenWISP Monitoring
+    """
+
+    resource_fields = [
+        'name',
+        'mac_address',
+        'organization__name',
+        'group__name',
+        'config__status',
+        'config__backend',
+        'last_ip',
+        'management_ip',
+        'created',
+        'modified',
+        'key',
+        'id',
+        'organization',
+        'group',
+    ]
+
+    def test_device_import_export_buttons(self):
+        path = reverse(f'admin:{self.app_label}_device_changelist')
+        response = self.client.get(path)
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, 'Import</a>')
+        self.assertContains(response, 'Export</a>')
+
+    def test_device_export(self):
+        response = self.client.post(
+            reverse(f'admin:{self.app_label}_device_export'), {'file_format': '0'}
+        )
+        self.assertNotContains(response, 'error')
+        self.assertIsNotNone(response.get('Content-Disposition'))
+        file_ = io.BytesIO(response.content)
+        csv = file_.getvalue().decode()
+        for field in self.resource_fields:
+            self.assertIn(field, csv)
+
+    def test_device_import(self):
+        org = self._get_org()
+        contents = (
+            'organization,name,mac_address\n' f'{org.pk},TestImport,00:11:22:09:44:55'
+        )
+        csv = ContentFile(contents)
+        response = self.client.post(
+            reverse(f'admin:{self.app_label}_device_import'),
+            {'input_format': '0', 'import_file': csv},
+        )
+        with open('test.html', 'w+') as f:
+            f.write(response.content.decode())
+        self.assertNotContains(response, 'errorlist')
+        self.assertNotContains(response, 'Errors')
+        self.assertContains(response, 'Confirm import')
+
+
 class TestAdmin(
+    TestImportExportMixin,
     TestGeoMixin,
     CreateDeviceGroupMixin,
     CreateConfigTemplateMixin,
diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py
index 16bf89b7f..0276789d4 100644
--- a/tests/openwisp2/settings.py
+++ b/tests/openwisp2/settings.py
@@ -62,6 +62,7 @@
     'drf_yasg',
     # channels
     'channels',
+    'import_export',
     # 'debug_toolbar',
 ]
 EXTENDED_APPS = ('django_x509', 'django_loci')