Skip to content

Commit df5c3fa

Browse files
committed
[feature] Added AutoRevisionMixin
1 parent f4378b9 commit df5c3fa

File tree

5 files changed

+117
-81
lines changed

5 files changed

+117
-81
lines changed

openwisp_controller/config/api/urls.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,21 @@ def get_api_urls(api_views):
1313
"""
1414
if getattr(settings, 'OPENWISP_CONTROLLER_API', True):
1515
return [
16+
path(
17+
'controller/<str:model_slug>/revision/',
18+
api_views.revision_list,
19+
name='revision_list',
20+
),
21+
path(
22+
'controller/<str:model_slug>/revision/<str:pk>/',
23+
api_views.revision_detail,
24+
name='revision_detail',
25+
),
26+
path(
27+
'controller/<str:model_slug>/revision/<str:pk>/restore/',
28+
api_views.revision_restore,
29+
name='revision_restore',
30+
),
1631
path(
1732
'controller/template/',
1833
api_views.template_list,
@@ -83,21 +98,6 @@ def get_api_urls(api_views):
8398
api_download_views.download_device_config,
8499
name='download_device_config',
85100
),
86-
path(
87-
'controller/reversion/',
88-
api_views.reversion_list,
89-
name='reversion_list',
90-
),
91-
path(
92-
'controller/reversion/<str:pk>/',
93-
api_views.reversion_detail,
94-
name='reversion_detail',
95-
),
96-
path(
97-
'controller/reversion/<str:pk>/restore/',
98-
api_views.reversion_restore,
99-
name='reversion_restore',
100-
),
101101
]
102102
else:
103103
return []

openwisp_controller/config/api/views.py

Lines changed: 59 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.core.exceptions import ObjectDoesNotExist
44
from django.db.models import F, Q
55
from django.http import Http404
6+
from django.shortcuts import get_list_or_404
67
from django.urls.base import reverse
78
from django_filters.rest_framework import DjangoFilterBackend
89
from rest_framework import pagination, serializers, status
@@ -19,12 +20,11 @@
1920

2021
from openwisp_users.api.permissions import DjangoModelPermissions
2122

22-
from ...mixins import ProtectedAPIMixin
23+
from ...mixins import AutoRevisionMixin, ProtectedAPIMixin
2324
from .filters import (
2425
DeviceGroupListFilter,
2526
DeviceListFilter,
2627
DeviceListFilterBackend,
27-
ReversionFilter,
2828
TemplateListFilter,
2929
VPNListFilter,
3030
)
@@ -53,28 +53,30 @@ class ListViewPagination(pagination.PageNumberPagination):
5353
max_page_size = 100
5454

5555

56-
class TemplateListCreateView(ProtectedAPIMixin, ListCreateAPIView):
56+
class TemplateListCreateView(ProtectedAPIMixin, AutoRevisionMixin, ListCreateAPIView):
5757
serializer_class = TemplateSerializer
5858
queryset = Template.objects.prefetch_related('tags').order_by('-created')
5959
pagination_class = ListViewPagination
6060
filter_backends = [DjangoFilterBackend]
6161
filterset_class = TemplateListFilter
6262

6363

64-
class TemplateDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
64+
class TemplateDetailView(
65+
ProtectedAPIMixin, AutoRevisionMixin, RetrieveUpdateDestroyAPIView
66+
):
6567
serializer_class = TemplateSerializer
6668
queryset = Template.objects.all()
6769

6870

69-
class VpnListCreateView(ProtectedAPIMixin, ListCreateAPIView):
71+
class VpnListCreateView(ProtectedAPIMixin, AutoRevisionMixin, ListCreateAPIView):
7072
serializer_class = VpnSerializer
7173
queryset = Vpn.objects.select_related('subnet').order_by('-created')
7274
pagination_class = ListViewPagination
7375
filter_backends = [DjangoFilterBackend]
7476
filterset_class = VPNListFilter
7577

7678

77-
class VpnDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
79+
class VpnDetailView(ProtectedAPIMixin, AutoRevisionMixin, RetrieveUpdateDestroyAPIView):
7880
serializer_class = VpnSerializer
7981
queryset = Vpn.objects.all()
8082

@@ -87,7 +89,7 @@ def has_object_permission(self, request, view, obj):
8789
return perm and not obj.is_deactivated()
8890

8991

90-
class DeviceListCreateView(ProtectedAPIMixin, ListCreateAPIView):
92+
class DeviceListCreateView(ProtectedAPIMixin, AutoRevisionMixin, ListCreateAPIView):
9193
"""
9294
Templates: Templates flagged as required will be added automatically
9395
to the `config` of a device and cannot be unassigned.
@@ -102,7 +104,9 @@ class DeviceListCreateView(ProtectedAPIMixin, ListCreateAPIView):
102104
filterset_class = DeviceListFilter
103105

