Skip to content

Commit dd5c232

Browse files
committed
[feature] Added AutoRevisionMixin
1 parent 675f162 commit dd5c232

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.
@@ -129,7 +133,7 @@ def get_serializer_context(self):
129133
return context
130134

131135

132-
class DeviceActivateView(ProtectedAPIMixin, GenericAPIView):
136+
class DeviceActivateView(ProtectedAPIMixin, AutoRevisionMixin, GenericAPIView):
133137
serializer_class = serializers.Serializer
134138
queryset = Device.objects.filter(_is_deactivated=True)
135139

@@ -142,7 +146,7 @@ def post(self, request, *args, **kwargs):
142146
return Response(serializer.data, status=status.HTTP_200_OK)
143147

144148

145-
class DeviceDeactivateView(ProtectedAPIMixin, GenericAPIView):
149+
class DeviceDeactivateView(ProtectedAPIMixin, AutoRevisionMixin, GenericAPIView):
146150
serializer_class = serializers.Serializer
147151
queryset = Device.objects.filter(_is_deactivated=False)
148152

@@ -155,15 +159,19 @@ def post(self, request, *args, **kwargs):
155159
return Response(serializer.data, status=status.HTTP_200_OK)
156160

157161

158-
class DeviceGroupListCreateView(ProtectedAPIMixin, ListCreateAPIView):
162+
class DeviceGroupListCreateView(
163+
ProtectedAPIMixin, AutoRevisionMixin, ListCreateAPIView
164+
):
159165
serializer_class = DeviceGroupSerializer
160166
queryset = DeviceGroup.objects.prefetch_related("templates").order_by("-created")
161167
pagination_class = ListViewPagination
162168
filter_backends = [DjangoFilterBackend]
163169
filterset_class = DeviceGroupListFilter
164170

165171

166-
class DeviceGroupDetailView(ProtectedAPIMixin, RetrieveUpdateDestroyAPIView):
172+
class DeviceGroupDetailView(
173+
ProtectedAPIMixin, AutoRevisionMixin, RetrieveUpdateDestroyAPIView
174+
):
167175
serializer_class = DeviceGroupSerializer
168176
queryset = DeviceGroup.objects.select_related("organization").order_by("-created")
169177

@@ -177,7 +185,7 @@ def get_cached_devicegroup_args_rewrite(cls, org_slugs, common_name):
177185
return url
178186

179187

180-
class DeviceGroupCommonName(ProtectedAPIMixin, RetrieveAPIView):
188+
class DeviceGroupCommonName(ProtectedAPIMixin, AutoRevisionMixin, RetrieveAPIView):
181189
serializer_class = DeviceGroupSerializer
182190
queryset = DeviceGroup.objects.select_related("organization").order_by("-created")
183191
# Not setting lookup_field makes DRF raise error. but it is not used
@@ -294,39 +302,55 @@ def certificate_delete_invalidates_cache(cls, organization_id, common_name):
294302
cls.get_device_group.invalidate(cls, org_slug, common_name)
295303

296304

297-
class ReversionListView(ProtectedAPIMixin, ListAPIView):
305+
class RevisionListView(ProtectedAPIMixin, AutoRevisionMixin, ListAPIView):
298306
serializer_class = ReversionSerializer
299-
queryset = Version.objects.select_related('revision').order_by(
300-
'-revision__date_created'
301-
)
302-
filter_backends = [DjangoFilterBackend]
303-
filterset_class = ReversionFilter
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+
)
304315

305316

306-
class ReversionDetailView(ProtectedAPIMixin, RetrieveAPIView):
317+
class RevisionDetailView(ProtectedAPIMixin, RetrieveAPIView):
307318
serializer_class = ReversionSerializer
308-
queryset = Version.objects.select_related('revision').order_by(
309-
'-revision__date_created'
310-
)
311-
lookup_field = 'pk'
312319

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+
)
313327

314-
class ReversionRestoreView(ProtectedAPIMixin, GenericAPIView):
328+
329+
class RevisionRestoreView(ProtectedAPIMixin, GenericAPIView):
315330
serializer_class = serializers.Serializer
316-
queryset = Version.objects.select_related('revision').order_by(
317-
'-revision__date_created'
318-
)
331+
332+
def get_queryset(self):
333+
model_slug = self.kwargs.get('model_slug').lower()
334+
return (
335+
Version.objects.select_related('revision')
336+
.filter(content_type__model=model_slug)
337+
.order_by('-revision__date_created')
338+
)
319339

