Skip to content

Commit d25ab75

Browse files
authored
Merge branch 'master' into feature/894-rest-api-revisions
2 parents df5c3fa + ee8d2fe commit d25ab75

File tree

27 files changed

+476
-217
lines changed

27 files changed

+476
-217
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/admin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from django.utils.translation import ngettext_lazy
2727
from flat_json_widget.widgets import FlatJsonWidget
2828
from import_export.admin import ImportExportMixin
29+
from import_export.forms import ExportForm
2930
from openwisp_ipam.filters import SubnetFilter
3031
from swapper import load_model
3132

@@ -971,6 +972,7 @@ def add_reversion_following(cls, follow):
971972

972973

973974
class DeviceAdminExportable(ImportExportMixin, DeviceAdmin):
975+
export_form_class = ExportForm
974976
resource_class = DeviceResource
975977
# needed to support both reversion and import-export
976978
change_list_template = 'admin/config/change_list_device.html'

openwisp_controller/config/api/serializers.py

Lines changed: 56 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
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
@@ -8,9 +6,9 @@
86
from reversion.models import Version
97
from swapper import load_model
108

11-
from openwisp_users.api.mixins import FilterSerializerByOrgManaged
129
from openwisp_utils.api.serializers import ValidatedModelSerializer
1310

11+
from ...serializers import BaseSerializer
1412
from .. import settings as app_settings
1513

1614
Template = load_model('config', 'Template')
@@ -25,10 +23,6 @@ class BaseMeta:
2523
read_only_fields = ['created', 'modified']
2624

2725

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

113107

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

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

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

@@ -132,11 +149,29 @@ def _prepare_config(self, device, config_data):
132149
config.full_clean()
133150
return config
134151

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

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

193223

194-
class DeviceListSerializer(
195-
DeviceConfigMixin, FilterSerializerByOrgManaged, serializers.ModelSerializer
196-
):
224+
class DeviceListSerializer(DeviceConfigSerializer):
197225
config = DeviceListConfigSerializer(required=False)
198226

199227
class Meta(BaseMeta):
@@ -220,14 +248,13 @@ class Meta(BaseMeta):
220248
'management_ip': {'allow_blank': True},
221249
}
222250

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

232259
def create(self, validated_data):
233260
config_data = validated_data.pop('config', None)
@@ -248,7 +275,7 @@ class DeviceDetailConfigSerializer(BaseConfigSerializer):
248275
templates = FilterTemplatesByOrganization(many=True)
249276

250277

251-
class DeviceDetailSerializer(DeviceConfigMixin, BaseSerializer):
278+
class DeviceDetailSerializer(DeviceConfigSerializer):
252279
config = DeviceDetailConfigSerializer(allow_null=True)
253280
is_deactivated = serializers.BooleanField(read_only=True)
254281

@@ -281,7 +308,7 @@ def update(self, instance, validated_data):
281308
if config_data:
282309
self._update_config(instance, config_data)
283310

284-
elif hasattr(instance, 'config') and validated_data.get('organization'):
311+
elif instance._has_config() and validated_data.get('organization'):
285312
if instance.organization != validated_data.get('organization'):
286313
# config.device.organization is used for validating
287314
# 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
@@ -34,12 +34,12 @@ def get_api_urls(api_views):
3434
name='template_list',
3535
),
3636
path(
37-
'controller/template/<str:pk>/',
37+
'controller/template/<uuid:pk>/',
3838
api_views.template_detail,
3939
name='template_detail',
4040
),
4141
path(
42-
'controller/template/<str:pk>/configuration/',
42+
'controller/template/<uuid:pk>/configuration/',
4343
api_download_views.download_template_config,
4444
name='download_template_config',
4545
),
@@ -49,12 +49,12 @@ def get_api_urls(api_views):
4949
name='vpn_list',
5050
),
5151
path(
52-
'controller/vpn/<str:pk>/',
52+
'controller/vpn/<uuid:pk>/',
5353
api_views.vpn_detail,
5454
name='vpn_detail',
5555
),
5656
path(
57-
'controller/vpn/<str:pk>/configuration/',
57+
'controller/vpn/<uuid:pk>/configuration/',
5858
api_download_views.download_vpn_config,
5959
name='download_vpn_config',
6060
),
@@ -64,17 +64,17 @@ def get_api_urls(api_views):
6464
name='device_list',
6565
),
6666
path(
67-
'controller/device/<str:pk>/',
67+
'controller/device/<uuid:pk>/',
6868
api_views.device_detail,
6969
name='device_detail',
7070
),
7171
path(
72-
'controller/device/<str:pk>/activate/',
72+
'controller/device/<uuid:pk>/activate/',
7373
api_views.device_activate,
7474
name='device_activate',
7575
),
7676
path(
77-
'controller/device/<str:pk>/deactivate/',
77+
'controller/device/<uuid:pk>/deactivate/',
7878
api_views.device_deactivate,
7979
name='device_deactivate',
8080
),
@@ -84,7 +84,7 @@ def get_api_urls(api_views):
8484
name='devicegroup_list',
8585
),
8686
path(
87-
'controller/group/<str:pk>/',
87+
'controller/group/<uuid:pk>/',
8888
api_views.devicegroup_detail,
8989
name='devicegroup_detail',
9090
),
@@ -94,7 +94,7 @@ def get_api_urls(api_views):
9494
name='devicegroup_x509_commonname',
9595
),
9696
path(
97-
'controller/device/<str:pk>/configuration/',
97+
'controller/device/<uuid:pk>/configuration/',
9898
api_download_views.download_device_config,
9999
name='download_device_config',
100100
),