104106

105-
class DeviceDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
107+
class DeviceDetailView(
108+
ProtectedAPIMixin, AutoRevisionMixin, RetrieveUpdateDestroyAPIView
109+
):
106110
"""
107111
Templates: Templates flagged as _required_ will be added automatically
108112
to the `config` of a device and cannot be unassigned.
@@ -117,7 +121,7 @@ def perform_destroy(self, instance):
117121
instance.delete(check_deactivated=(not force_deletion))
118122

119123

120-
class DeviceActivateView(ProtectedAPIMixin, GenericAPIView):
124+
class DeviceActivateView(ProtectedAPIMixin, AutoRevisionMixin, GenericAPIView):
121125
serializer_class = serializers.Serializer
122126
queryset = Device.objects.filter(_is_deactivated=True)
123127

@@ -130,7 +134,7 @@ def post(self, request, *args, **kwargs):
130134
return Response(serializer.data, status=status.HTTP_200_OK)
131135

132136

133-
class DeviceDeactivateView(ProtectedAPIMixin, GenericAPIView):
137+
class DeviceDeactivateView(ProtectedAPIMixin, AutoRevisionMixin, GenericAPIView):
134138
serializer_class = serializers.Serializer
135139
queryset = Device.objects.filter(_is_deactivated=False)
136140

@@ -143,15 +147,19 @@ def post(self, request, *args, **kwargs):
143147
return Response(serializer.data, status=status.HTTP_200_OK)
144148

145149

146-
class DeviceGroupListCreateView(ProtectedAPIMixin, ListCreateAPIView):
150+
class DeviceGroupListCreateView(
151+
ProtectedAPIMixin, AutoRevisionMixin, ListCreateAPIView
152+
):
147153
serializer_class = DeviceGroupSerializer
148154
queryset = DeviceGroup.objects.prefetch_related('templates').order_by('-created')
149155
pagination_class = ListViewPagination
150156
filter_backends = [DjangoFilterBackend]
151157
filterset_class = DeviceGroupListFilter
152158

153159

154-
class DeviceGroupDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
160+
class DeviceGroupDetailView(
161+
ProtectedAPIMixin, AutoRevisionMixin, RetrieveUpdateDestroyAPIView
162+
):
155163
serializer_class = DeviceGroupSerializer
156164
queryset = DeviceGroup.objects.select_related('organization').order_by('-created')
157165

@@ -165,7 +173,7 @@ def get_cached_devicegroup_args_rewrite(cls, org_slugs, common_name):
165173
return url
166174

167175

168-
class DeviceGroupCommonName(ProtectedAPIMixin, RetrieveAPIView):
176+
class DeviceGroupCommonName(ProtectedAPIMixin, AutoRevisionMixin, RetrieveAPIView):
169177
serializer_class = DeviceGroupSerializer
170178
queryset = DeviceGroup.objects.select_related('organization').order_by('-created')
171179
# Not setting lookup_field makes DRF raise error. but it is not used
@@ -282,39 +290,55 @@ def certificate_delete_invalidates_cache(cls, organization_id, common_name):
282290
cls.get_device_group.invalidate(cls, org_slug, common_name)
283291

284292

285-
class ReversionListView(ProtectedAPIMixin, ListAPIView):
293+
class RevisionListView(ProtectedAPIMixin, AutoRevisionMixin, ListAPIView):
286294
serializer_class = ReversionSerializer
287-
queryset = Version.objects.select_related('revision').order_by(
288-
'-revision__date_created'
289-
)
290-
filter_backends = [DjangoFilterBackend]
291-
filterset_class = ReversionFilter
295+
296+
def get_queryset(self):
297+
model_slug = self.kwargs.get('model_slug').lower()
298+
return (
299+
Version.objects.select_related('revision')
300+
.filter(content_type__model=model_slug)
301+
.order_by('-revision__date_created')
302+
)
292303

293304

294-
class ReversionDetailView(ProtectedAPIMixin, RetrieveAPIView):
305+
class RevisionDetailView(ProtectedAPIMixin, RetrieveAPIView):
295306
serializer_class = ReversionSerializer
296-
queryset = Version.objects.select_related('revision').order_by(
297-
'-revision__date_created'
298-
)
299-
lookup_field = 'pk'
307+
308+
def get_queryset(self):
309+
model_slug = self.kwargs.get('model_slug').lower()
310+
return (
311+
Version.objects.select_related('revision')
312+
.filter(content_type__model=model_slug)
313+
.order_by('-revision__date_created')
314+
)
300315

301316

302-
class ReversionRestoreView(ProtectedAPIMixin, GenericAPIView):
317+
class RevisionRestoreView(ProtectedAPIMixin, GenericAPIView):
303318
serializer_class = serializers.Serializer
304-
queryset = Version.objects.select_related('revision').order_by(
305-
'-revision__date_created'
306-
)
319+
320+
def get_queryset(self):
321+
model_slug = self.kwargs.get('model_slug').lower()
322+
return (
323+
Version.objects.select_related('revision')
324+
.filter(content_type__model=model_slug)
325+
.order_by('-revision__date_created')
326+
)
307327

308328
def post(self, request, *args, **kwargs):
309-
version = self.get_object()
329+
qs = self.get_queryset()
330+
versions = get_list_or_404(qs, revision_id=kwargs['pk'])
310331
with reversion.create_revision():
311-
version.revert()
332+
for version in versions:
333+
version.revert()
312334
reversion.set_user(request.user)
313335
reversion.set_comment(
314-
f"Restored to previous revision: {version.revision_id}"
336+
f"Restored to previous revision: {self.kwargs.get('pk')}"
315337
)
316338

317-
serializer = ReversionSerializer(version, context=self.get_serializer_context())
339+
serializer = ReversionSerializer(
340+
versions, many=True, context=self.get_serializer_context()
341+
)
318342
return Response(serializer.data, status=status.HTTP_200_OK)
319343

320344

@@ -329,6 +353,6 @@ def post(self, request, *args, **kwargs):
329353
devicegroup_list = DeviceGroupListCreateView.as_view()
330354
devicegroup_detail = DeviceGroupDetailView.as_view()
331355
devicegroup_commonname = DeviceGroupCommonName.as_view()
332-
reversion_list = ReversionListView.as_view()
333-
reversion_detail = ReversionDetailView.as_view()
334-
reversion_restore = ReversionRestoreView.as_view()
356+
revision_list = RevisionListView.as_view()
357+
revision_detail = RevisionDetailView.as_view()
358+
revision_restore = RevisionRestoreView.as_view()

openwisp_controller/config/tests/test_api.py

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1520,41 +1520,39 @@ def test_device_patch_with_templates_of_same_org(self):
15201520
self.assertEqual(d1.config.templates.count(), 2)
15211521
self.assertEqual(r.data['config']['templates'], [t1.id, t2.id])
15221522

1523-
def test_reversion_list_and_restore_api(self):
1523+
def test_revision_list_and_restore_api(self):
15241524
org = self._get_org()
1525+
model_slug = 'device'
15251526
with reversion.create_revision():
15261527
device = self._create_device(
1527-
organization=org, name="test", _is_deactivated=True
1528+
organization=org,
1529+
name="test",
15281530
)
1529-
path = reverse("config_api:device_detail", args=[device.pk])
1530-
response = self.client.delete(path)
1531-
self.assertEqual(response.status_code, 204)
1532-
self.assertEqual(Device.objects.count(), 0)
1531+
path = reverse('config_api:device_detail', args=[device.pk])
1532+
data = dict(name='change-test-device')
1533+
response = self.client.patch(path, data, content_type='application/json')
1534+
self.assertEqual(response.status_code, 200)
1535+
self.assertEqual(response.data['name'], 'change-test-device')
15331536

1534-
path = reverse("config_api:reversion_list")
1537+
path = reverse("config_api:revision_list", args=[model_slug])
15351538
response = self.client.get(path)
15361539
response_json = response.json()
1537-
version_id = response_json[0]["id"]
1540+
version_id = response_json[1]["id"]
15381541
self.assertEqual(response.status_code, 200)
1539-
self.assertEqual(len(response_json), 1)
1542+
self.assertEqual(len(response_json), 2)
15401543

1541-
with self.subTest("Test filter reversion list with model name"):
1542-
params = {"model": "Device"}
1543-
response = self.client.get(path, params)
1544-
self.assertEqual(response.status_code, 200)
1545-
self.assertEqual(len(response.json()), 1)
1546-
self.assertEqual(response.json()[0]["object_id"], str(device.pk))
1547-
1548-
with self.subTest("Test reversion detail"):
1549-
path = reverse("config_api:reversion_detail", args=[version_id])
1544+
with self.subTest("Test revision detail"):
1545+
path = reverse("config_api:revision_detail", args=[model_slug, version_id])
15501546
response = self.client.get(path)
15511547
self.assertEqual(response.status_code, 200)
15521548
self.assertEqual(response.json()["id"], version_id)
15531549
self.assertEqual(response.json()["object_id"], str(device.pk))
15541550

1555-
with self.subTest("Test reversion restore view"):
1556-
path = reverse("config_api:reversion_restore", args=[version_id])
1551+
with self.subTest("Test revision restore view"):
1552+
revision_id = response_json[1]["revision_id"]
1553+
path = reverse(
1554+
"config_api:revision_restore", args=[model_slug, revision_id]
1555+
)
15571556
response = self.client.post(path)
15581557
self.assertEqual(response.status_code, 200)
1559-
self.assertEqual(Device.objects.count(), 1)
1560-
self.assertEqual(Device.objects.first().id, device.pk)
1558+
self.assertEqual(Device.objects.get(name="test").pk, device.pk)

openwisp_controller/mixins.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import reversion
2+
from reversion.views import RevisionMixin
3+
14
from openwisp_users.api.mixins import FilterByOrganizationManaged
25
from openwisp_users.api.mixins import ProtectedAPIMixin as BaseProtectedAPIMixin
36
from openwisp_users.api.permissions import DjangoModelPermissions, IsOrganizationManager
@@ -35,3 +38,14 @@ class RelatedDeviceProtectedAPIMixin(
3538

3639
class ProtectedAPIMixin(BaseProtectedAPIMixin, FilterByOrganizationManaged):
3740
pass
41+
42+
43+
class AutoRevisionMixin(RevisionMixin):
44+
def dispatch(self, request, *args, **kwargs):
45+
if request.method in ('GET', 'HEAD', 'OPTIONS'):
46+
return super().dispatch(request, *args, **kwargs)
47+
with reversion.create_revision():
48+
response = super().dispatch(request, *args, **kwargs)
49+
reversion.set_user(request.user)
50+
reversion.set_comment(f'API request: {request.method} {request.path}')
51+
return response

tests/openwisp2/sample_config/api/views.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,13 @@
2929
DeviceListCreateView as BaseDeviceListCreateView,
3030
)
3131
from openwisp_controller.config.api.views import (
32-
ReversionDetailView as BaseReversionDetailView,
32+
RevisionDetailView as BaseRevisionDetailView,
3333
)
3434
from openwisp_controller.config.api.views import (
35-
ReversionListView as BaseReversionListView,
35+
RevisionListView as BaseRevisionListView,
3636
)
3737
from openwisp_controller.config.api.views import (
38-
ReversionRestoreView as BaseReversionRestoreView,
38+
RevisionRestoreView as BaseRevisionRestoreView,
3939
)
4040
from openwisp_controller.config.api.views import (
4141
TemplateDetailView as BaseTemplateDetailView,
@@ -105,15 +105,15 @@ class DownloadDeviceView(BaseDownloadDeviceView):
105105
pass
106106

107107

108-
class ReversionListView(BaseReversionListView):
108+
class RevisionListView(BaseRevisionListView):
109109
pass
110110

111111

112-
class ReversionDetailView(BaseReversionDetailView):
112+
class RevisionDetailView(BaseRevisionDetailView):
113113
pass
114114

115115

116-
class ReversionRestoreView(BaseReversionRestoreView):
116+
class RevisionRestoreView(BaseRevisionRestoreView):
117117
pass
118118

119119

@@ -131,6 +131,6 @@ class ReversionRestoreView(BaseReversionRestoreView):
131131
devicegroup_list = DeviceGroupListCreateView.as_view()
132132
devicegroup_detail = DeviceGroupDetailView.as_view()
133133
devicegroup_commonname = DeviceGroupCommonName.as_view()
134-
reversion_list = ReversionListView.as_view()
135-
reversion_detail = ReversionDetailView.as_view()
136-
reversion_restore = ReversionRestoreView.as_view()
134+
revision_list = RevisionListView.as_view()
135+
revision_detail = RevisionDetailView.as_view()
136+
revision_restore = RevisionRestoreView.as_view()

0 commit comments

Comments
 (0)