320340
def post(self, request, *args, **kwargs):
321-
version = self.get_object()
341+
qs = self.get_queryset()
342+
versions = get_list_or_404(qs, revision_id=kwargs['pk'])
322343
with reversion.create_revision():
323-
version.revert()
344+
for version in versions:
345+
version.revert()
324346
reversion.set_user(request.user)
325347
reversion.set_comment(
326-
f"Restored to previous revision: {version.revision_id}"
348+
f"Restored to previous revision: {self.kwargs.get('pk')}"
327349
)
328350

329-
serializer = ReversionSerializer(version, context=self.get_serializer_context())
351+
serializer = ReversionSerializer(
352+
versions, many=True, context=self.get_serializer_context()
353+
)
330354
return Response(serializer.data, status=status.HTTP_200_OK)
331355

332356

@@ -341,6 +365,6 @@ def post(self, request, *args, **kwargs):
341365
devicegroup_list = DeviceGroupListCreateView.as_view()
342366
devicegroup_detail = DeviceGroupDetailView.as_view()
343367
devicegroup_commonname = DeviceGroupCommonName.as_view()
344-
reversion_list = ReversionListView.as_view()
345-
reversion_detail = ReversionDetailView.as_view()
346-
reversion_restore = ReversionRestoreView.as_view()
368+
revision_list = RevisionListView.as_view()
369+
revision_detail = RevisionDetailView.as_view()
370+
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
@@ -1636,41 +1636,39 @@ def test_device_patch_with_templates_of_same_org(self):
16361636
self.assertEqual(d1.config.templates.count(), 2)
16371637
self.assertEqual(r.data["config"]["templates"], [t1.id, t2.id])
16381638

1639-
def test_reversion_list_and_restore_api(self):
1639+
def test_revision_list_and_restore_api(self):
16401640
org = self._get_org()
1641+
model_slug = 'device'
16411642
with reversion.create_revision():
16421643
device = self._create_device(
1643-
organization=org, name="test", _is_deactivated=True
1644+
organization=org,
1645+
name="test",
16441646
)
1645-
path = reverse("config_api:device_detail", args=[device.pk])
1646-
response = self.client.delete(path)
1647-
self.assertEqual(response.status_code, 204)
1648-
self.assertEqual(Device.objects.count(), 0)
1647+
path = reverse('config_api:device_detail', args=[device.pk])
1648+
data = dict(name='change-test-device')
1649+
response = self.client.patch(path, data, content_type='application/json')
1650+
self.assertEqual(response.status_code, 200)
1651+
self.assertEqual(response.data['name'], 'change-test-device')
16491652

1650-
path = reverse("config_api:reversion_list")
1653+
path = reverse("config_api:revision_list", args=[model_slug])
16511654
response = self.client.get(path)
16521655
response_json = response.json()
1653-
version_id = response_json[0]["id"]
1656+
version_id = response_json[1]["id"]
16541657
self.assertEqual(response.status_code, 200)
1655-
self.assertEqual(len(response_json), 1)
1656-
1657-
with self.subTest("Test filter reversion list with model name"):
1658-
params = {"model": "Device"}
1659-
response = self.client.get(path, params)
1660-
self.assertEqual(response.status_code, 200)
1661-
self.assertEqual(len(response.json()), 1)
1662-
self.assertEqual(response.json()[0]["object_id"], str(device.pk))
1658+
self.assertEqual(len(response_json), 2)
16631659

1664-
with self.subTest("Test reversion detail"):
1665-
path = reverse("config_api:reversion_detail", args=[version_id])
1660+
with self.subTest("Test revision detail"):
1661+
path = reverse("config_api:revision_detail", args=[model_slug, version_id])
16661662
response = self.client.get(path)
16671663
self.assertEqual(response.status_code, 200)
16681664
self.assertEqual(response.json()["id"], version_id)
16691665
self.assertEqual(response.json()["object_id"], str(device.pk))
16701666

1671-
with self.subTest("Test reversion restore view"):
1672-
path = reverse("config_api:reversion_restore", args=[version_id])
1667+
with self.subTest("Test revision restore view"):
1668+
revision_id = response_json[1]["revision_id"]
1669+
path = reverse(
1670+
"config_api:revision_restore", args=[model_slug, revision_id]
1671+
)
16731672
response = self.client.post(path)
16741673
self.assertEqual(response.status_code, 200)
1675-
self.assertEqual(Device.objects.count(), 1)
1676-
self.assertEqual(Device.objects.first().id, device.pk)
1674+
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)