openwisp_controller/config/api/views.py

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

123+
def get_object(self):
124+
"""Set device property for serializer context."""
125+
obj = super().get_object()
126+
self.device = obj
127+
return obj
128+
129+
def get_serializer_context(self):
130+
"""Add device to serializer context for validation purposes."""
131+
context = super().get_serializer_context()
132+
context['device'] = self.device
133+
return context
134+
123135

124136
class DeviceActivateView(ProtectedAPIMixin, AutoRevisionMixin, GenericAPIView):
125137
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/exportable.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,10 @@ def validate_instance(
127127
if config.device_id != instance.id:
128128
config.device_id = instance.id
129129

130-
def after_save_instance(self, instance, using_transactions, dry_run):
131-
super().after_save_instance(instance, using_transactions, dry_run)
132-
if not dry_run:
133-
# save config afte device has been imported
130+
def after_save_instance(self, instance, row, **kwargs):
131+
super().after_save_instance(instance, row, **kwargs)
132+
if not self._is_dry_run(kwargs):
133+
# save config after device has been imported
134134
if instance._has_config():
135135
instance.config.save()
136136

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 (

openwisp_controller/config/tests/test_admin.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def test_device_import_export_buttons(self):
9090

9191
def test_device_export(self):
9292
response = self.client.post(
93-
reverse(f'admin:{self.app_label}_device_export'), {'file_format': '0'}
93+
reverse(f'admin:{self.app_label}_device_export'), {'format': '0'}
9494
)
9595
self.assertNotContains(response, 'error')
9696
self.assertIsNotNone(response.get('Content-Disposition'))
@@ -108,7 +108,7 @@ def test_device_import(self):
108108
csv = ContentFile(contents)
109109
response = self.client.post(
110110
reverse(f'admin:{self.app_label}_device_import'),
111-
{'input_format': '0', 'import_file': csv},
111+
{'format': '0', 'import_file': csv},
112112
)
113113
self.assertNotContains(response, 'errorlist')
114114
self.assertNotContains(response, 'Errors')
@@ -132,7 +132,7 @@ def test_device_import_empty_config(self):
132132
csv = ContentFile(contents)
133133
response = self.client.post(
134134
reverse(f'admin:{self.app_label}_device_import'),
135-
{'input_format': '0', 'import_file': csv, 'file_name': 'test.csv'},
135+
{'format': '0', 'import_file': csv, 'file_name': 'test.csv'},
136136
)
137137
self.assertFalse(response.context['result'].has_errors())
138138
self.assertIn('confirm_form', response.context)
@@ -173,7 +173,7 @@ def test_device_import_missing_config(self):
173173
csv = ContentFile(contents)
174174
response = self.client.post(
175175
reverse(f'admin:{self.app_label}_device_import'),
176-
{'input_format': '0', 'import_file': csv, 'file_name': 'test.csv'},
176+
{'format': '0', 'import_file': csv, 'file_name': 'test.csv'},
177177
)
178178
self.assertFalse(response.context['result'].has_errors())
179179
self.assertIn('confirm_form', response.context)
@@ -212,7 +212,7 @@ def test_device_import_config_not_templates(self):
212212
csv = ContentFile(contents)
213213
response = self.client.post(
214214
reverse(f'admin:{self.app_label}_device_import'),
215-
{'input_format': '0', 'import_file': csv, 'file_name': 'test.csv'},
215+
{'format': '0', 'import_file': csv, 'file_name': 'test.csv'},
216216
)
217217
self.assertFalse(response.context['result'].has_errors())
218218
self.assertIn('confirm_form', response.context)
@@ -636,7 +636,7 @@ def test_device_import_with_group_apply_templates(self):
636636
csv = ContentFile(contents)
637637
response = self.client.post(
638638
reverse(f'admin:{self.app_label}_device_import'),
639-
{'input_format': '0', 'import_file': csv, 'file_name': 'test.csv'},
639+
{'format': '0', 'import_file': csv, 'file_name': 'test.csv'},
640640
)
641641
self.assertFalse(response.context['result'].has_errors())
642642
self.assertIn('confirm_form', response.context)
@@ -682,7 +682,7 @@ def test_device_import_templates_and_config(self):
682682
csv = ContentFile(contents)
683683
response = self.client.post(
684684
reverse(f'admin:{self.app_label}_device_import'),
685-
{'input_format': '0', 'import_file': csv, 'file_name': 'test.csv'},
685+
{'format': '0', 'import_file': csv, 'file_name': 'test.csv'},
686686
)
687687
self.assertFalse(response.context['result'].has_errors())
688688
self.assertIn('confirm_form', response.context)

0 commit comments

Comments
 (0)