From c6ca386dff61121c6c0d73a2ffafe7c101cf7cbf Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 15 Jun 2021 16:26:09 +0530 Subject: [PATCH 01/15] [feature] Added device groups #203 A group can be specified for devices, i.e. DeviceGroup. DeviceGroup contains metadata about group of deivces in form of JSON. JSONSchema can be set systemwide for validation of entered metadata. Added REST API endpoint for listing, creating and retrieving DeviceGroups. Closes #203 --- README.rst | 75 ++++++++++++- openwisp_controller/config/admin.py | 56 +++++++++- openwisp_controller/config/api/serializers.py | 11 ++ openwisp_controller/config/api/urls.py | 10 ++ openwisp_controller/config/api/views.py | 21 +++- openwisp_controller/config/base/device.py | 45 ++++++-- .../config/base/device_group.py | 40 +++++++ .../config/migrations/0036_device_group.py | 100 ++++++++++++++++++ .../config/migrations/__init__.py | 21 ++++ openwisp_controller/config/models.py | 11 ++ openwisp_controller/config/settings.py | 1 + openwisp_controller/config/signals.py | 1 + openwisp_controller/config/tests/test_api.py | 56 +++++++++- .../config/tests/test_device.py | 32 +++++- .../config/tests/test_device_group.py | 39 +++++++ openwisp_controller/config/tests/utils.py | 17 +++ openwisp_controller/config/widgets.py | 13 +++ tests/openwisp2/sample_config/admin.py | 8 +- tests/openwisp2/sample_config/api/views.py | 16 +++ .../sample_config/migrations/0001_initial.py | 70 ++++++++++++ tests/openwisp2/sample_config/models.py | 10 ++ tests/openwisp2/sample_config/tests.py | 8 ++ tests/openwisp2/settings.py | 1 + 23 files changed, 643 insertions(+), 19 deletions(-) create mode 100644 openwisp_controller/config/base/device_group.py create mode 100644 openwisp_controller/config/migrations/0036_device_group.py create mode 100644 openwisp_controller/config/tests/test_device_group.py diff --git a/README.rst b/README.rst index 94caf713e..5e8a07737 100755 --- a/README.rst +++ b/README.rst @@ -134,6 +134,7 @@ Config App * **configuration templates**: reduce repetition to the minimum * `configuration variables <#how-to-use-configuration-variables>`_: reference ansible-like variables in the configuration and templates * **template tags**: tag templates to automate different types of auto-configurations (eg: mesh, WDS, 4G) +* **device groups**: add `devices to dedicated groups <#device-groups>`_ for easy management * **simple HTTP resources**: allow devices to automatically download configuration updates * **VPN management**: automatically provision VPN tunnels with unique x509 certificates @@ -625,7 +626,18 @@ Allows to specify backend URL for API requests, if the frontend is hosted separa +--------------+----------+ Allows to specify a `list` of tuples for adding commands as described in -`'How to add commands" <#how-to-add-commands>`_ section. +`'How to add commands" <#how-to-add-commands>`_ section. + +``OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + ++--------------+----------+ +| **type**: | ``dict`` | ++--------------+----------+ +| **default**: | ``{}`` | ++--------------+----------+ + +Allows specifying JSONSchema used for validating meta-data of `Device Group <#device-groups>`_. REST API -------- @@ -807,6 +819,27 @@ List locations with devices deployed (in GeoJSON format) GET api/v1/controller/location/geojson/ +List device groups +^^^^^^^^^^^^^^^^^^ + +.. code:: text + + GET api/v1/controller/group/ + +Create device group +^^^^^^^^^^^^^^^^^^^ + +.. code:: text + + POST api/v1/controller/group/ + +Get device group detail +^^^^^^^^^^^^^^^^^^^^^^^ + +.. code-block:: text + + GET /api/v1/controller/group/{id}/ + List templates ^^^^^^^^^^^^^^ @@ -1050,6 +1083,18 @@ Please refer `troubleshooting issues related to geospatial libraries `_. +Device Groups +------------- + +Device Groups provide an easy way to organize devices of a particular organization. +You can achieve following by using Device Groups: + +- Group similar devices by having dedicated groups for access points, routers, etc. +- Store additional information regarding a group in the structured metadata field. +- Customize structure and validation of metadata field of DeviceGroup to standardize + information across all groups using `"OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA" <#openwisp-controller-device-group-schema>`_ + setting. + How to use configuration variables ---------------------------------- @@ -1632,6 +1677,19 @@ The signal is emitted when the device name changes. It is not emitted when the device is created. +``device_group_changed`` +~~~~~~~~~~~~~~~~~~~~~~~~ + +**Path**: ``openwisp_controller.config.signals.device_group_changed`` + +**Arguments**: + +- ``instance``: instance of ``Device``. + +The signal is emitted when the device group changes. + +It is not emitted when the device is created. + Setup (integrate in an existing django project) ----------------------------------------------- @@ -2013,6 +2071,7 @@ Once you have created the models, add the following to your ``settings.py``: # Setting models for swapper module CONFIG_DEVICE_MODEL = 'sample_config.Device' + CONFIG_DEVICEGROUP_MODEL = 'sample_config.DeviceGroup' CONFIG_CONFIG_MODEL = 'sample_config.Config' CONFIG_TEMPLATETAG_MODEL = 'sample_config.TemplateTag' CONFIG_TAGGEDTEMPLATE_MODEL = 'sample_config.TaggedTemplate' @@ -2082,7 +2141,12 @@ sample_config .. code-block:: python - from openwisp_controller.config.admin import DeviceAdmin, TemplateAdmin, VpnAdmin + from openwisp_controller.config.admin import ( + DeviceAdmin, + DeviceGroupAdmin, + TemplateAdmin, + VpnAdmin, + ) # DeviceAdmin.fields += ['example'] <-- monkey patching example @@ -2129,14 +2193,17 @@ sample_config DeviceAdmin as BaseDeviceAdmin, TemplateAdmin as BaseTemplateAdmin, VpnAdmin as BaseVpnAdmin, + DeviceGroupAdmin as BaseDeviceGroupAdmin, from swapper import load_model Vpn = load_model('openwisp_controller', 'Vpn') Device = load_model('openwisp_controller', 'Device') + DeviceGroup = load_model('openwisp_controller', 'DeviceGroup') Template = load_model('openwisp_controller', 'Template') admin.site.unregister(Vpn) admin.site.unregister(Device) + admin.site.unregister(DeviceGroup) admin.site.unregister(Template) @admin.register(Vpn) @@ -2147,6 +2214,10 @@ sample_config class DeviceAdmin(BaseDeviceAdmin): # add your changes here + @admin.register(DeviceGroup) + class DeviceGroupAdmin(BaseDeviceGroupAdmin): + # add your changes here + @admin.register(Template) class TemplateAdmin(BaseTemplateAdmin): # add your changes here diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index 9af2d6546..09ab6acab 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -13,11 +13,11 @@ ObjectDoesNotExist, ValidationError, ) -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, JsonResponse from django.shortcuts import get_object_or_404 from django.template.loader import get_template from django.template.response import TemplateResponse -from django.urls import reverse +from django.urls import path, reverse from django.utils.translation import ugettext_lazy as _ from flat_json_widget.widgets import FlatJsonWidget from swapper import load_model @@ -41,12 +41,13 @@ from . import settings as app_settings from .base.vpn import AbstractVpn from .utils import send_file -from .widgets import JsonSchemaWidget +from .widgets import DeviceGroupJsonSchemaWidget, JsonSchemaWidget logger = logging.getLogger(__name__) prefix = 'config/' Config = load_model('config', 'Config') Device = load_model('config', 'Device') +DeviceGroup = load_model('config', 'DeviceGroup') Template = load_model('config', 'Template') Vpn = load_model('config', 'Vpn') Organization = load_model('openwisp_users', 'Organization') @@ -385,6 +386,7 @@ class DeviceAdmin(MultitenantAdminMixin, BaseConfigAdmin, UUIDAdmin): list_display = [ 'name', 'backend', + 'group', 'config_status', 'mac_address', 'ip', @@ -408,12 +410,14 @@ class DeviceAdmin(MultitenantAdminMixin, BaseConfigAdmin, UUIDAdmin): 'devicelocation__location__address', ] readonly_fields = ['last_ip', 'management_ip', 'uuid'] + autocomplete_fields = ['group'] fields = [ 'name', 'organization', 'mac_address', 'uuid', 'key', + 'group', 'last_ip', 'management_ip', 'model', @@ -707,9 +711,55 @@ class Media(BaseConfigAdmin): js = list(BaseConfigAdmin.Media.js) + [f'{prefix}js/vpn.js'] +class DeviceGroupForm(BaseForm): + class Meta(BaseForm.Meta): + model = DeviceGroup + widgets = {'context': DeviceGroupJsonSchemaWidget} + labels = {'context': _('Metadata')} + help_texts = { + 'context': _( + # TODO: Rephrase this + 'In this section you can add metadata of the DeviceGroup' + ) + } + + +class DeviceGroupAdmin(BaseAdmin): + form = DeviceGroupForm + fields = [ + 'name', + 'organization', + 'description', + 'context', + 'created', + 'modified', + ] + search_fields = ['name'] + + class Media: + css = {'all': (f'{prefix}css/admin.css',)} + + def get_urls(self): + options = self.model._meta + url_prefix = f'{options.app_label}_{options.model_name}' + urls = super().get_urls() + urls += [ + path( + f'{options.app_label}/{options.model_name}/ui/schema.json', + self.admin_site.admin_view(self.schema_view), + name=f'{url_prefix}_schema', + ), + ] + return urls + + def schema_view(self, request): + return JsonResponse(app_settings.DEVICE_GROUP_SCHEMA) + + admin.site.register(Device, DeviceAdmin) admin.site.register(Template, TemplateAdmin) admin.site.register(Vpn, VpnAdmin) +admin.site.register(DeviceGroup, DeviceGroupAdmin) if getattr(app_settings, 'REGISTRATION_ENABLED', True): diff --git a/openwisp_controller/config/api/serializers.py b/openwisp_controller/config/api/serializers.py index 60a0359e8..18f1205b9 100644 --- a/openwisp_controller/config/api/serializers.py +++ b/openwisp_controller/config/api/serializers.py @@ -10,6 +10,7 @@ Template = load_model('config', 'Template') Vpn = load_model('config', 'Vpn') Device = load_model('config', 'Device') +DeviceGroup = load_model('config', 'DeviceGroup') Config = load_model('config', 'Config') Organization = load_model('openwisp_users', 'Organization') @@ -132,6 +133,7 @@ class Meta(BaseMeta): 'id', 'name', 'organization', + 'group', 'mac_address', 'key', 'last_ip', @@ -184,6 +186,7 @@ class Meta(BaseMeta): 'id', 'name', 'organization', + 'group', 'mac_address', 'key', 'last_ip', @@ -256,3 +259,11 @@ def update(self, instance, validated_data): instance.config.full_clean() instance.config.save() return super().update(instance, validated_data) + + +class DeviceGroupSerializer(BaseSerializer): + context = serializers.JSONField(required=False, initial={}) + + class Meta(BaseMeta): + model = DeviceGroup + fields = ['name', 'organization', 'description', 'context'] diff --git a/openwisp_controller/config/api/urls.py b/openwisp_controller/config/api/urls.py index 9ac7baa10..32c508e2c 100644 --- a/openwisp_controller/config/api/urls.py +++ b/openwisp_controller/config/api/urls.py @@ -38,6 +38,16 @@ def get_api_urls(api_views): api_views.device_detail, name='device_detail', ), + path( + 'controller/group/', + api_views.devicegroup_list, + name='devicegroup_list', + ), + path( + 'controller/group//', + api_views.devicegroup_detail, + name='devicegroup_detail', + ), path( 'controller/device//configuration/', api_views.download_device_config, diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py index 232779d3c..a96eb286a 100644 --- a/openwisp_controller/config/api/views.py +++ b/openwisp_controller/config/api/views.py @@ -15,6 +15,7 @@ from ..admin import BaseConfigAdmin from .serializers import ( DeviceDetailSerializer, + DeviceGroupSerializer, DeviceListSerializer, TemplateSerializer, VpnSerializer, @@ -23,6 +24,7 @@ Template = load_model('config', 'Template') Vpn = load_model('config', 'Vpn') Device = load_model('config', 'Device') +DeviceGroup = load_model('config', 'DeviceGroup') Config = load_model('config', 'Config') @@ -87,7 +89,9 @@ class DeviceListCreateView(ProtectedAPIMixin, ListCreateAPIView): """ serializer_class = DeviceListSerializer - queryset = Device.objects.select_related('config').order_by('-created') + queryset = Device.objects.select_related( + 'config', 'group', 'organization' + ).order_by('-created') pagination_class = ListViewPagination @@ -98,7 +102,7 @@ class DeviceDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView): """ serializer_class = DeviceDetailSerializer - queryset = Device.objects.select_related('config') + queryset = Device.objects.select_related('config', 'group', 'organization') class DownloadDeviceView(ProtectedAPIMixin, RetrieveAPIView): @@ -110,6 +114,17 @@ def retrieve(self, request, *args, **kwargs): return BaseConfigAdmin.download_view(self, request, pk=kwargs['pk']) +class DeviceGroupListCreateView(ProtectedAPIMixin, ListCreateAPIView): + serializer_class = DeviceGroupSerializer + queryset = DeviceGroup.objects.select_related('organization').order_by('-created') + pagination_class = ListViewPagination + + +class DeviceGroupDetailView(ProtectedAPIMixin, ListCreateAPIView): + serializer_class = DeviceGroupSerializer + queryset = DeviceGroup.objects.select_related('organization').order_by('-created') + + template_list = TemplateListCreateView.as_view() template_detail = TemplateDetailView.as_view() download_template_config = DownloadTemplateconfiguration.as_view() @@ -118,4 +133,6 @@ def retrieve(self, request, *args, **kwargs): download_vpn_config = DownloadVpnView.as_view() device_list = DeviceListCreateView.as_view() device_detail = DeviceDetailView.as_view() +devicegroup_list = DeviceGroupListCreateView.as_view() +devicegroup_detail = DeviceGroupDetailView.as_view() download_device_config = DownloadDeviceView().as_view() diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 19c962c32..606e7bdc3 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -4,12 +4,13 @@ from django.db import models from django.db.models import Q from django.utils.translation import ugettext_lazy as _ +from swapper import get_model_name from openwisp_users.mixins import OrgMixin from openwisp_utils.base import KeyField from .. import settings as app_settings -from ..signals import device_name_changed, management_ip_changed +from ..signals import device_group_changed, device_name_changed, management_ip_changed from ..validators import device_name_validator, mac_address_validator from .base import BaseModel @@ -63,6 +64,13 @@ class AbstractDevice(OrgMixin, BaseModel): help_text=_('system on chip or CPU info'), ) notes = models.TextField(blank=True, help_text=_('internal notes')) + group = models.ForeignKey( + get_model_name('config', 'DeviceGroup'), + verbose_name=_('Device Group'), + on_delete=models.SET_NULL, + blank=True, + null=True, + ) # these fields are filled automatically # with data received from devices last_ip = models.GenericIPAddressField( @@ -174,20 +182,32 @@ def save(self, *args, **kwargs): update_fields = kwargs.get('update_fields') if not update_fields or 'name' in update_fields: self._check_name_changed() + if not update_fields or 'group' in update_fields: + self._check_group_changed() super().save(*args, **kwargs) self._check_management_ip_changed() - def _check_name_changed(self): - if self._state.adding: - return - current = ( + @property + def _current_instance(self): + # Returns the instance current value from database + return ( self._meta.model.objects.only( - 'id', 'name', 'management_ip', 'config__id', 'config__status' + 'id', + 'name', + 'management_ip', + 'group_id', + 'config__id', + 'config__status', ) .select_related('config') .get(pk=self.pk) ) + def _check_name_changed(self): + if self._state.adding: + return + current = self._current_instance + if self.name != current.name: device_name_changed.send( sender=self.__class__, instance=self, @@ -196,6 +216,19 @@ def _check_name_changed(self): if self.name != current.name and self._has_config(): self.config.set_status_modified() + def _check_group_changed(self): + if self._state.adding: + return + current = self._current_instance + + if self.group_id != current.group_id: + device_group_changed.send( + sender=self.__class__, instance=self, + ) + + if self.name != current.name and self._has_config(): + self.config.set_status_modified() + def _check_management_ip_changed(self): if self.management_ip != self._initial_management_ip: management_ip_changed.send( diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py new file mode 100644 index 000000000..72cdb89b7 --- /dev/null +++ b/openwisp_controller/config/base/device_group.py @@ -0,0 +1,40 @@ +import collections + +import jsonschema +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from jsonfield import JSONField +from jsonschema.exceptions import ValidationError as SchemaError + +from openwisp_users.mixins import OrgMixin +from openwisp_utils.base import TimeStampedEditableModel + +from .. import settings as app_settings + + +class AbstractDeviceGroup(OrgMixin, TimeStampedEditableModel): + name = models.CharField(max_length=60, unique=True, null=False, blank=False) + description = models.TextField(blank=True, help_text=_('internal notes')) + context = context = JSONField( + blank=True, + default=dict, + load_kwargs={'object_pairs_hook': collections.OrderedDict}, + dump_kwargs={'indent': 4}, + ) + + def __str__(self): + return self.name + + class Meta: + abstract = True + verbose_name = _('Device Group') + verbose_name_plural = _('Device Groups') + + def clean(self): + try: + jsonschema.Draft4Validator(app_settings.DEVICE_GROUP_SCHEMA).validate( + self.context + ) + except SchemaError as e: + raise ValidationError({'input': e.message}) diff --git a/openwisp_controller/config/migrations/0036_device_group.py b/openwisp_controller/config/migrations/0036_device_group.py new file mode 100644 index 000000000..ceb4c1fde --- /dev/null +++ b/openwisp_controller/config/migrations/0036_device_group.py @@ -0,0 +1,100 @@ +# Generated by Django 3.1.12 on 2021-06-14 17:51 + +import collections +import uuid + +import django.db.models.deletion +import django.utils.timezone +import jsonfield.fields +import model_utils.fields +import swapper +from django.db import migrations, models + +import openwisp_users.mixins + +from . import assign_devicegroup_permissions_to_groups + + +class Migration(migrations.Migration): + + dependencies = [ + ('openwisp_users', '0014_user_notes'), + ('config', '0035_device_name_unique_optional'), + ] + + operations = [ + migrations.CreateModel( + name='DeviceGroup', + fields=[ + ( + 'id', + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + 'created', + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name='created', + ), + ), + ( + 'modified', + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name='modified', + ), + ), + ('name', models.CharField(max_length=60, unique=True)), + ( + 'description', + models.TextField(blank=True, help_text='internal notes'), + ), + ( + 'context', + jsonfield.fields.JSONField( + blank=True, + default=dict, + dump_kwargs={'ensure_ascii': False, 'indent': 4}, + load_kwargs={'object_pairs_hook': collections.OrderedDict}, + ), + ), + ( + 'organization', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to='openwisp_users.organization', + verbose_name='organization', + ), + ), + ], + options={ + 'verbose_name': 'Device Group', + 'verbose_name_plural': 'Device Groups', + 'abstract': False, + 'swappable': 'CONFIG_DEVICEGROUP_MODEL', + }, + bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model), + ), + migrations.AddField( + model_name='device', + name='group', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=swapper.get_model_name('config', 'DeviceGroup'), + verbose_name='Device Group', + ), + ), + migrations.RunPython( + code=assign_devicegroup_permissions_to_groups, + reverse_code=migrations.operations.special.RunPython.noop, + ), + ] diff --git a/openwisp_controller/config/migrations/__init__.py b/openwisp_controller/config/migrations/__init__.py index 9a52b3bae..d31ce5eee 100644 --- a/openwisp_controller/config/migrations/__init__.py +++ b/openwisp_controller/config/migrations/__init__.py @@ -57,3 +57,24 @@ def assign_permissions_to_groups(apps, schema_editor): codename='{}_{}'.format(operation, model_name) ).pk ) + + +def assign_devicegroup_permissions_to_groups(apps, schema_editor): + create_default_permissions(apps, schema_editor) + operator_and_admin_operations = ['add', 'change', 'delete', 'view'] + Group = get_swapped_model(apps, 'openwisp_users', 'Group') + + try: + admin = Group.objects.get(name='Administrator') + operator = Group.objects.get(name='Operator') + # consider failures custom cases + # that do not have to be dealt with + except Group.DoesNotExist: + return + + for operation in operator_and_admin_operations: + permission = Permission.objects.get( + codename='{}_{}'.format(operation, 'devicegroup') + ) + admin.permissions.add(permission.pk) + operator.permissions.add(permission.pk) diff --git a/openwisp_controller/config/models.py b/openwisp_controller/config/models.py index 27076b771..c3789b384 100644 --- a/openwisp_controller/config/models.py +++ b/openwisp_controller/config/models.py @@ -2,6 +2,7 @@ from .base.config import AbstractConfig from .base.device import AbstractDevice +from .base.device_group import AbstractDeviceGroup from .base.multitenancy import AbstractOrganizationConfigSettings from .base.tag import AbstractTaggedTemplate, AbstractTemplateTag from .base.template import AbstractTemplate @@ -18,6 +19,16 @@ class Meta(AbstractDevice.Meta): swappable = swapper.swappable_setting('config', 'Device') +class DeviceGroup(AbstractDeviceGroup): + """ + Concrete DeviceGroup model + """ + + class Meta(AbstractDeviceGroup.Meta): + abstract = False + swappable = swapper.swappable_setting('config', 'devicegroup') + + class Config(AbstractConfig): """ Concrete Config model diff --git a/openwisp_controller/config/settings.py b/openwisp_controller/config/settings.py index 088bdbe37..409f04d2a 100644 --- a/openwisp_controller/config/settings.py +++ b/openwisp_controller/config/settings.py @@ -65,3 +65,4 @@ def get_settings_value(option, default): 'DEVICE_VERBOSE_NAME', (_('Device'), _('Devices')) ) DEVICE_NAME_UNIQUE = get_settings_value('DEVICE_NAME_UNIQUE', True) +DEVICE_GROUP_SCHEMA = get_settings_value('DEVICE_GROUP_SCHEMA', {}) diff --git a/openwisp_controller/config/signals.py b/openwisp_controller/config/signals.py index efcb378fb..c72bc1ac5 100644 --- a/openwisp_controller/config/signals.py +++ b/openwisp_controller/config/signals.py @@ -12,3 +12,4 @@ providing_args=['instance', 'management_ip', 'old_management_ip'] ) device_name_changed = Signal(providing_args=['instance']) +device_group_changed = Signal(providing_args=['instance']) diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index 08a4a7725..c1f5a3673 100644 --- a/openwisp_controller/config/tests/test_api.py +++ b/openwisp_controller/config/tests/test_api.py @@ -7,11 +7,12 @@ from openwisp_controller.tests.utils import TestAdminMixin from openwisp_users.tests.utils import TestOrganizationMixin -from .utils import CreateConfigTemplateMixin, TestVpnX509Mixin +from .utils import CreateConfigTemplateMixin, CreateDeviceGroupMixin, TestVpnX509Mixin Template = load_model('config', 'Template') Vpn = load_model('config', 'Vpn') Device = load_model('config', 'Device') +DeviceGroup = load_model('config', 'DeviceGroup') OrganizationUser = load_model('openwisp_users', 'OrganizationUser') @@ -20,6 +21,7 @@ class TestConfigApi( TestOrganizationMixin, CreateConfigTemplateMixin, TestVpnX509Mixin, + CreateDeviceGroupMixin, TestCase, ): def setUp(self): @@ -70,6 +72,13 @@ def setUp(self): }, } + _get_devicegroup_data = { + 'name': 'Access Points', + 'description': 'Group for APs of default organization', + 'organization': 'None', + 'context': {'captive_portal_urls': 'https://example.com'}, + } + def test_device_create_with_config_api(self): self.assertEqual(Device.objects.count(), 0) path = reverse('config_api:device_list') @@ -120,6 +129,19 @@ def test_device_post_with_templates_of_different_org(self): ''' self.assertTrue(' '.join(validation_msg.split()) in error.exception.message) + def test_device_create_with_devicegroup(self): + self.assertEqual(Device.objects.count(), 0) + path = reverse('config_api:device_list') + data = self._get_device_data.copy() + org = self._get_org() + device_group = self._create_device_group() + data['organization'] = org.pk + data['group'] = device_group.pk + response = self.client.post(path, data, content_type='application/json') + self.assertEqual(response.status_code, 201) + self.assertEqual(Device.objects.count(), 1) + self.assertEqual(response.data['group'], device_group.pk) + def test_device_list_api(self): self._create_device() path = reverse('config_api:device_list') @@ -553,3 +575,35 @@ def test_get_request_with_no_perm(self): path = reverse('config_api:template_list') response = self.client.get(path) self.assertEqual(response.status_code, 403) + + def test_devicegroup_create_api(self): + self.assertEqual(DeviceGroup.objects.count(), 0) + org = self._get_org() + path = reverse('config_api:devicegroup_list') + data = self._get_devicegroup_data.copy() + data['organization'] = org.pk + response = self.client.post(path, data, content_type='application/json') + self.assertEqual(DeviceGroup.objects.count(), 1) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data['name'], data['name']) + self.assertEqual(response.data['description'], data['description']) + self.assertEqual(response.data['context'], data['context']) + self.assertEqual(response.data['organization'], org.pk) + + def test_devicegroup_list_api(self): + self._create_device_group() + path = reverse('config_api:devicegroup_list') + with self.assertNumQueries(4): + r = self.client.get(path) + self.assertEqual(r.status_code, 200) + + def test_devicegroup_detail_api(self): + device_group = self._create_device_group() + path = reverse('config_api:devicegroup_detail', args=[device_group.pk]) + with self.assertNumQueries(3): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]['name'], device_group.name) + self.assertEqual(response.data[0]['description'], device_group.description) + self.assertDictEqual(response.data[0]['context'], device_group.context) + self.assertEqual(response.data[0]['organization'], device_group.organization.pk) diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index 849b49986..caaebf387 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -6,21 +6,28 @@ from swapper import load_model from openwisp_users.tests.utils import TestOrganizationMixin -from openwisp_utils.tests import catch_signal +from openwisp_utils.tests import AssertNumQueriesSubTestMixin, catch_signal from .. import settings as app_settings -from ..signals import device_name_changed, management_ip_changed +from ..signals import device_group_changed, device_name_changed, management_ip_changed from ..validators import device_name_validator, mac_address_validator -from .utils import CreateConfigTemplateMixin +from .utils import CreateConfigTemplateMixin, CreateDeviceGroupMixin TEST_ORG_SHARED_SECRET = 'functional_testing_secret' Config = load_model('config', 'Config') Device = load_model('config', 'Device') +DeviceGroup = load_model('config', 'DeviceGroup') _original_context = app_settings.CONTEXT.copy() -class TestDevice(CreateConfigTemplateMixin, TestOrganizationMixin, TestCase): +class TestDevice( + CreateConfigTemplateMixin, + TestOrganizationMixin, + AssertNumQueriesSubTestMixin, + CreateDeviceGroupMixin, + TestCase, +): """ tests for Device model """ @@ -354,3 +361,20 @@ def test_device_name_changed_not_emitted_on_creation(self): with catch_signal(device_name_changed) as handler: self._create_device(organization=self._get_org()) handler.assert_not_called() + + def test_device_group_changed_emitted(self): + org = self._get_org() + device = self._create_device(name='test', organization=org) + device_group = self._create_device_group() + + with catch_signal(device_group_changed) as handler, self.assertNumQueries(3): + device.group = device_group + device.save() + handler.assert_called_once_with( + signal=device_group_changed, sender=Device, instance=device, + ) + + def test_device_group_changed_not_emitted_on_creation(self): + with catch_signal(device_group_changed) as handler: + self._create_device(organization=self._get_org()) + handler.assert_not_called() diff --git a/openwisp_controller/config/tests/test_device_group.py b/openwisp_controller/config/tests/test_device_group.py new file mode 100644 index 000000000..627250710 --- /dev/null +++ b/openwisp_controller/config/tests/test_device_group.py @@ -0,0 +1,39 @@ +from unittest.mock import patch + +from django.core.exceptions import ValidationError +from django.test import TestCase +from swapper import load_model + +from openwisp_users.tests.utils import TestOrganizationMixin + +from .. import settings as app_settings +from .utils import CreateDeviceGroupMixin + +DeviceGroup = load_model('config', 'DeviceGroup') + + +class TestDeviceGroup(TestOrganizationMixin, CreateDeviceGroupMixin, TestCase): + def test_device_group(self): + self._create_device_group(context={'captive_portal_url': 'https//example.com'}) + self.assertEqual(DeviceGroup.objects.count(), 1) + + def test_device_group_schema_validation(self): + device_group_schema = { + 'required': ['captive_portal_url'], + 'properties': { + 'captive_portal_url': { + 'type': 'string', + 'title': 'Captive Portal URL', + }, + }, + 'additionalProperties': True, + } + + with patch.object(app_settings, 'DEVICE_GROUP_SCHEMA', device_group_schema): + with self.subTest('Test for failing validation'): + self.assertRaises(ValidationError, self._create_device_group) + + with self.subTest('Test for passing validation'): + self._create_device_group( + context={'captive_portal_url': 'https://example.com'} + ) diff --git a/openwisp_controller/config/tests/utils.py b/openwisp_controller/config/tests/utils.py index 828d8a0a7..0ebd57533 100644 --- a/openwisp_controller/config/tests/utils.py +++ b/openwisp_controller/config/tests/utils.py @@ -13,6 +13,7 @@ Config = load_model('config', 'Config') Device = load_model('config', 'Device') +DeviceGroup = load_model('config', 'DeviceGroup') Template = load_model('config', 'Template') Vpn = load_model('config', 'Vpn') Ca = load_model('django_x509', 'Ca') @@ -147,6 +148,22 @@ def _create_config(self, **kwargs): return super()._create_config(**kwargs) +class CreateDeviceGroupMixin: + def _create_device_group(self, **kwargs): + options = { + 'name': 'Routers', + 'description': 'Group for all routers', + 'context': {}, + } + options.update(kwargs) + if 'organization' not in options: + options['organization'] = self._get_org() + device_group = DeviceGroup(**options) + device_group.full_clean() + device_group.save() + return device_group + + class SeleniumTestCase(StaticLiveServerTestCase): """ A base test case for Selenium, providing helped methods for generating diff --git a/openwisp_controller/config/widgets.py b/openwisp_controller/config/widgets.py index a41402fb5..43c0dbe9c 100644 --- a/openwisp_controller/config/widgets.py +++ b/openwisp_controller/config/widgets.py @@ -2,6 +2,9 @@ from django.contrib.admin.widgets import AdminTextareaWidget from django.template.loader import get_template from django.urls import reverse +from swapper import load_model + +DeviceGroup = load_model('config', 'DeviceGroup') class JsonSchemaWidget(AdminTextareaWidget): @@ -49,3 +52,13 @@ def render(self, name, value, attrs=None, renderer=None): attrs.update({'data-schema-url': reverse(self.schema_view_name)}) html += super().render(name, value, attrs, renderer) return html + + +class DeviceGroupJsonSchemaWidget(JsonSchemaWidget): + schema_view_name = ( + f'admin:{DeviceGroup._meta.app_label}_{DeviceGroup._meta.model_name}_schema' + ) + app_label_model = f'{DeviceGroup._meta.app_label}_{DeviceGroup._meta.model_name}' + netjsonconfig_hint = False + advanced_mode = True + extra_attrs = {} diff --git a/tests/openwisp2/sample_config/admin.py b/tests/openwisp2/sample_config/admin.py index a7d9014af..9727fc9df 100644 --- a/tests/openwisp2/sample_config/admin.py +++ b/tests/openwisp2/sample_config/admin.py @@ -1,6 +1,12 @@ -from openwisp_controller.config.admin import DeviceAdmin, TemplateAdmin, VpnAdmin +from openwisp_controller.config.admin import ( + DeviceAdmin, + DeviceGroupAdmin, + TemplateAdmin, + VpnAdmin, +) # Monkey Patching done only for testing purposes DeviceAdmin.fields += ['details'] TemplateAdmin.fields += ['details'] VpnAdmin.fields += ['details'] +DeviceGroupAdmin.fields += ['details'] diff --git a/tests/openwisp2/sample_config/api/views.py b/tests/openwisp2/sample_config/api/views.py index a3dd6e74b..d5c0d1f4c 100644 --- a/tests/openwisp2/sample_config/api/views.py +++ b/tests/openwisp2/sample_config/api/views.py @@ -1,6 +1,12 @@ from openwisp_controller.config.api.views import ( DeviceDetailView as BaseDeviceDetailView, ) +from openwisp_controller.config.api.views import ( + DeviceGroupDetailView as BaseDeviceGroupDetailView, +) +from openwisp_controller.config.api.views import ( + DeviceGroupListCreateView as BaseDeviceGroupListCreateView, +) from openwisp_controller.config.api.views import ( DeviceListCreateView as BaseDeviceListCreateView, ) @@ -55,6 +61,14 @@ class DeviceDetailView(BaseDeviceDetailView): pass +class DeviceGroupListCreateView(BaseDeviceGroupListCreateView): + pass + + +class DeviceGroupDetailView(BaseDeviceGroupDetailView): + pass + + class DownloadDeviceView(BaseDownloadDeviceView): pass @@ -68,3 +82,5 @@ class DownloadDeviceView(BaseDownloadDeviceView): device_list = DeviceListCreateView.as_view() device_detail = DeviceDetailView.as_view() download_device_config = DownloadDeviceView().as_view() +devicegroup_list = DeviceGroupListCreateView.as_view() +devicegroup_detail = DeviceGroupDetailView.as_view() diff --git a/tests/openwisp2/sample_config/migrations/0001_initial.py b/tests/openwisp2/sample_config/migrations/0001_initial.py index 4e3aa5531..41531ae83 100644 --- a/tests/openwisp2/sample_config/migrations/0001_initial.py +++ b/tests/openwisp2/sample_config/migrations/0001_initial.py @@ -585,6 +585,66 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='DeviceGroup', + fields=[ + ('details', models.CharField(blank=True, max_length=64, null=True)), + ( + 'id', + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ( + 'created', + model_utils.fields.AutoCreatedField( + default=django.utils.timezone.now, + editable=False, + verbose_name='created', + ), + ), + ( + 'modified', + model_utils.fields.AutoLastModifiedField( + default=django.utils.timezone.now, + editable=False, + verbose_name='modified', + ), + ), + ('name', models.CharField(max_length=60, unique=True)), + ( + 'description', + models.TextField(blank=True, help_text='internal notes'), + ), + ( + 'context', + jsonfield.fields.JSONField( + blank=True, + default=dict, + dump_kwargs={'ensure_ascii': False, 'indent': 4}, + load_kwargs={'object_pairs_hook': collections.OrderedDict}, + ), + ), + ( + 'organization', + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=swapper.get_model_name('openwisp_users', 'Organization'), + verbose_name='organization', + ), + ), + ], + options={ + 'verbose_name': 'Device Group', + 'verbose_name_plural': 'Device Groups', + 'abstract': False, + 'swappable': 'CONFIG_DEVICEGROUP_MODEL', + }, + bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model), + ), migrations.CreateModel( name='Device', fields=[ @@ -650,6 +710,16 @@ class Migration(migrations.Migration): ], ), ), + ( + 'group', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=swapper.get_model_name('config', 'DeviceGroup'), + verbose_name='Device Group', + ), + ), ( 'key', openwisp_utils.base.KeyField( diff --git a/tests/openwisp2/sample_config/models.py b/tests/openwisp2/sample_config/models.py index fc0eef2c8..94346a480 100644 --- a/tests/openwisp2/sample_config/models.py +++ b/tests/openwisp2/sample_config/models.py @@ -2,6 +2,7 @@ from openwisp_controller.config.base.config import AbstractConfig from openwisp_controller.config.base.device import AbstractDevice +from openwisp_controller.config.base.device_group import AbstractDeviceGroup from openwisp_controller.config.base.multitenancy import ( AbstractOrganizationConfigSettings, ) @@ -29,6 +30,15 @@ class Meta(AbstractDevice.Meta): abstract = False +class DeviceGroup(DetailsModel, AbstractDeviceGroup): + """ + Concrete Device model + """ + + class Meta(AbstractDeviceGroup.Meta): + abstract = False + + class Config(DetailsModel, AbstractConfig): """ Concrete Config model diff --git a/tests/openwisp2/sample_config/tests.py b/tests/openwisp2/sample_config/tests.py index 252a02f08..558df0963 100644 --- a/tests/openwisp2/sample_config/tests.py +++ b/tests/openwisp2/sample_config/tests.py @@ -9,6 +9,9 @@ TestController as BaseTestController, ) from openwisp_controller.config.tests.test_device import TestDevice as BaseTestDevice +from openwisp_controller.config.tests.test_device_group import ( + TestDeviceGroup as BaseTestDeviceGroup, +) from openwisp_controller.config.tests.test_notifications import ( TestNotifications as BaseTestNotifications, ) @@ -46,6 +49,10 @@ class TestDevice(BaseTestDevice): pass +class TestDeviceGroup(BaseTestDeviceGroup): + pass + + class TestTag(BaseTestTag): pass @@ -87,6 +94,7 @@ class TestConfigApi(BaseTestConfigApi): del BaseTestTransactionConfig del BaseTestController del BaseTestDevice +del BaseTestDeviceGroup del BaseTestTag del BaseTestTemplate del BaseTestTemplateTransaction diff --git a/tests/openwisp2/settings.py b/tests/openwisp2/settings.py index a468daef2..aa81a9f36 100644 --- a/tests/openwisp2/settings.py +++ b/tests/openwisp2/settings.py @@ -216,6 +216,7 @@ OPENWISP_USERS_ORGANIZATIONUSER_MODEL = 'sample_users.OrganizationUser' OPENWISP_USERS_ORGANIZATIONOWNER_MODEL = 'sample_users.OrganizationOwner' CONFIG_DEVICE_MODEL = 'sample_config.Device' + CONFIG_DEVICEGROUP_MODEL = 'sample_config.DeviceGroup' CONFIG_CONFIG_MODEL = 'sample_config.Config' CONFIG_TEMPLATETAG_MODEL = 'sample_config.TemplateTag' CONFIG_TAGGEDTEMPLATE_MODEL = 'sample_config.TaggedTemplate' From 32bd648b686b116f1181c033346876a1aeabeba4 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Wed, 16 Jun 2021 21:50:28 +0530 Subject: [PATCH 02/15] [requested-changes] Miscellaneous requested changes --- README.rst | 2 ++ openwisp_controller/config/admin.py | 4 ++-- openwisp_controller/config/base/device.py | 5 ++++- openwisp_controller/config/base/device_group.py | 3 ++- openwisp_controller/config/migrations/0036_device_group.py | 5 ++++- openwisp_controller/config/signals.py | 2 +- openwisp_controller/config/tests/test_api.py | 2 +- openwisp_controller/config/tests/test_device.py | 6 +++++- 8 files changed, 21 insertions(+), 8 deletions(-) diff --git a/README.rst b/README.rst index 5e8a07737..17fab301f 100755 --- a/README.rst +++ b/README.rst @@ -1685,6 +1685,8 @@ It is not emitted when the device is created. **Arguments**: - ``instance``: instance of ``Device``. +- ``group_id``: primary key of ``DeviceGroup`` of ``Device`` +- ``old_group_id``: primary key of previous ``DeviceGroup`` of ``Device`` The signal is emitted when the device group changes. diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index 09ab6acab..bf579d17d 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -718,8 +718,8 @@ class Meta(BaseForm.Meta): labels = {'context': _('Metadata')} help_texts = { 'context': _( - # TODO: Rephrase this - 'In this section you can add metadata of the DeviceGroup' + 'Group meta data, use this field to store data which is related' + 'to this group and can be retrieved via the REST API.' ) } diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 606e7bdc3..168c4d15b 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -223,7 +223,10 @@ def _check_group_changed(self): if self.group_id != current.group_id: device_group_changed.send( - sender=self.__class__, instance=self, + sender=self.__class__, + instance=self, + group_id=self.group_id, + old_group_id=current.group_id, ) if self.name != current.name and self._has_config(): diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py index 72cdb89b7..f9452fd94 100644 --- a/openwisp_controller/config/base/device_group.py +++ b/openwisp_controller/config/base/device_group.py @@ -14,7 +14,7 @@ class AbstractDeviceGroup(OrgMixin, TimeStampedEditableModel): - name = models.CharField(max_length=60, unique=True, null=False, blank=False) + name = models.CharField(max_length=60, null=False, blank=False) description = models.TextField(blank=True, help_text=_('internal notes')) context = context = JSONField( blank=True, @@ -30,6 +30,7 @@ class Meta: abstract = True verbose_name = _('Device Group') verbose_name_plural = _('Device Groups') + unique_together = (('organization', 'name'),) def clean(self): try: diff --git a/openwisp_controller/config/migrations/0036_device_group.py b/openwisp_controller/config/migrations/0036_device_group.py index ceb4c1fde..762a17614 100644 --- a/openwisp_controller/config/migrations/0036_device_group.py +++ b/openwisp_controller/config/migrations/0036_device_group.py @@ -51,7 +51,7 @@ class Migration(migrations.Migration): verbose_name='modified', ), ), - ('name', models.CharField(max_length=60, unique=True)), + ('name', models.CharField(max_length=60)), ( 'description', models.TextField(blank=True, help_text='internal notes'), @@ -82,6 +82,9 @@ class Migration(migrations.Migration): }, bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model), ), + migrations.AlterUniqueTogether( + name='devicegroup', unique_together={('organization', 'name')}, + ), migrations.AddField( model_name='device', name='group', diff --git a/openwisp_controller/config/signals.py b/openwisp_controller/config/signals.py index c72bc1ac5..2c0b8ed4a 100644 --- a/openwisp_controller/config/signals.py +++ b/openwisp_controller/config/signals.py @@ -12,4 +12,4 @@ providing_args=['instance', 'management_ip', 'old_management_ip'] ) device_name_changed = Signal(providing_args=['instance']) -device_group_changed = Signal(providing_args=['instance']) +device_group_changed = Signal(providing_args=['instance', 'group', 'old_group']) diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index c1f5a3673..50127d4e8 100644 --- a/openwisp_controller/config/tests/test_api.py +++ b/openwisp_controller/config/tests/test_api.py @@ -76,7 +76,7 @@ def setUp(self): 'name': 'Access Points', 'description': 'Group for APs of default organization', 'organization': 'None', - 'context': {'captive_portal_urls': 'https://example.com'}, + 'context': {'captive_portal_url': 'https://example.com'}, } def test_device_create_with_config_api(self): diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index caaebf387..073aec17c 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -371,7 +371,11 @@ def test_device_group_changed_emitted(self): device.group = device_group device.save() handler.assert_called_once_with( - signal=device_group_changed, sender=Device, instance=device, + signal=device_group_changed, + sender=Device, + instance=device, + old_group_id=None, + group_id=device_group.id, ) def test_device_group_changed_not_emitted_on_creation(self): From 8c27f98fc2aea2a653406fcbe5070e1110f4cf8e Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 17 Jun 2021 00:50:34 +0530 Subject: [PATCH 03/15] [refactor] Reworked implementaion of device field changed checks --- openwisp_controller/config/base/device.py | 86 +++++++++++-------- .../config/tests/test_device.py | 12 +-- 2 files changed, 52 insertions(+), 46 deletions(-) diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 168c4d15b..aaa63bd49 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -101,7 +101,21 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._initial_management_ip = self.management_ip + self._set_initial_values() + + def _set_initial_values(self): + if self._is_field_deferred('management_ip'): + self._initial_management_ip = models.DEFERRED + else: + self._initial_management_ip = self.management_ip + if self._is_field_deferred('group_id'): + self._initial_group_id = models.DEFERRED + else: + self._initial_group_id = self.group_id + if self._is_field_deferred('name'): + self._initial_name = models.DEFERRED + else: + self._initial_name = self.name def __str__(self): return ( @@ -177,62 +191,64 @@ def save(self, *args, **kwargs): self.key = KeyField.default_callable() else: self.key = self.generate_key(shared_secret) - # update the status of the config object if the device name - # changed, but skip if the save operation is not touching the name - update_fields = kwargs.get('update_fields') - if not update_fields or 'name' in update_fields: - self._check_name_changed() - if not update_fields or 'group' in update_fields: - self._check_group_changed() super().save(*args, **kwargs) + self._check_changed_fields() + + def _check_changed_fields(self): + # self._load_deferred_fields(fields=['name', 'group_id', 'management_ip']) self._check_management_ip_changed() + self._check_name_changed() + self._check_group_changed() - @property - def _current_instance(self): - # Returns the instance current value from database - return ( - self._meta.model.objects.only( - 'id', - 'name', - 'management_ip', - 'group_id', - 'config__id', - 'config__status', - ) - .select_related('config') - .get(pk=self.pk) - ) + def _load_deferred_fields(self, fields=[]): + for field in fields: + try: + getattr(self, f'_initial_{field}') + except AttributeError: + current = [] + for field_ in fields: + current.append(getattr(self,)) + self.refresh_from_db(fields=set(fields)) + break + + def _is_field_deferred(self, field_name): + return field_name in self.get_deferred_fields() def _check_name_changed(self): - if self._state.adding: + if self._state.adding or ( + self._initial_name == models.DEFERRED and self._is_field_deferred('name') + ): return - current = self._current_instance - if self.name != current.name: + if self._initial_name != self.name: device_name_changed.send( sender=self.__class__, instance=self, ) - if self.name != current.name and self._has_config(): - self.config.set_status_modified() + if self._has_config(): + self.config.set_status_modified() def _check_group_changed(self): - if self._state.adding: + if self._state.adding or ( + self._initial_group_id == models.DEFERRED + and self._is_field_deferred('group_id') + ): return - current = self._current_instance - if self.group_id != current.group_id: + if self._initial_group_id != self.group_id: device_group_changed.send( sender=self.__class__, instance=self, group_id=self.group_id, - old_group_id=current.group_id, + old_group_id=self._initial_group_id, ) - if self.name != current.name and self._has_config(): - self.config.set_status_modified() - def _check_management_ip_changed(self): + if self._state.adding or ( + self._initial_management_ip == models.DEFERRED + and self._is_field_deferred('management_ip') + ): + return if self.management_ip != self._initial_management_ip: management_ip_changed.send( sender=self.__class__, diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index 073aec17c..c678e92b1 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -335,16 +335,6 @@ def test_name_unique_validation(self): message_dict['__all__'], ) - def test_check_name_changed_query(self): - org = self._get_org() - device = self._create_device(name='test', organization=org) - device.refresh_from_db() - with self.assertNumQueries(1): - device._check_name_changed() - with self.assertNumQueries(2): - device.name = 'changed' - device._check_name_changed() - def test_device_name_changed_emitted(self): org = self._get_org() device = self._create_device(name='test', organization=org) @@ -367,7 +357,7 @@ def test_device_group_changed_emitted(self): device = self._create_device(name='test', organization=org) device_group = self._create_device_group() - with catch_signal(device_group_changed) as handler, self.assertNumQueries(3): + with catch_signal(device_group_changed) as handler: device.group = device_group device.save() handler.assert_called_once_with( From 97275eb321dcac28bcf74f086813533203c10ac3 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 17 Jun 2021 01:57:47 +0530 Subject: [PATCH 04/15] [requested-change] Multitenant Device Group Admin --- openwisp_controller/config/admin.py | 13 ++++-- openwisp_controller/config/api/serializers.py | 4 +- .../config/base/device_group.py | 4 +- .../config/migrations/0036_device_group.py | 2 +- openwisp_controller/config/models.py | 2 +- .../config/tests/test_admin.py | 46 ++++++++++++++++++- openwisp_controller/config/tests/test_api.py | 6 +-- .../config/tests/test_device_group.py | 6 ++- openwisp_controller/config/tests/utils.py | 2 +- .../sample_config/migrations/0001_initial.py | 7 ++- tests/openwisp2/sample_config/tests.py | 7 +++ 11 files changed, 79 insertions(+), 20 deletions(-) diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index bf579d17d..1927db286 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -714,27 +714,30 @@ class Media(BaseConfigAdmin): class DeviceGroupForm(BaseForm): class Meta(BaseForm.Meta): model = DeviceGroup - widgets = {'context': DeviceGroupJsonSchemaWidget} - labels = {'context': _('Metadata')} + widgets = {'meta_data': DeviceGroupJsonSchemaWidget} + labels = {'meta_data': _('Metadata')} help_texts = { - 'context': _( + 'meta_data': _( 'Group meta data, use this field to store data which is related' 'to this group and can be retrieved via the REST API.' ) } -class DeviceGroupAdmin(BaseAdmin): +class DeviceGroupAdmin(MultitenantAdminMixin, BaseAdmin): form = DeviceGroupForm fields = [ 'name', 'organization', 'description', - 'context', + 'meta_data', 'created', 'modified', ] search_fields = ['name'] + list_filter = [ + ('organization', MultitenantOrgFilter), + ] class Media: css = {'all': (f'{prefix}css/admin.css',)} diff --git a/openwisp_controller/config/api/serializers.py b/openwisp_controller/config/api/serializers.py index 18f1205b9..eac6883bf 100644 --- a/openwisp_controller/config/api/serializers.py +++ b/openwisp_controller/config/api/serializers.py @@ -262,8 +262,8 @@ def update(self, instance, validated_data): class DeviceGroupSerializer(BaseSerializer): - context = serializers.JSONField(required=False, initial={}) + meta_data = serializers.JSONField(required=False, initial={}) class Meta(BaseMeta): model = DeviceGroup - fields = ['name', 'organization', 'description', 'context'] + fields = ['name', 'organization', 'description', 'meta_data'] diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/device_group.py index f9452fd94..d4cb33bbc 100644 --- a/openwisp_controller/config/base/device_group.py +++ b/openwisp_controller/config/base/device_group.py @@ -16,7 +16,7 @@ class AbstractDeviceGroup(OrgMixin, TimeStampedEditableModel): name = models.CharField(max_length=60, null=False, blank=False) description = models.TextField(blank=True, help_text=_('internal notes')) - context = context = JSONField( + meta_data = JSONField( blank=True, default=dict, load_kwargs={'object_pairs_hook': collections.OrderedDict}, @@ -35,7 +35,7 @@ class Meta: def clean(self): try: jsonschema.Draft4Validator(app_settings.DEVICE_GROUP_SCHEMA).validate( - self.context + self.meta_data ) except SchemaError as e: raise ValidationError({'input': e.message}) diff --git a/openwisp_controller/config/migrations/0036_device_group.py b/openwisp_controller/config/migrations/0036_device_group.py index 762a17614..e16f15aba 100644 --- a/openwisp_controller/config/migrations/0036_device_group.py +++ b/openwisp_controller/config/migrations/0036_device_group.py @@ -57,7 +57,7 @@ class Migration(migrations.Migration): models.TextField(blank=True, help_text='internal notes'), ), ( - 'context', + 'meta_data', jsonfield.fields.JSONField( blank=True, default=dict, diff --git a/openwisp_controller/config/models.py b/openwisp_controller/config/models.py index c3789b384..b6f97cf59 100644 --- a/openwisp_controller/config/models.py +++ b/openwisp_controller/config/models.py @@ -26,7 +26,7 @@ class DeviceGroup(AbstractDeviceGroup): class Meta(AbstractDeviceGroup.Meta): abstract = False - swappable = swapper.swappable_setting('config', 'devicegroup') + swappable = swapper.swappable_setting('config', 'DeviceGroup') class Config(AbstractConfig): diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 93f0a9c0f..5c133026d 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -13,7 +13,7 @@ from ...geo.tests.utils import TestGeoMixin from ...tests.utils import TestAdminMixin from .. import settings as app_settings -from .utils import CreateConfigTemplateMixin, TestVpnX509Mixin +from .utils import CreateConfigTemplateMixin, CreateDeviceGroupMixin, TestVpnX509Mixin devnull = open(os.devnull, 'w') Config = load_model('config', 'Config') @@ -25,6 +25,7 @@ User = get_user_model() Location = load_model('geo', 'Location') DeviceLocation = load_model('geo', 'DeviceLocation') +Group = load_model('openwisp_users', 'Group') class TestAdmin( @@ -1207,3 +1208,46 @@ def test_no_system_context(self): def tearDownClass(cls): super().tearDownClass() devnull.close() + + +class TestDeviceGroupAdmin( + CreateDeviceGroupMixin, TestOrganizationMixin, TestAdminMixin, TestCase +): + app_label = 'config' + + def setUp(self): + self.client.force_login(self._get_admin()) + + def test_multitenant_admin(self): + org1 = self._create_org(name='org1') + org2 = self._create_org(name='org2') + user = self._create_org_user( + organization=org1, is_admin=True, user=self._get_operator() + ).user + user.groups.add(Group.objects.get(name='Operator')) + + self._create_device_group(name='Org1 APs', organization=org1) + self._create_device_group(name='Org2 APs', organization=org2) + self.client.logout() + self.client.force_login(user) + + response = self.client.get( + reverse(f'admin:{self.app_label}_devicegroup_changelist') + ) + self.assertContains(response, 'Org1 APs') + self.assertNotContains(response, 'Org2 APs') + + def test_organization_filter(self): + org1 = self._create_org(name='org1') + org2 = self._create_org(name='org1') + self._create_device_group(name='Org1 APs', organization=org1) + self._create_device_group(name='Org2 APs', organization=org2) + url = reverse(f'admin:{self.app_label}_devicegroup_changelist') + query = f'?organization__id__exact={org1.pk}' + response = self.client.get(url) + self.assertContains(response, 'Org1 APs') + self.assertContains(response, 'Org2 APs') + + response = self.client.get(f'{url}{query}') + self.assertContains(response, 'Org1 APs') + self.assertNotContains(response, 'Org2 APs') diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index 50127d4e8..3f233dd12 100644 --- a/openwisp_controller/config/tests/test_api.py +++ b/openwisp_controller/config/tests/test_api.py @@ -76,7 +76,7 @@ def setUp(self): 'name': 'Access Points', 'description': 'Group for APs of default organization', 'organization': 'None', - 'context': {'captive_portal_url': 'https://example.com'}, + 'meta_data': {'captive_portal_url': 'https://example.com'}, } def test_device_create_with_config_api(self): @@ -587,7 +587,7 @@ def test_devicegroup_create_api(self): self.assertEqual(response.status_code, 201) self.assertEqual(response.data['name'], data['name']) self.assertEqual(response.data['description'], data['description']) - self.assertEqual(response.data['context'], data['context']) + self.assertEqual(response.data['meta_data'], data['meta_data']) self.assertEqual(response.data['organization'], org.pk) def test_devicegroup_list_api(self): @@ -605,5 +605,5 @@ def test_devicegroup_detail_api(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data[0]['name'], device_group.name) self.assertEqual(response.data[0]['description'], device_group.description) - self.assertDictEqual(response.data[0]['context'], device_group.context) + self.assertDictEqual(response.data[0]['meta_data'], device_group.meta_data) self.assertEqual(response.data[0]['organization'], device_group.organization.pk) diff --git a/openwisp_controller/config/tests/test_device_group.py b/openwisp_controller/config/tests/test_device_group.py index 627250710..bb494ecfd 100644 --- a/openwisp_controller/config/tests/test_device_group.py +++ b/openwisp_controller/config/tests/test_device_group.py @@ -14,7 +14,9 @@ class TestDeviceGroup(TestOrganizationMixin, CreateDeviceGroupMixin, TestCase): def test_device_group(self): - self._create_device_group(context={'captive_portal_url': 'https//example.com'}) + self._create_device_group( + meta_data={'captive_portal_url': 'https//example.com'} + ) self.assertEqual(DeviceGroup.objects.count(), 1) def test_device_group_schema_validation(self): @@ -35,5 +37,5 @@ def test_device_group_schema_validation(self): with self.subTest('Test for passing validation'): self._create_device_group( - context={'captive_portal_url': 'https://example.com'} + meta_data={'captive_portal_url': 'https://example.com'} ) diff --git a/openwisp_controller/config/tests/utils.py b/openwisp_controller/config/tests/utils.py index 0ebd57533..cbb3a4744 100644 --- a/openwisp_controller/config/tests/utils.py +++ b/openwisp_controller/config/tests/utils.py @@ -153,7 +153,7 @@ def _create_device_group(self, **kwargs): options = { 'name': 'Routers', 'description': 'Group for all routers', - 'context': {}, + 'meta_data': {}, } options.update(kwargs) if 'organization' not in options: diff --git a/tests/openwisp2/sample_config/migrations/0001_initial.py b/tests/openwisp2/sample_config/migrations/0001_initial.py index 41531ae83..d1c2c3416 100644 --- a/tests/openwisp2/sample_config/migrations/0001_initial.py +++ b/tests/openwisp2/sample_config/migrations/0001_initial.py @@ -614,13 +614,13 @@ class Migration(migrations.Migration): verbose_name='modified', ), ), - ('name', models.CharField(max_length=60, unique=True)), + ('name', models.CharField(max_length=60)), ( 'description', models.TextField(blank=True, help_text='internal notes'), ), ( - 'context', + 'meta_data', jsonfield.fields.JSONField( blank=True, default=dict, @@ -645,6 +645,9 @@ class Migration(migrations.Migration): }, bases=(openwisp_users.mixins.ValidateOrgMixin, models.Model), ), + migrations.AlterUniqueTogether( + name='devicegroup', unique_together={('organization', 'name')}, + ), migrations.CreateModel( name='Device', fields=[ diff --git a/tests/openwisp2/sample_config/tests.py b/tests/openwisp2/sample_config/tests.py index 558df0963..00099fe23 100644 --- a/tests/openwisp2/sample_config/tests.py +++ b/tests/openwisp2/sample_config/tests.py @@ -1,4 +1,7 @@ from openwisp_controller.config.tests.test_admin import TestAdmin as BaseTestAdmin +from openwisp_controller.config.tests.test_admin import ( + TestDeviceGroupAdmin as BaseTestDeviceGroupAdmin, +) from openwisp_controller.config.tests.test_api import TestConfigApi as BaseTestConfigApi from openwisp_controller.config.tests.test_apps import TestApps as BaseTestApps from openwisp_controller.config.tests.test_config import TestConfig as BaseTestConfig @@ -33,6 +36,10 @@ class TestAdmin(BaseTestAdmin): app_label = 'sample_config' +class TestDeviceGroupAdmin(BaseTestDeviceGroupAdmin): + app_label = 'sample_config' + + class TestConfig(BaseTestConfig): pass From 57aa696e38e162e910934e026115a65307b9bcbe Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 17 Jun 2021 02:11:40 +0530 Subject: [PATCH 05/15] [requested-change] Added image to REAMDE --- README.rst | 3 +++ docs/device-groups.png | Bin 0 -> 41989 bytes 2 files changed, 3 insertions(+) create mode 100644 docs/device-groups.png diff --git a/README.rst b/README.rst index 17fab301f..00575f208 100755 --- a/README.rst +++ b/README.rst @@ -1095,6 +1095,9 @@ You can achieve following by using Device Groups: information across all groups using `"OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA" <#openwisp-controller-device-group-schema>`_ setting. +.. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/device-groups.png + :alt: Device Group example + How to use configuration variables ---------------------------------- diff --git a/docs/device-groups.png b/docs/device-groups.png new file mode 100644 index 0000000000000000000000000000000000000000..57e63380d5bfabd3942b63060da31ab252248d5d GIT binary patch literal 41989 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 Q$pfPt(ma@X;QOEc2Xc^* Date: Thu, 17 Jun 2021 02:31:40 +0530 Subject: [PATCH 06/15] [requested-change] Minor fixes --- openwisp_controller/config/base/device.py | 12 ------------ tests/openwisp2/sample_config/tests.py | 1 + 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index aaa63bd49..1e2609e2f 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -195,22 +195,10 @@ def save(self, *args, **kwargs): self._check_changed_fields() def _check_changed_fields(self): - # self._load_deferred_fields(fields=['name', 'group_id', 'management_ip']) self._check_management_ip_changed() self._check_name_changed() self._check_group_changed() - def _load_deferred_fields(self, fields=[]): - for field in fields: - try: - getattr(self, f'_initial_{field}') - except AttributeError: - current = [] - for field_ in fields: - current.append(getattr(self,)) - self.refresh_from_db(fields=set(fields)) - break - def _is_field_deferred(self, field_name): return field_name in self.get_deferred_fields() diff --git a/tests/openwisp2/sample_config/tests.py b/tests/openwisp2/sample_config/tests.py index 00099fe23..6a02c3e1b 100644 --- a/tests/openwisp2/sample_config/tests.py +++ b/tests/openwisp2/sample_config/tests.py @@ -97,6 +97,7 @@ class TestConfigApi(BaseTestConfigApi): del BaseTestAdmin +del BaseTestDeviceGroupAdmin del BaseTestConfig del BaseTestTransactionConfig del BaseTestController From db7bc6a21ad787bea337c6e570aebf8546a2f2c4 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 18 Jun 2021 19:11:23 +0530 Subject: [PATCH 07/15] [change] Improved implementation of change field checks of Device model --- openwisp_controller/config/base/device.py | 75 +++++++++++-------- .../config/tests/test_device.py | 16 ++++ 2 files changed, 60 insertions(+), 31 deletions(-) diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 1e2609e2f..3b745f55f 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -22,6 +22,8 @@ class AbstractDevice(OrgMixin, BaseModel): physical properties of a network device """ + _changed_checked_fields = ['name', 'group_id', 'management_ip'] + name = models.CharField( max_length=64, unique=False, @@ -101,21 +103,14 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._set_initial_values() + self._set_initial_values_for_changed_checked_fields() - def _set_initial_values(self): - if self._is_field_deferred('management_ip'): - self._initial_management_ip = models.DEFERRED - else: - self._initial_management_ip = self.management_ip - if self._is_field_deferred('group_id'): - self._initial_group_id = models.DEFERRED - else: - self._initial_group_id = self.group_id - if self._is_field_deferred('name'): - self._initial_name = models.DEFERRED - else: - self._initial_name = self.name + def _set_initial_values_for_changed_checked_fields(self): + for field in self._changed_checked_fields: + if self._is_deferred(field): + setattr(self, f'_initial_{field}', models.DEFERRED) + else: + setattr(self, f'_initial_{field}', getattr(self, field)) def __str__(self): return ( @@ -195,17 +190,41 @@ def save(self, *args, **kwargs): self._check_changed_fields() def _check_changed_fields(self): - self._check_management_ip_changed() - self._check_name_changed() - self._check_group_changed() + self._get_initial_values_for_checked_fields() + # Execute method for checked for each field in self._changed_checked_fields + for field in self._changed_checked_fields: + getattr(self, f'_check_{field}_changed')() - def _is_field_deferred(self, field_name): - return field_name in self.get_deferred_fields() + def _is_deferred(self, field): + """ + Return a boolean whether the field is deferred. + """ + return field in self.get_deferred_fields() + + def _get_initial_values_for_checked_fields(self): + # Refresh values from database only when the checked field + # was initially deferred, but is no longer deferred now. + # Store the present value of such fields because they will + # be overwritten fetching values from database + # NOTE: Initial value of a field will only remain deferred + # if the current value of the field is still deferred. This + present_values = dict() + for field in self._changed_checked_fields: + if getattr( + self, f'_initial_{field}' + ) == models.DEFERRED and not self._is_deferred(field): + present_values[field] = getattr(self, field) + # Skip fetching values from database if all of the checked fields are + # still deferred, or were not deferred from the begining. + if not present_values: + return + self.refresh_from_db(fields=present_values.keys()) + for field in self._changed_checked_fields: + setattr(self, f'_initial_{field}', field) + setattr(self, field, present_values[field]) def _check_name_changed(self): - if self._state.adding or ( - self._initial_name == models.DEFERRED and self._is_field_deferred('name') - ): + if self._initial_name == models.DEFERRED: return if self._initial_name != self.name: @@ -216,11 +235,8 @@ def _check_name_changed(self): if self._has_config(): self.config.set_status_modified() - def _check_group_changed(self): - if self._state.adding or ( - self._initial_group_id == models.DEFERRED - and self._is_field_deferred('group_id') - ): + def _check_group_id_changed(self): + if self._initial_group_id == models.DEFERRED: return if self._initial_group_id != self.group_id: @@ -232,10 +248,7 @@ def _check_group_changed(self): ) def _check_management_ip_changed(self): - if self._state.adding or ( - self._initial_management_ip == models.DEFERRED - and self._is_field_deferred('management_ip') - ): + if self._initial_management_ip == models.DEFERRED: return if self.management_ip != self._initial_management_ip: management_ip_changed.send( diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index c678e92b1..ef2c74bc8 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -372,3 +372,19 @@ def test_device_group_changed_not_emitted_on_creation(self): with catch_signal(device_group_changed) as handler: self._create_device(organization=self._get_org()) handler.assert_not_called() + + def test_device_field_changed_checks(self): + self._create_device() + device_group = self._create_device_group() + with self.subTest('Deferred fields remained deferred'): + device = Device.objects.only('id', 'created').first() + device._check_changed_fields() + + with self.subTest('Deferred fields becomes non-deferred'): + device.name = 'new-name' + device.management_ip = '10.0.0.1' + device.group_id = device_group.id + # Another query is generated due to "config,set_status_modified" + # on name change + with self.assertNumQueries(2): + device._check_changed_fields() From 4d036d1dc8575127debc8e8b5275275e79b16ca8 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Thu, 1 Jul 2021 21:39:15 +0530 Subject: [PATCH 08/15] [chore] Fixed failing test for sample app --- .../migrations/0002_default_groups_permissions.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/openwisp2/sample_config/migrations/0002_default_groups_permissions.py b/tests/openwisp2/sample_config/migrations/0002_default_groups_permissions.py index afa82feaf..ec203b25d 100644 --- a/tests/openwisp2/sample_config/migrations/0002_default_groups_permissions.py +++ b/tests/openwisp2/sample_config/migrations/0002_default_groups_permissions.py @@ -1,6 +1,9 @@ from django.db import migrations -from openwisp_controller.config.migrations import assign_permissions_to_groups +from openwisp_controller.config.migrations import ( + assign_devicegroup_permissions_to_groups, + assign_permissions_to_groups, +) class Migration(migrations.Migration): @@ -9,5 +12,9 @@ class Migration(migrations.Migration): operations = [ migrations.RunPython( assign_permissions_to_groups, reverse_code=migrations.RunPython.noop - ) + ), + migrations.RunPython( + assign_devicegroup_permissions_to_groups, + reverse_code=migrations.RunPython.noop, + ), ] From 63090af2ff9999eb0eac9c0aa560cb3b0d1c756c Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 6 Jul 2021 17:37:28 +0530 Subject: [PATCH 09/15] [requested-change] Hide Advance Mode and Configuration Menu button from JSONSchema Editor --- .../config/static/config/css/devicegroup.css | 3 +++ openwisp_controller/config/widgets.py | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 openwisp_controller/config/static/config/css/devicegroup.css diff --git a/openwisp_controller/config/static/config/css/devicegroup.css b/openwisp_controller/config/static/config/css/devicegroup.css new file mode 100644 index 000000000..2af127f3f --- /dev/null +++ b/openwisp_controller/config/static/config/css/devicegroup.css @@ -0,0 +1,3 @@ +#id_meta_data_jsoneditor h3.controls { + display: none; +} diff --git a/openwisp_controller/config/widgets.py b/openwisp_controller/config/widgets.py index 43c0dbe9c..948019e72 100644 --- a/openwisp_controller/config/widgets.py +++ b/openwisp_controller/config/widgets.py @@ -60,5 +60,12 @@ class DeviceGroupJsonSchemaWidget(JsonSchemaWidget): ) app_label_model = f'{DeviceGroup._meta.app_label}_{DeviceGroup._meta.model_name}' netjsonconfig_hint = False - advanced_mode = True + advanced_mode = False extra_attrs = {} + + @property + def media(self): + media = super().media + css = media._css.copy() + css['all'] += ['config/css/devicegroup.css'] + return forms.Media(js=media._js, css=css) From 72039d604a73463570596c5985e21da09d7a354a Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 9 Jul 2021 22:20:15 +0530 Subject: [PATCH 10/15] [requested-change] Fixed JSONSchema editor for empty schema in DeviceGroup Admin --- .../config/static/config/css/devicegroup.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openwisp_controller/config/static/config/css/devicegroup.css b/openwisp_controller/config/static/config/css/devicegroup.css index 2af127f3f..017b07895 100644 --- a/openwisp_controller/config/static/config/css/devicegroup.css +++ b/openwisp_controller/config/static/config/css/devicegroup.css @@ -1,3 +1,13 @@ +#id_meta_data_jsoneditor { + min-height: 75px; +} +#id_meta_data_jsoneditor>div[data-schemaid="root"] { + margin-top: 15px; +} #id_meta_data_jsoneditor h3.controls { display: none; } +.jsoneditor-wrapper div[data-schemaid="root"]>label:first-of-type, +.jsoneditor-wrapper div[data-schemaid="root"]>select:first-of-type { + position: static; +} From 3c2d55ebc77bcb5aeb7971059d41e62281db2331 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Fri, 9 Jul 2021 22:34:21 +0530 Subject: [PATCH 11/15] [requested-change] Fixed DeviceGroup detail api endpoint --- openwisp_controller/config/api/views.py | 2 +- openwisp_controller/config/tests/test_api.py | 34 ++++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/openwisp_controller/config/api/views.py b/openwisp_controller/config/api/views.py index a96eb286a..03bc67f71 100644 --- a/openwisp_controller/config/api/views.py +++ b/openwisp_controller/config/api/views.py @@ -120,7 +120,7 @@ class DeviceGroupListCreateView(ProtectedAPIMixin, ListCreateAPIView): pagination_class = ListViewPagination -class DeviceGroupDetailView(ProtectedAPIMixin, ListCreateAPIView): +class DeviceGroupDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView): serializer_class = DeviceGroupSerializer queryset = DeviceGroup.objects.select_related('organization').order_by('-created') diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index 3f233dd12..e0e707d95 100644 --- a/openwisp_controller/config/tests/test_api.py +++ b/openwisp_controller/config/tests/test_api.py @@ -600,10 +600,30 @@ def test_devicegroup_list_api(self): def test_devicegroup_detail_api(self): device_group = self._create_device_group() path = reverse('config_api:devicegroup_detail', args=[device_group.pk]) - with self.assertNumQueries(3): - response = self.client.get(path) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.data[0]['name'], device_group.name) - self.assertEqual(response.data[0]['description'], device_group.description) - self.assertDictEqual(response.data[0]['meta_data'], device_group.meta_data) - self.assertEqual(response.data[0]['organization'], device_group.organization.pk) + + with self.subTest('Test GET'): + with self.assertNumQueries(3): + response = self.client.get(path) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['name'], device_group.name) + self.assertEqual(response.data['description'], device_group.description) + self.assertDictEqual(response.data['meta_data'], device_group.meta_data) + self.assertEqual( + response.data['organization'], device_group.organization.pk + ) + + with self.subTest('Test PATCH'): + response = self.client.patch( + path, + data={'meta_data': self._get_devicegroup_data['meta_data']}, + content_type='application/json', + ) + self.assertEqual(response.status_code, 200) + device_group.refresh_from_db() + self.assertDictEqual( + device_group.meta_data, self._get_devicegroup_data['meta_data'] + ) + + with self.subTest('Test DELETE'): + response = self.client.delete(path) + self.assertEqual(DeviceGroup.objects.count(), 0) From fb2c13526b617344a5c708986ab5f1dcd778c944 Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 12 Jul 2021 23:28:55 +0530 Subject: [PATCH 12/15] [requested-changes] Menu item, id in endpoint and default jsonschema for metdata --- README.rst | 10 +++++----- openwisp_controller/config/api/serializers.py | 10 +++++++++- openwisp_controller/config/apps.py | 1 + openwisp_controller/config/settings.py | 4 +++- .../config/static/config/css/devicegroup.css | 3 --- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index 00575f208..e3fa47256 100755 --- a/README.rst +++ b/README.rst @@ -631,11 +631,11 @@ Allows to specify a `list` of tuples for adding commands as described in ``OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA`` ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -+--------------+----------+ -| **type**: | ``dict`` | -+--------------+----------+ -| **default**: | ``{}`` | -+--------------+----------+ ++--------------+------------------------------------------+ +| **type**: | ``dict`` | ++--------------+------------------------------------------+ +| **default**: | ``{'type': 'object', 'properties': {}}`` | ++--------------+------------------------------------------+ Allows specifying JSONSchema used for validating meta-data of `Device Group <#device-groups>`_. diff --git a/openwisp_controller/config/api/serializers.py b/openwisp_controller/config/api/serializers.py index eac6883bf..01192bcc4 100644 --- a/openwisp_controller/config/api/serializers.py +++ b/openwisp_controller/config/api/serializers.py @@ -266,4 +266,12 @@ class DeviceGroupSerializer(BaseSerializer): class Meta(BaseMeta): model = DeviceGroup - fields = ['name', 'organization', 'description', 'meta_data'] + fields = [ + 'id', + 'name', + 'organization', + 'description', + 'meta_data', + 'created', + 'modified', + ] diff --git a/openwisp_controller/config/apps.py b/openwisp_controller/config/apps.py index 8fe416d5c..f6e0a5c58 100644 --- a/openwisp_controller/config/apps.py +++ b/openwisp_controller/config/apps.py @@ -89,6 +89,7 @@ def add_default_menu_items(self): {'model': get_model_name('config', 'Device')}, {'model': get_model_name('config', 'Template')}, {'model': get_model_name('config', 'Vpn')}, + {'model': get_model_name('config', 'DeviceGroup')}, ] if not hasattr(settings, menu_setting): setattr(settings, menu_setting, items) diff --git a/openwisp_controller/config/settings.py b/openwisp_controller/config/settings.py index 409f04d2a..76693337d 100644 --- a/openwisp_controller/config/settings.py +++ b/openwisp_controller/config/settings.py @@ -65,4 +65,6 @@ def get_settings_value(option, default): 'DEVICE_VERBOSE_NAME', (_('Device'), _('Devices')) ) DEVICE_NAME_UNIQUE = get_settings_value('DEVICE_NAME_UNIQUE', True) -DEVICE_GROUP_SCHEMA = get_settings_value('DEVICE_GROUP_SCHEMA', {}) +DEVICE_GROUP_SCHEMA = get_settings_value( + 'DEVICE_GROUP_SCHEMA', {'type': 'object', 'properties': {}} +) diff --git a/openwisp_controller/config/static/config/css/devicegroup.css b/openwisp_controller/config/static/config/css/devicegroup.css index 017b07895..4c1a1b70b 100644 --- a/openwisp_controller/config/static/config/css/devicegroup.css +++ b/openwisp_controller/config/static/config/css/devicegroup.css @@ -4,9 +4,6 @@ #id_meta_data_jsoneditor>div[data-schemaid="root"] { margin-top: 15px; } -#id_meta_data_jsoneditor h3.controls { - display: none; -} .jsoneditor-wrapper div[data-schemaid="root"]>label:first-of-type, .jsoneditor-wrapper div[data-schemaid="root"]>select:first-of-type { position: static; From 8de60b2e98c1973ea61bc93b5121b3997fc8a31e Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Mon, 12 Jul 2021 23:42:03 +0530 Subject: [PATCH 13/15] [chore] Renamed device_group > devicegroup in code --- README.rst | 12 ++++----- openwisp_controller/config/admin.py | 2 +- openwisp_controller/config/base/device.py | 4 +-- .../base/{device_group.py => devicegroup.py} | 2 +- ...36_device_group.py => 0036_devicegroup.py} | 0 openwisp_controller/config/models.py | 2 +- openwisp_controller/config/settings.py | 4 +-- openwisp_controller/config/signals.py | 2 +- .../config/tests/test_admin.py | 8 +++--- openwisp_controller/config/tests/test_api.py | 26 +++++++++---------- .../config/tests/test_device.py | 22 ++++++++-------- ...st_device_group.py => test_devicegroup.py} | 16 +++++------- openwisp_controller/config/tests/utils.py | 10 +++---- tests/openwisp2/sample_config/models.py | 2 +- tests/openwisp2/sample_config/tests.py | 2 +- 15 files changed, 55 insertions(+), 59 deletions(-) rename openwisp_controller/config/base/{device_group.py => devicegroup.py} (93%) rename openwisp_controller/config/migrations/{0036_device_group.py => 0036_devicegroup.py} (100%) rename openwisp_controller/config/tests/{test_device_group.py => test_devicegroup.py} (74%) diff --git a/README.rst b/README.rst index e3fa47256..58aa9f197 100755 --- a/README.rst +++ b/README.rst @@ -628,8 +628,8 @@ Allows to specify backend URL for API requests, if the frontend is hosted separa Allows to specify a `list` of tuples for adding commands as described in `'How to add commands" <#how-to-add-commands>`_ section. -``OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``OPENWISP_CONTROLLER_DEVICEGROUP_SCHEMA`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +--------------+------------------------------------------+ | **type**: | ``dict`` | @@ -1092,7 +1092,7 @@ You can achieve following by using Device Groups: - Group similar devices by having dedicated groups for access points, routers, etc. - Store additional information regarding a group in the structured metadata field. - Customize structure and validation of metadata field of DeviceGroup to standardize - information across all groups using `"OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA" <#openwisp-controller-device-group-schema>`_ + information across all groups using `"OPENWISP_CONTROLLER_DEVICEGROUP_SCHEMA" <#openwisp-controller-devicegroup-schema>`_ setting. .. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/device-groups.png @@ -1680,10 +1680,10 @@ The signal is emitted when the device name changes. It is not emitted when the device is created. -``device_group_changed`` -~~~~~~~~~~~~~~~~~~~~~~~~ +``devicegroup_changed`` +~~~~~~~~~~~~~~~~~~~~~~~ -**Path**: ``openwisp_controller.config.signals.device_group_changed`` +**Path**: ``openwisp_controller.config.signals.devicegroup_changed`` **Arguments**: diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index 1927db286..cc0c5cfc6 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -756,7 +756,7 @@ def get_urls(self): return urls def schema_view(self, request): - return JsonResponse(app_settings.DEVICE_GROUP_SCHEMA) + return JsonResponse(app_settings.DEVICEGROUP_SCHEMA) admin.site.register(Device, DeviceAdmin) diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 3b745f55f..472b15a1e 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -10,7 +10,7 @@ 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_name_changed, devicegroup_changed, management_ip_changed from ..validators import device_name_validator, mac_address_validator from .base import BaseModel @@ -240,7 +240,7 @@ def _check_group_id_changed(self): return if self._initial_group_id != self.group_id: - device_group_changed.send( + devicegroup_changed.send( sender=self.__class__, instance=self, group_id=self.group_id, diff --git a/openwisp_controller/config/base/device_group.py b/openwisp_controller/config/base/devicegroup.py similarity index 93% rename from openwisp_controller/config/base/device_group.py rename to openwisp_controller/config/base/devicegroup.py index d4cb33bbc..8293693a5 100644 --- a/openwisp_controller/config/base/device_group.py +++ b/openwisp_controller/config/base/devicegroup.py @@ -34,7 +34,7 @@ class Meta: def clean(self): try: - jsonschema.Draft4Validator(app_settings.DEVICE_GROUP_SCHEMA).validate( + jsonschema.Draft4Validator(app_settings.DEVICEGROUP_SCHEMA).validate( self.meta_data ) except SchemaError as e: diff --git a/openwisp_controller/config/migrations/0036_device_group.py b/openwisp_controller/config/migrations/0036_devicegroup.py similarity index 100% rename from openwisp_controller/config/migrations/0036_device_group.py rename to openwisp_controller/config/migrations/0036_devicegroup.py diff --git a/openwisp_controller/config/models.py b/openwisp_controller/config/models.py index b6f97cf59..fb4ef66ef 100644 --- a/openwisp_controller/config/models.py +++ b/openwisp_controller/config/models.py @@ -2,7 +2,7 @@ from .base.config import AbstractConfig from .base.device import AbstractDevice -from .base.device_group import AbstractDeviceGroup +from .base.devicegroup import AbstractDeviceGroup from .base.multitenancy import AbstractOrganizationConfigSettings from .base.tag import AbstractTaggedTemplate, AbstractTemplateTag from .base.template import AbstractTemplate diff --git a/openwisp_controller/config/settings.py b/openwisp_controller/config/settings.py index 76693337d..99cececb1 100644 --- a/openwisp_controller/config/settings.py +++ b/openwisp_controller/config/settings.py @@ -65,6 +65,6 @@ def get_settings_value(option, default): 'DEVICE_VERBOSE_NAME', (_('Device'), _('Devices')) ) DEVICE_NAME_UNIQUE = get_settings_value('DEVICE_NAME_UNIQUE', True) -DEVICE_GROUP_SCHEMA = get_settings_value( - 'DEVICE_GROUP_SCHEMA', {'type': 'object', 'properties': {}} +DEVICEGROUP_SCHEMA = get_settings_value( + 'DEVICEGROUP_SCHEMA', {'type': 'object', 'properties': {}} ) diff --git a/openwisp_controller/config/signals.py b/openwisp_controller/config/signals.py index 2c0b8ed4a..ccf4484c4 100644 --- a/openwisp_controller/config/signals.py +++ b/openwisp_controller/config/signals.py @@ -12,4 +12,4 @@ providing_args=['instance', 'management_ip', 'old_management_ip'] ) device_name_changed = Signal(providing_args=['instance']) -device_group_changed = Signal(providing_args=['instance', 'group', 'old_group']) +devicegroup_changed = Signal(providing_args=['instance', 'group', 'old_group']) diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 5c133026d..5e827947d 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -1226,8 +1226,8 @@ def test_multitenant_admin(self): ).user user.groups.add(Group.objects.get(name='Operator')) - self._create_device_group(name='Org1 APs', organization=org1) - self._create_device_group(name='Org2 APs', organization=org2) + self._create_devicegroup(name='Org1 APs', organization=org1) + self._create_devicegroup(name='Org2 APs', organization=org2) self.client.logout() self.client.force_login(user) @@ -1240,8 +1240,8 @@ def test_multitenant_admin(self): def test_organization_filter(self): org1 = self._create_org(name='org1') org2 = self._create_org(name='org1') - self._create_device_group(name='Org1 APs', organization=org1) - self._create_device_group(name='Org2 APs', organization=org2) + self._create_devicegroup(name='Org1 APs', organization=org1) + self._create_devicegroup(name='Org2 APs', organization=org2) url = reverse(f'admin:{self.app_label}_devicegroup_changelist') query = f'?organization__id__exact={org1.pk}' response = self.client.get(url) diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index e0e707d95..908e4d78f 100644 --- a/openwisp_controller/config/tests/test_api.py +++ b/openwisp_controller/config/tests/test_api.py @@ -134,13 +134,13 @@ def test_device_create_with_devicegroup(self): path = reverse('config_api:device_list') data = self._get_device_data.copy() org = self._get_org() - device_group = self._create_device_group() + devicegroup = self._create_devicegroup() data['organization'] = org.pk - data['group'] = device_group.pk + data['group'] = devicegroup.pk response = self.client.post(path, data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(Device.objects.count(), 1) - self.assertEqual(response.data['group'], device_group.pk) + self.assertEqual(response.data['group'], devicegroup.pk) def test_device_list_api(self): self._create_device() @@ -591,26 +591,24 @@ def test_devicegroup_create_api(self): self.assertEqual(response.data['organization'], org.pk) def test_devicegroup_list_api(self): - self._create_device_group() + self._create_devicegroup() path = reverse('config_api:devicegroup_list') with self.assertNumQueries(4): r = self.client.get(path) self.assertEqual(r.status_code, 200) def test_devicegroup_detail_api(self): - device_group = self._create_device_group() - path = reverse('config_api:devicegroup_detail', args=[device_group.pk]) + devicegroup = self._create_devicegroup() + path = reverse('config_api:devicegroup_detail', args=[devicegroup.pk]) with self.subTest('Test GET'): with self.assertNumQueries(3): response = self.client.get(path) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['name'], device_group.name) - self.assertEqual(response.data['description'], device_group.description) - self.assertDictEqual(response.data['meta_data'], device_group.meta_data) - self.assertEqual( - response.data['organization'], device_group.organization.pk - ) + self.assertEqual(response.data['name'], devicegroup.name) + self.assertEqual(response.data['description'], devicegroup.description) + self.assertDictEqual(response.data['meta_data'], devicegroup.meta_data) + self.assertEqual(response.data['organization'], devicegroup.organization.pk) with self.subTest('Test PATCH'): response = self.client.patch( @@ -619,9 +617,9 @@ def test_devicegroup_detail_api(self): content_type='application/json', ) self.assertEqual(response.status_code, 200) - device_group.refresh_from_db() + devicegroup.refresh_from_db() self.assertDictEqual( - device_group.meta_data, self._get_devicegroup_data['meta_data'] + devicegroup.meta_data, self._get_devicegroup_data['meta_data'] ) with self.subTest('Test DELETE'): diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index ef2c74bc8..11a65a46c 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -9,7 +9,7 @@ from openwisp_utils.tests import AssertNumQueriesSubTestMixin, catch_signal from .. import settings as app_settings -from ..signals import device_group_changed, device_name_changed, management_ip_changed +from ..signals import device_name_changed, devicegroup_changed, management_ip_changed from ..validators import device_name_validator, mac_address_validator from .utils import CreateConfigTemplateMixin, CreateDeviceGroupMixin @@ -352,30 +352,30 @@ def test_device_name_changed_not_emitted_on_creation(self): self._create_device(organization=self._get_org()) handler.assert_not_called() - def test_device_group_changed_emitted(self): + def test_devicegroup_changed_emitted(self): org = self._get_org() device = self._create_device(name='test', organization=org) - device_group = self._create_device_group() + devicegroup = self._create_devicegroup() - with catch_signal(device_group_changed) as handler: - device.group = device_group + with catch_signal(devicegroup_changed) as handler: + device.group = devicegroup device.save() handler.assert_called_once_with( - signal=device_group_changed, + signal=devicegroup_changed, sender=Device, instance=device, old_group_id=None, - group_id=device_group.id, + group_id=devicegroup.id, ) - def test_device_group_changed_not_emitted_on_creation(self): - with catch_signal(device_group_changed) as handler: + def test_devicegroup_changed_not_emitted_on_creation(self): + with catch_signal(devicegroup_changed) as handler: self._create_device(organization=self._get_org()) handler.assert_not_called() def test_device_field_changed_checks(self): self._create_device() - device_group = self._create_device_group() + devicegroup = self._create_devicegroup() with self.subTest('Deferred fields remained deferred'): device = Device.objects.only('id', 'created').first() device._check_changed_fields() @@ -383,7 +383,7 @@ def test_device_field_changed_checks(self): with self.subTest('Deferred fields becomes non-deferred'): device.name = 'new-name' device.management_ip = '10.0.0.1' - device.group_id = device_group.id + device.group_id = devicegroup.id # Another query is generated due to "config,set_status_modified" # on name change with self.assertNumQueries(2): diff --git a/openwisp_controller/config/tests/test_device_group.py b/openwisp_controller/config/tests/test_devicegroup.py similarity index 74% rename from openwisp_controller/config/tests/test_device_group.py rename to openwisp_controller/config/tests/test_devicegroup.py index bb494ecfd..30f7c12a3 100644 --- a/openwisp_controller/config/tests/test_device_group.py +++ b/openwisp_controller/config/tests/test_devicegroup.py @@ -13,14 +13,12 @@ class TestDeviceGroup(TestOrganizationMixin, CreateDeviceGroupMixin, TestCase): - def test_device_group(self): - self._create_device_group( - meta_data={'captive_portal_url': 'https//example.com'} - ) + def test_devicegroup(self): + self._create_devicegroup(meta_data={'captive_portal_url': 'https//example.com'}) self.assertEqual(DeviceGroup.objects.count(), 1) - def test_device_group_schema_validation(self): - device_group_schema = { + def test_devicegroup_schema_validation(self): + devicegroup_schema = { 'required': ['captive_portal_url'], 'properties': { 'captive_portal_url': { @@ -31,11 +29,11 @@ def test_device_group_schema_validation(self): 'additionalProperties': True, } - with patch.object(app_settings, 'DEVICE_GROUP_SCHEMA', device_group_schema): + with patch.object(app_settings, 'DEVICEGROUP_SCHEMA', devicegroup_schema): with self.subTest('Test for failing validation'): - self.assertRaises(ValidationError, self._create_device_group) + self.assertRaises(ValidationError, self._create_devicegroup) with self.subTest('Test for passing validation'): - self._create_device_group( + self._create_devicegroup( meta_data={'captive_portal_url': 'https://example.com'} ) diff --git a/openwisp_controller/config/tests/utils.py b/openwisp_controller/config/tests/utils.py index cbb3a4744..29b8250ef 100644 --- a/openwisp_controller/config/tests/utils.py +++ b/openwisp_controller/config/tests/utils.py @@ -149,7 +149,7 @@ def _create_config(self, **kwargs): class CreateDeviceGroupMixin: - def _create_device_group(self, **kwargs): + def _create_devicegroup(self, **kwargs): options = { 'name': 'Routers', 'description': 'Group for all routers', @@ -158,10 +158,10 @@ def _create_device_group(self, **kwargs): options.update(kwargs) if 'organization' not in options: options['organization'] = self._get_org() - device_group = DeviceGroup(**options) - device_group.full_clean() - device_group.save() - return device_group + devicegroup = DeviceGroup(**options) + devicegroup.full_clean() + devicegroup.save() + return devicegroup class SeleniumTestCase(StaticLiveServerTestCase): diff --git a/tests/openwisp2/sample_config/models.py b/tests/openwisp2/sample_config/models.py index 94346a480..737e261b5 100644 --- a/tests/openwisp2/sample_config/models.py +++ b/tests/openwisp2/sample_config/models.py @@ -2,7 +2,7 @@ from openwisp_controller.config.base.config import AbstractConfig from openwisp_controller.config.base.device import AbstractDevice -from openwisp_controller.config.base.device_group import AbstractDeviceGroup +from openwisp_controller.config.base.devicegroup import AbstractDeviceGroup from openwisp_controller.config.base.multitenancy import ( AbstractOrganizationConfigSettings, ) diff --git a/tests/openwisp2/sample_config/tests.py b/tests/openwisp2/sample_config/tests.py index 6a02c3e1b..c6760ec24 100644 --- a/tests/openwisp2/sample_config/tests.py +++ b/tests/openwisp2/sample_config/tests.py @@ -12,7 +12,7 @@ TestController as BaseTestController, ) from openwisp_controller.config.tests.test_device import TestDevice as BaseTestDevice -from openwisp_controller.config.tests.test_device_group import ( +from openwisp_controller.config.tests.test_devicegroup import ( TestDeviceGroup as BaseTestDeviceGroup, ) from openwisp_controller.config.tests.test_notifications import ( From e2e725af08554c84eb70963355b244ca4158d20f Mon Sep 17 00:00:00 2001 From: Gagan Deep Date: Tue, 13 Jul 2021 22:18:36 +0530 Subject: [PATCH 14/15] [requested-change] Changed group field verbose name in device model --- openwisp_controller/config/base/device.py | 2 +- openwisp_controller/config/migrations/0036_devicegroup.py | 2 +- tests/openwisp2/sample_config/migrations/0001_initial.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 472b15a1e..97a1b1ebb 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -68,7 +68,7 @@ class AbstractDevice(OrgMixin, BaseModel): notes = models.TextField(blank=True, help_text=_('internal notes')) group = models.ForeignKey( get_model_name('config', 'DeviceGroup'), - verbose_name=_('Device Group'), + verbose_name=_('Group'), on_delete=models.SET_NULL, blank=True, null=True, diff --git a/openwisp_controller/config/migrations/0036_devicegroup.py b/openwisp_controller/config/migrations/0036_devicegroup.py index e16f15aba..d6a2ae765 100644 --- a/openwisp_controller/config/migrations/0036_devicegroup.py +++ b/openwisp_controller/config/migrations/0036_devicegroup.py @@ -93,7 +93,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, to=swapper.get_model_name('config', 'DeviceGroup'), - verbose_name='Device Group', + verbose_name='Group', ), ), migrations.RunPython( diff --git a/tests/openwisp2/sample_config/migrations/0001_initial.py b/tests/openwisp2/sample_config/migrations/0001_initial.py index d1c2c3416..b9461fc9e 100644 --- a/tests/openwisp2/sample_config/migrations/0001_initial.py +++ b/tests/openwisp2/sample_config/migrations/0001_initial.py @@ -720,7 +720,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, to=swapper.get_model_name('config', 'DeviceGroup'), - verbose_name='Device Group', + verbose_name='Group', ), ), ( From 702be0301acb9c1147dea918f9e362f187915dda Mon Sep 17 00:00:00 2001 From: Federico Capoano Date: Wed, 14 Jul 2021 20:22:50 -0500 Subject: [PATCH 15/15] [change] Revert "[chore] Renamed device_group > devicegroup in code" This reverts commit 8de60b2e98c1973ea61bc93b5121b3997fc8a31e. --- README.rst | 12 ++++----- openwisp_controller/config/admin.py | 2 +- openwisp_controller/config/base/device.py | 4 +-- .../base/{devicegroup.py => device_group.py} | 2 +- ...36_devicegroup.py => 0036_device_group.py} | 0 openwisp_controller/config/models.py | 2 +- openwisp_controller/config/settings.py | 4 +-- openwisp_controller/config/signals.py | 2 +- .../config/tests/test_admin.py | 8 +++--- openwisp_controller/config/tests/test_api.py | 26 ++++++++++--------- .../config/tests/test_device.py | 22 ++++++++-------- ...st_devicegroup.py => test_device_group.py} | 16 +++++++----- openwisp_controller/config/tests/utils.py | 10 +++---- tests/openwisp2/sample_config/models.py | 2 +- tests/openwisp2/sample_config/tests.py | 2 +- 15 files changed, 59 insertions(+), 55 deletions(-) rename openwisp_controller/config/base/{devicegroup.py => device_group.py} (93%) rename openwisp_controller/config/migrations/{0036_devicegroup.py => 0036_device_group.py} (100%) rename openwisp_controller/config/tests/{test_devicegroup.py => test_device_group.py} (74%) diff --git a/README.rst b/README.rst index 58aa9f197..e3fa47256 100755 --- a/README.rst +++ b/README.rst @@ -628,8 +628,8 @@ Allows to specify backend URL for API requests, if the frontend is hosted separa Allows to specify a `list` of tuples for adding commands as described in `'How to add commands" <#how-to-add-commands>`_ section. -``OPENWISP_CONTROLLER_DEVICEGROUP_SCHEMA`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +``OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +--------------+------------------------------------------+ | **type**: | ``dict`` | @@ -1092,7 +1092,7 @@ You can achieve following by using Device Groups: - Group similar devices by having dedicated groups for access points, routers, etc. - Store additional information regarding a group in the structured metadata field. - Customize structure and validation of metadata field of DeviceGroup to standardize - information across all groups using `"OPENWISP_CONTROLLER_DEVICEGROUP_SCHEMA" <#openwisp-controller-devicegroup-schema>`_ + information across all groups using `"OPENWISP_CONTROLLER_DEVICE_GROUP_SCHEMA" <#openwisp-controller-device-group-schema>`_ setting. .. image:: https://raw.githubusercontent.com/openwisp/openwisp-controller/master/docs/device-groups.png @@ -1680,10 +1680,10 @@ The signal is emitted when the device name changes. It is not emitted when the device is created. -``devicegroup_changed`` -~~~~~~~~~~~~~~~~~~~~~~~ +``device_group_changed`` +~~~~~~~~~~~~~~~~~~~~~~~~ -**Path**: ``openwisp_controller.config.signals.devicegroup_changed`` +**Path**: ``openwisp_controller.config.signals.device_group_changed`` **Arguments**: diff --git a/openwisp_controller/config/admin.py b/openwisp_controller/config/admin.py index cc0c5cfc6..1927db286 100644 --- a/openwisp_controller/config/admin.py +++ b/openwisp_controller/config/admin.py @@ -756,7 +756,7 @@ def get_urls(self): return urls def schema_view(self, request): - return JsonResponse(app_settings.DEVICEGROUP_SCHEMA) + return JsonResponse(app_settings.DEVICE_GROUP_SCHEMA) admin.site.register(Device, DeviceAdmin) diff --git a/openwisp_controller/config/base/device.py b/openwisp_controller/config/base/device.py index 97a1b1ebb..5c82b25b5 100644 --- a/openwisp_controller/config/base/device.py +++ b/openwisp_controller/config/base/device.py @@ -10,7 +10,7 @@ from openwisp_utils.base import KeyField from .. import settings as app_settings -from ..signals import device_name_changed, devicegroup_changed, management_ip_changed +from ..signals import device_group_changed, device_name_changed, management_ip_changed from ..validators import device_name_validator, mac_address_validator from .base import BaseModel @@ -240,7 +240,7 @@ def _check_group_id_changed(self): return if self._initial_group_id != self.group_id: - devicegroup_changed.send( + device_group_changed.send( sender=self.__class__, instance=self, group_id=self.group_id, diff --git a/openwisp_controller/config/base/devicegroup.py b/openwisp_controller/config/base/device_group.py similarity index 93% rename from openwisp_controller/config/base/devicegroup.py rename to openwisp_controller/config/base/device_group.py index 8293693a5..d4cb33bbc 100644 --- a/openwisp_controller/config/base/devicegroup.py +++ b/openwisp_controller/config/base/device_group.py @@ -34,7 +34,7 @@ class Meta: def clean(self): try: - jsonschema.Draft4Validator(app_settings.DEVICEGROUP_SCHEMA).validate( + jsonschema.Draft4Validator(app_settings.DEVICE_GROUP_SCHEMA).validate( self.meta_data ) except SchemaError as e: diff --git a/openwisp_controller/config/migrations/0036_devicegroup.py b/openwisp_controller/config/migrations/0036_device_group.py similarity index 100% rename from openwisp_controller/config/migrations/0036_devicegroup.py rename to openwisp_controller/config/migrations/0036_device_group.py diff --git a/openwisp_controller/config/models.py b/openwisp_controller/config/models.py index fb4ef66ef..b6f97cf59 100644 --- a/openwisp_controller/config/models.py +++ b/openwisp_controller/config/models.py @@ -2,7 +2,7 @@ from .base.config import AbstractConfig from .base.device import AbstractDevice -from .base.devicegroup import AbstractDeviceGroup +from .base.device_group import AbstractDeviceGroup from .base.multitenancy import AbstractOrganizationConfigSettings from .base.tag import AbstractTaggedTemplate, AbstractTemplateTag from .base.template import AbstractTemplate diff --git a/openwisp_controller/config/settings.py b/openwisp_controller/config/settings.py index 99cececb1..76693337d 100644 --- a/openwisp_controller/config/settings.py +++ b/openwisp_controller/config/settings.py @@ -65,6 +65,6 @@ def get_settings_value(option, default): 'DEVICE_VERBOSE_NAME', (_('Device'), _('Devices')) ) DEVICE_NAME_UNIQUE = get_settings_value('DEVICE_NAME_UNIQUE', True) -DEVICEGROUP_SCHEMA = get_settings_value( - 'DEVICEGROUP_SCHEMA', {'type': 'object', 'properties': {}} +DEVICE_GROUP_SCHEMA = get_settings_value( + 'DEVICE_GROUP_SCHEMA', {'type': 'object', 'properties': {}} ) diff --git a/openwisp_controller/config/signals.py b/openwisp_controller/config/signals.py index ccf4484c4..2c0b8ed4a 100644 --- a/openwisp_controller/config/signals.py +++ b/openwisp_controller/config/signals.py @@ -12,4 +12,4 @@ providing_args=['instance', 'management_ip', 'old_management_ip'] ) device_name_changed = Signal(providing_args=['instance']) -devicegroup_changed = Signal(providing_args=['instance', 'group', 'old_group']) +device_group_changed = Signal(providing_args=['instance', 'group', 'old_group']) diff --git a/openwisp_controller/config/tests/test_admin.py b/openwisp_controller/config/tests/test_admin.py index 5e827947d..5c133026d 100644 --- a/openwisp_controller/config/tests/test_admin.py +++ b/openwisp_controller/config/tests/test_admin.py @@ -1226,8 +1226,8 @@ def test_multitenant_admin(self): ).user user.groups.add(Group.objects.get(name='Operator')) - self._create_devicegroup(name='Org1 APs', organization=org1) - self._create_devicegroup(name='Org2 APs', organization=org2) + self._create_device_group(name='Org1 APs', organization=org1) + self._create_device_group(name='Org2 APs', organization=org2) self.client.logout() self.client.force_login(user) @@ -1240,8 +1240,8 @@ def test_multitenant_admin(self): def test_organization_filter(self): org1 = self._create_org(name='org1') org2 = self._create_org(name='org1') - self._create_devicegroup(name='Org1 APs', organization=org1) - self._create_devicegroup(name='Org2 APs', organization=org2) + self._create_device_group(name='Org1 APs', organization=org1) + self._create_device_group(name='Org2 APs', organization=org2) url = reverse(f'admin:{self.app_label}_devicegroup_changelist') query = f'?organization__id__exact={org1.pk}' response = self.client.get(url) diff --git a/openwisp_controller/config/tests/test_api.py b/openwisp_controller/config/tests/test_api.py index 908e4d78f..e0e707d95 100644 --- a/openwisp_controller/config/tests/test_api.py +++ b/openwisp_controller/config/tests/test_api.py @@ -134,13 +134,13 @@ def test_device_create_with_devicegroup(self): path = reverse('config_api:device_list') data = self._get_device_data.copy() org = self._get_org() - devicegroup = self._create_devicegroup() + device_group = self._create_device_group() data['organization'] = org.pk - data['group'] = devicegroup.pk + data['group'] = device_group.pk response = self.client.post(path, data, content_type='application/json') self.assertEqual(response.status_code, 201) self.assertEqual(Device.objects.count(), 1) - self.assertEqual(response.data['group'], devicegroup.pk) + self.assertEqual(response.data['group'], device_group.pk) def test_device_list_api(self): self._create_device() @@ -591,24 +591,26 @@ def test_devicegroup_create_api(self): self.assertEqual(response.data['organization'], org.pk) def test_devicegroup_list_api(self): - self._create_devicegroup() + self._create_device_group() path = reverse('config_api:devicegroup_list') with self.assertNumQueries(4): r = self.client.get(path) self.assertEqual(r.status_code, 200) def test_devicegroup_detail_api(self): - devicegroup = self._create_devicegroup() - path = reverse('config_api:devicegroup_detail', args=[devicegroup.pk]) + device_group = self._create_device_group() + path = reverse('config_api:devicegroup_detail', args=[device_group.pk]) with self.subTest('Test GET'): with self.assertNumQueries(3): response = self.client.get(path) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data['name'], devicegroup.name) - self.assertEqual(response.data['description'], devicegroup.description) - self.assertDictEqual(response.data['meta_data'], devicegroup.meta_data) - self.assertEqual(response.data['organization'], devicegroup.organization.pk) + self.assertEqual(response.data['name'], device_group.name) + self.assertEqual(response.data['description'], device_group.description) + self.assertDictEqual(response.data['meta_data'], device_group.meta_data) + self.assertEqual( + response.data['organization'], device_group.organization.pk + ) with self.subTest('Test PATCH'): response = self.client.patch( @@ -617,9 +619,9 @@ def test_devicegroup_detail_api(self): content_type='application/json', ) self.assertEqual(response.status_code, 200) - devicegroup.refresh_from_db() + device_group.refresh_from_db() self.assertDictEqual( - devicegroup.meta_data, self._get_devicegroup_data['meta_data'] + device_group.meta_data, self._get_devicegroup_data['meta_data'] ) with self.subTest('Test DELETE'): diff --git a/openwisp_controller/config/tests/test_device.py b/openwisp_controller/config/tests/test_device.py index 11a65a46c..ef2c74bc8 100644 --- a/openwisp_controller/config/tests/test_device.py +++ b/openwisp_controller/config/tests/test_device.py @@ -9,7 +9,7 @@ from openwisp_utils.tests import AssertNumQueriesSubTestMixin, catch_signal from .. import settings as app_settings -from ..signals import device_name_changed, devicegroup_changed, management_ip_changed +from ..signals import device_group_changed, device_name_changed, management_ip_changed from ..validators import device_name_validator, mac_address_validator from .utils import CreateConfigTemplateMixin, CreateDeviceGroupMixin @@ -352,30 +352,30 @@ def test_device_name_changed_not_emitted_on_creation(self): self._create_device(organization=self._get_org()) handler.assert_not_called() - def test_devicegroup_changed_emitted(self): + def test_device_group_changed_emitted(self): org = self._get_org() device = self._create_device(name='test', organization=org) - devicegroup = self._create_devicegroup() + device_group = self._create_device_group() - with catch_signal(devicegroup_changed) as handler: - device.group = devicegroup + with catch_signal(device_group_changed) as handler: + device.group = device_group device.save() handler.assert_called_once_with( - signal=devicegroup_changed, + signal=device_group_changed, sender=Device, instance=device, old_group_id=None, - group_id=devicegroup.id, + group_id=device_group.id, ) - def test_devicegroup_changed_not_emitted_on_creation(self): - with catch_signal(devicegroup_changed) as handler: + def test_device_group_changed_not_emitted_on_creation(self): + with catch_signal(device_group_changed) as handler: self._create_device(organization=self._get_org()) handler.assert_not_called() def test_device_field_changed_checks(self): self._create_device() - devicegroup = self._create_devicegroup() + device_group = self._create_device_group() with self.subTest('Deferred fields remained deferred'): device = Device.objects.only('id', 'created').first() device._check_changed_fields() @@ -383,7 +383,7 @@ def test_device_field_changed_checks(self): with self.subTest('Deferred fields becomes non-deferred'): device.name = 'new-name' device.management_ip = '10.0.0.1' - device.group_id = devicegroup.id + device.group_id = device_group.id # Another query is generated due to "config,set_status_modified" # on name change with self.assertNumQueries(2): diff --git a/openwisp_controller/config/tests/test_devicegroup.py b/openwisp_controller/config/tests/test_device_group.py similarity index 74% rename from openwisp_controller/config/tests/test_devicegroup.py rename to openwisp_controller/config/tests/test_device_group.py index 30f7c12a3..bb494ecfd 100644 --- a/openwisp_controller/config/tests/test_devicegroup.py +++ b/openwisp_controller/config/tests/test_device_group.py @@ -13,12 +13,14 @@ class TestDeviceGroup(TestOrganizationMixin, CreateDeviceGroupMixin, TestCase): - def test_devicegroup(self): - self._create_devicegroup(meta_data={'captive_portal_url': 'https//example.com'}) + def test_device_group(self): + self._create_device_group( + meta_data={'captive_portal_url': 'https//example.com'} + ) self.assertEqual(DeviceGroup.objects.count(), 1) - def test_devicegroup_schema_validation(self): - devicegroup_schema = { + def test_device_group_schema_validation(self): + device_group_schema = { 'required': ['captive_portal_url'], 'properties': { 'captive_portal_url': { @@ -29,11 +31,11 @@ def test_devicegroup_schema_validation(self): 'additionalProperties': True, } - with patch.object(app_settings, 'DEVICEGROUP_SCHEMA', devicegroup_schema): + with patch.object(app_settings, 'DEVICE_GROUP_SCHEMA', device_group_schema): with self.subTest('Test for failing validation'): - self.assertRaises(ValidationError, self._create_devicegroup) + self.assertRaises(ValidationError, self._create_device_group) with self.subTest('Test for passing validation'): - self._create_devicegroup( + self._create_device_group( meta_data={'captive_portal_url': 'https://example.com'} ) diff --git a/openwisp_controller/config/tests/utils.py b/openwisp_controller/config/tests/utils.py index 29b8250ef..cbb3a4744 100644 --- a/openwisp_controller/config/tests/utils.py +++ b/openwisp_controller/config/tests/utils.py @@ -149,7 +149,7 @@ def _create_config(self, **kwargs): class CreateDeviceGroupMixin: - def _create_devicegroup(self, **kwargs): + def _create_device_group(self, **kwargs): options = { 'name': 'Routers', 'description': 'Group for all routers', @@ -158,10 +158,10 @@ def _create_devicegroup(self, **kwargs): options.update(kwargs) if 'organization' not in options: options['organization'] = self._get_org() - devicegroup = DeviceGroup(**options) - devicegroup.full_clean() - devicegroup.save() - return devicegroup + device_group = DeviceGroup(**options) + device_group.full_clean() + device_group.save() + return device_group class SeleniumTestCase(StaticLiveServerTestCase): diff --git a/tests/openwisp2/sample_config/models.py b/tests/openwisp2/sample_config/models.py index 737e261b5..94346a480 100644 --- a/tests/openwisp2/sample_config/models.py +++ b/tests/openwisp2/sample_config/models.py @@ -2,7 +2,7 @@ from openwisp_controller.config.base.config import AbstractConfig from openwisp_controller.config.base.device import AbstractDevice -from openwisp_controller.config.base.devicegroup import AbstractDeviceGroup +from openwisp_controller.config.base.device_group import AbstractDeviceGroup from openwisp_controller.config.base.multitenancy import ( AbstractOrganizationConfigSettings, ) diff --git a/tests/openwisp2/sample_config/tests.py b/tests/openwisp2/sample_config/tests.py index c6760ec24..6a02c3e1b 100644 --- a/tests/openwisp2/sample_config/tests.py +++ b/tests/openwisp2/sample_config/tests.py @@ -12,7 +12,7 @@ TestController as BaseTestController, ) from openwisp_controller.config.tests.test_device import TestDevice as BaseTestDevice -from openwisp_controller.config.tests.test_devicegroup import ( +from openwisp_controller.config.tests.test_device_group import ( TestDeviceGroup as BaseTestDeviceGroup, ) from openwisp_controller.config.tests.test_notifications import (