Skip to content

Commit d29a35b

Browse files
authored
Merge branch 'master' into issues/204-sortedm2m-queries
2 parents 46f4a12 + e1d4e69 commit d29a35b

File tree

21 files changed

+357
-193
lines changed

21 files changed

+357
-193
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ jobs:
8787
- name: Tests
8888
if: ${{ !cancelled() && steps.deps.conclusion == 'success' }}
8989
run: |
90-
coverage run runtests.py --parallel
90+
coverage run runtests.py --parallel || coverage run ./runtests.py
9191
# tests the extension capability
92-
SAMPLE_APP=1 coverage run ./runtests.py --parallel --keepdb --exclude-tag=selenium_tests
92+
SAMPLE_APP=1 coverage run ./runtests.py --parallel --keepdb --exclude-tag=selenium_tests \
93+
|| SAMPLE_APP=1 coverage run ./runtests.py --keepdb --exclude-tag=selenium_tests
9394
coverage combine
9495
coverage xml
9596
env:

openwisp_controller/config/api/serializers.py

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
from copy import deepcopy
2-
31
from django.core.exceptions import ValidationError
42
from django.db import transaction
53
from django.db.models import Q
64
from django.utils.translation import gettext_lazy as _
75
from rest_framework import serializers
86
from swapper import load_model
97

10-
from openwisp_users.api.mixins import FilterSerializerByOrgManaged
118
from openwisp_utils.api.serializers import ValidatedModelSerializer
129

10+
from ...serializers import BaseSerializer
1311
from .. import settings as app_settings
1412

1513
Template = load_model('config', 'Template')
@@ -24,10 +22,6 @@ class BaseMeta:
2422
read_only_fields = ['created', 'modified']
2523

2624

27-
class BaseSerializer(FilterSerializerByOrgManaged, ValidatedModelSerializer):
28-
pass
29-
30-
3125
class TemplateSerializer(BaseSerializer):
3226
config = serializers.JSONField(initial={}, required=False)
3327
tags = serializers.StringRelatedField(many=True, read_only=True)
@@ -110,7 +104,12 @@ def get_queryset(self):
110104
return queryset
111105

112106

113-
class BaseConfigSerializer(serializers.ModelSerializer):
107+
class BaseConfigSerializer(ValidatedModelSerializer):
108+
# The device object is excluded from validation
109+
# because this serializer focuses on validating
110+
# config objects.
111+
exclude_validation = ['device']
112+
114113
class Meta:
115114
model = Config
116115
fields = ['status', 'error_reason', 'backend', 'templates', 'context', 'config']
@@ -119,8 +118,26 @@ class Meta:
119118
'error_reason': {'read_only': True},
120119
}
121120

121+
def validate(self, data):
122+
"""
123+
The validation here is a bit tricky:
124+
125+
For existing devices, we have to perform the
126+
model validation of the existing config object,
127+
because if we simulate the validation on a new
128+
config object pointing to an existing device,
129+
the validation will fail because a config object
130+
for this device already exists (due to one-to-one relationship).
131+
"""
132+
device = self.context.get('device')
133+
if not self.instance and device:
134+
# Existing device with existing config
135+
if device._has_config():
136+
self.instance = device.config
137+
return super().validate(data)
138+
122139

123-
class DeviceConfigMixin(object):
140+
class DeviceConfigSerializer(BaseSerializer):
124141
def _get_config_templates(self, config_data):
125142
return [template.pk for template in config_data.pop('templates', [])]
126143

@@ -131,11 +148,29 @@ def _prepare_config(self, device, config_data):
131148
config.full_clean()
132149
return config
133150

151+
def _is_config_data_relevant(self, config_data):
152+
"""
153+
Returns True if ``config_data`` does not equal
154+
the default values and hence the config is useful.
155+
"""
156+
return not (
157+
config_data.get('backend') == app_settings.DEFAULT_BACKEND
158+
and not config_data.get('templates')
159+
and not config_data.get('context')
160+
and not config_data.get('config')
161+
)
162+
134163
@transaction.atomic
135164
def _create_config(self, device, config_data):
136165
config_templates = self._get_config_templates(config_data)
137166
try:
138167
if not device._has_config():
168+
# if the user hasn't set any useful config data, skip
169+
if (
170+
not self._is_config_data_relevant(config_data)
171+
and not config_templates
172+
):
173+
return
139174
config = Config(device=device, **config_data)
140175
config.full_clean()
141176
config.save()
@@ -151,15 +186,10 @@ def _create_config(self, device, config_data):
151186
raise serializers.ValidationError({'config': error.messages})
152187

153188
def _update_config(self, device, config_data):
154-
if (
155-
config_data.get('backend') == app_settings.DEFAULT_BACKEND
156-
and not config_data.get('templates')
157-
and not config_data.get('context')
158-
and not config_data.get('config')
159-
):
160-
# Do not create Config object if config_data only
161-
# contains the default value.
162-
# See https://github.com/openwisp/openwisp-controller/issues/699
189+
# Do not create Config object if config_data only
190+
# contains the default values.
191+
# See https://github.com/openwisp/openwisp-controller/issues/699
192+
if not self._is_config_data_relevant(config_data):
163193
return
164194
if not device._has_config():
165195
return self._create_config(device, config_data)
@@ -190,9 +220,7 @@ class DeviceListConfigSerializer(BaseConfigSerializer):
190220
templates = FilterTemplatesByOrganization(many=True, write_only=True)
191221

192222

193-
class DeviceListSerializer(
194-
DeviceConfigMixin, FilterSerializerByOrgManaged, serializers.ModelSerializer
195-
):
223+
class DeviceListSerializer(DeviceConfigSerializer):
196224
config = DeviceListConfigSerializer(required=False)
197225

198226
class Meta(BaseMeta):
@@ -219,14 +247,13 @@ class Meta(BaseMeta):
219247
'management_ip': {'allow_blank': True},
220248
}
221249

222-
def validate(self, attrs):
223-
device_data = deepcopy(attrs)
250+
def validate(self, data):
224251
# Validation of "config" is performed after
225252
# device object is created in the "create" method.
226-
device_data.pop('config', None)
227-
device = self.instance or self.Meta.model(**device_data)
228-
device.full_clean()
229-
return attrs
253+
config_data = data.pop('config', None)
254+
data = super().validate(data)
255+
data['config'] = config_data
256+
return data
230257

231258
def create(self, validated_data):
232259
config_data = validated_data.pop('config', None)
@@ -247,7 +274,7 @@ class DeviceDetailConfigSerializer(BaseConfigSerializer):
247274
templates = FilterTemplatesByOrganization(many=True)
248275

249276

250-
class DeviceDetailSerializer(DeviceConfigMixin, BaseSerializer):
277+
class DeviceDetailSerializer(DeviceConfigSerializer):
251278
config = DeviceDetailConfigSerializer(allow_null=True)
252279
is_deactivated = serializers.BooleanField(read_only=True)
253280

@@ -280,7 +307,7 @@ def update(self, instance, validated_data):
280307
if config_data:
281308
self._update_config(instance, config_data)
282309

283-
elif hasattr(instance, 'config') and validated_data.get('organization'):
310+
elif instance._has_config() and validated_data.get('organization'):
284311
if instance.organization != validated_data.get('organization'):
285312
# config.device.organization is used for validating
286313
# the organization of templates. It is also used for adding

openwisp_controller/config/api/urls.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ def get_api_urls(api_views):
1919
name='template_list',
2020
),
2121
path(
22-
'controller/template/<str:pk>/',
22+
'controller/template/<uuid:pk>/',
2323
api_views.template_detail,
2424
name='template_detail',
2525
),
2626
path(
27-
'controller/template/<str:pk>/configuration/',
27+
'controller/template/<uuid:pk>/configuration/',
2828
api_download_views.download_template_config,
2929
name='download_template_config',
3030
),
@@ -34,12 +34,12 @@ def get_api_urls(api_views):
3434
name='vpn_list',
3535
),
3636
path(
37-
'controller/vpn/<str:pk>/',
37+
'controller/vpn/<uuid:pk>/',
3838
api_views.vpn_detail,
3939
name='vpn_detail',
4040
),
4141
path(
42-
'controller/vpn/<str:pk>/configuration/',
42+
'controller/vpn/<uuid:pk>/configuration/',
4343
api_download_views.download_vpn_config,
4444
name='download_vpn_config',
4545
),
@@ -49,17 +49,17 @@ def get_api_urls(api_views):
4949
name='device_list',
5050
),
5151
path(
52-
'controller/device/<str:pk>/',
52+
'controller/device/<uuid:pk>/',
5353
api_views.device_detail,
5454
name='device_detail',
5555
),
5656
path(
57-
'controller/device/<str:pk>/activate/',
57+
'controller/device/<uuid:pk>/activate/',
5858
api_views.device_activate,
5959
name='device_activate',
6060
),
6161
path(
62-
'controller/device/<str:pk>/deactivate/',
62+
'controller/device/<uuid:pk>/deactivate/',
6363
api_views.device_deactivate,
6464
name='device_deactivate',
6565
),
@@ -69,7 +69,7 @@ def get_api_urls(api_views):
6969
name='devicegroup_list',
7070
),
7171
path(
72-
'controller/group/<str:pk>/',
72+
'controller/group/<uuid:pk>/',
7373
api_views.devicegroup_detail,
7474
name='devicegroup_detail',
7575
),
@@ -79,7 +79,7 @@ def get_api_urls(api_views):
7979
name='devicegroup_x509_commonname',
8080
),
8181
path(
82-
'controller/device/<str:pk>/configuration/',
82+
'controller/device/<uuid:pk>/configuration/',
8383
api_download_views.download_device_config,
8484
name='download_device_config',
8585
),

openwisp_controller/config/api/views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ def perform_destroy(self, instance):
111111
force_deletion = self.request.query_params.get('force', None) == 'true'
112112
instance.delete(check_deactivated=(not force_deletion))
113113

114+
def get_object(self):
115+
"""Set device property for serializer context."""
116+
obj = super().get_object()
117+
self.device = obj
118+
return obj
119+
120+
def get_serializer_context(self):
121+
"""Add device to serializer context for validation purposes."""
122+
context = super().get_serializer_context()
123+
context['device'] = self.device
124+
return context
125+
114126

115127
class DeviceActivateView(ProtectedAPIMixin, GenericAPIView):
116128
serializer_class = serializers.Serializer

openwisp_controller/config/base/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,13 +469,13 @@ def clean(self):
469469
* modifies status if key attributes of the configuration
470470
have changed (queries the database)
471471
"""
472-
super().clean()
473472
if not self.context:
474473
self.context = {}
475474
if not isinstance(self.context, dict):
476475
raise ValidationError(
477476
{'context': _('the supplied value is not a JSON object')}
478477
)
478+
super().clean()
479479

480480
def save(self, *args, **kwargs):
481481
created = self._state.adding

openwisp_controller/config/static/config/js/unsaved_changes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"config-0-config",
1212
"config-0-context",
1313
"devicelocation-0-geometry",
14+
"geometry",
1415
];
1516
// ignore fields that have no name attribute, begin with "_" or "initial-"
1617
if (

0 commit comments

Comments
 (0)