Skip to content

Commit b8ca950

Browse files
committed
[feature] Rivision for Rest Api changes openwisp#894
Implemented three endpoints: 1. List all revisions with optional filtering by model. 2. Inspect a specific revision by its ID. 3. Restore a revision using its ID. Fixes openwisp#894
1 parent 957c4a3 commit b8ca950

File tree

6 files changed

+171
-0
lines changed

6 files changed

+171
-0
lines changed

openwisp_controller/config/api/filters.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django_filters import rest_framework as filters
55
from django_filters.rest_framework import DjangoFilterBackend
66
from rest_framework.exceptions import ValidationError
7+
from reversion.models import Version
78
from swapper import load_model
89

910
from openwisp_users.api.filters import OrganizationManagedFilter
@@ -142,3 +143,18 @@ def __init__(self, *args, **kwargs):
142143

143144
class Meta(BaseConfigAPIFilter.Meta):
144145
model = DeviceGroup
146+
147+
148+
class ReversionFilter(BaseConfigAPIFilter):
149+
model = filters.CharFilter(field_name="content_type__model", lookup_expr="iexact")
150+
151+
def _set_valid_filterform_labels(self):
152+
self.filters["model"].label = _("Model")
153+
154+
def __init__(self, *args, **kwargs):
155+
super().__init__(*args, **kwargs)
156+
self._set_valid_filterform_labels()
157+
158+
class Meta:
159+
model = Version
160+
fields = ["model"]

openwisp_controller/config/api/serializers.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.db.models import Q
44
from django.utils.translation import gettext_lazy as _
55
from rest_framework import serializers
6+
from reversion.models import Version
67
from swapper import load_model
78

89
from openwisp_utils.api.serializers import ValidatedModelSerializer
@@ -376,3 +377,34 @@ def update(self, instance, validated_data):
376377
instance = super().update(instance, validated_data)
377378
self._save_m2m_templates(instance)
378379
return instance
380+
381+
382+
class ReversionSerializer(BaseSerializer):
383+
user_id = serializers.SerializerMethodField()
384+
date_created = serializers.DateTimeField(
385+
source="revision.date_created", read_only=True
386+
)
387+
comment = serializers.CharField(source="revision.comment", read_only=True)
388+
content_type = serializers.SerializerMethodField()
389+
390+
class Meta:
391+
model = Version
392+
fields = [
393+
"id",
394+
"revision_id",
395+
"object_id",
396+
"content_type",
397+
"db",
398+
"format",
399+
"serialized_data",
400+
"object_repr",
401+
"user_id",
402+
"date_created",
403+
"comment",
404+
]
405+
406+
def get_user_id(self, obj):
407+
return getattr(obj.revision, "user_id", None)
408+
409+
def get_content_type(self, obj):
410+
return getattr(obj.content_type, "model", None)

openwisp_controller/config/api/urls.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ def get_api_urls(api_views):
8383
api_download_views.download_device_config,
8484
name='download_device_config',
8585
),
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+
),
86101
]
87102
else:
88103
return []

openwisp_controller/config/api/views.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import reversion
12
from cache_memoize import cache_memoize
23
from django.core.exceptions import ObjectDoesNotExist
34
from django.db.models import F, Q
@@ -7,11 +8,13 @@
78
from rest_framework import pagination, serializers, status
89
from rest_framework.generics import (
910
GenericAPIView,
11+
ListAPIView,
1012
ListCreateAPIView,
1113
RetrieveAPIView,
1214
RetrieveUpdateDestroyAPIView,
1315
)
1416
from rest_framework.response import Response
17+
from reversion.models import Version
1518
from swapper import load_model
1619

1720
from openwisp_users.api.permissions import DjangoModelPermissions
@@ -21,13 +24,15 @@
2124
DeviceGroupListFilter,
2225
DeviceListFilter,
2326
DeviceListFilterBackend,
27+
ReversionFilter,
2428
TemplateListFilter,
2529
VPNListFilter,
2630
)
2731
from .serializers import (
2832
DeviceDetailSerializer,
2933
DeviceGroupSerializer,
3034
DeviceListSerializer,
35+
ReversionSerializer,
3136
TemplateSerializer,
3237
VpnSerializer,
3338
)
@@ -289,6 +294,42 @@ def certificate_delete_invalidates_cache(cls, organization_id, common_name):
289294
cls.get_device_group.invalidate(cls, org_slug, common_name)
290295

291296

297+
class ReversionListView(ProtectedAPIMixin, ListAPIView):
298+
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
304+
305+
306+
class ReversionDetailView(ProtectedAPIMixin, RetrieveAPIView):
307+
serializer_class = ReversionSerializer
308+
queryset = Version.objects.select_related('revision').order_by(
309+
'-revision__date_created'
310+
)
311+
lookup_field = 'pk'
312+
313+
314+
class ReversionRestoreView(ProtectedAPIMixin, GenericAPIView):
315+
serializer_class = serializers.Serializer
316+
queryset = Version.objects.select_related('revision').order_by(
317+
'-revision__date_created'
318+
)
319+
320+
def post(self, request, *args, **kwargs):
321+
version = self.get_object()
322+
with reversion.create_revision():
323+
version.revert()
324+
reversion.set_user(request.user)
325+
reversion.set_comment(
326+
f"Restored to previous revision: {version.revision_id}"
327+
)
328+
329+
serializer = ReversionSerializer(version, context=self.get_serializer_context())
330+
return Response(serializer.data, status=status.HTTP_200_OK)
331+
332+
292333
template_list = TemplateListCreateView.as_view()
293334
template_detail = TemplateDetailView.as_view()
294335
vpn_list = VpnListCreateView.as_view()
@@ -300,3 +341,6 @@ def certificate_delete_invalidates_cache(cls, organization_id, common_name):
300341
devicegroup_list = DeviceGroupListCreateView.as_view()
301342
devicegroup_detail = DeviceGroupDetailView.as_view()
302343
devicegroup_commonname = DeviceGroupCommonName.as_view()
344+
reversion_list = ReversionListView.as_view()
345+
reversion_detail = ReversionDetailView.as_view()
346+
reversion_restore = ReversionRestoreView.as_view()

openwisp_controller/config/tests/test_api.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import reversion
12
from django.contrib.auth.models import Permission
23
from django.test import TestCase
34
from django.test.client import BOUNDARY, MULTIPART_CONTENT, encode_multipart
@@ -1564,3 +1565,42 @@ def test_device_patch_with_templates_of_same_org(self):
15641565
self.assertEqual(r.status_code, 200)
15651566
self.assertEqual(d1.config.templates.count(), 2)
15661567
self.assertEqual(r.data['config']['templates'], [t1.id, t2.id])
1568+
1569+
def test_reversion_list_and_restore_api(self):
1570+
org = self._get_org()
1571+
with reversion.create_revision():
1572+
device = self._create_device(
1573+
organization=org, name="test", _is_deactivated=True
1574+
)
1575+
path = reverse("config_api:device_detail", args=[device.pk])
1576+
response = self.client.delete(path)
1577+
self.assertEqual(response.status_code, 204)
1578+
self.assertEqual(Device.objects.count(), 0)
1579+
1580+
path = reverse("config_api:reversion_list")
1581+
response = self.client.get(path)
1582+
response_json = response.json()
1583+
version_id = response_json[0]["id"]
1584+
self.assertEqual(response.status_code, 200)
1585+
self.assertEqual(len(response_json), 1)
1586+
1587+
with self.subTest("Test filter reversion list with model name"):
1588+
params = {"id": 1, "model": "Device"}
1589+
response = self.client.get(path, params)
1590+
self.assertEqual(response.status_code, 200)
1591+
self.assertEqual(len(response.json()), 1)
1592+
self.assertEqual(response.json()[0]["object_id"], str(device.pk))
1593+
1594+
with self.subTest("Test reversion detail"):
1595+
path = reverse("config_api:reversion_detail", args=[version_id])
1596+
response = self.client.get(path)
1597+
self.assertEqual(response.status_code, 200)
1598+
self.assertEqual(response.json()["id"], version_id)
1599+
self.assertEqual(response.json()["object_id"], str(device.pk))
1600+
1601+
with self.subTest("Test reversion restore view"):
1602+
path = reverse("config_api:reversion_restore", args=[version_id])
1603+
response = self.client.post(path)
1604+
self.assertEqual(response.status_code, 200)
1605+
self.assertEqual(Device.objects.count(), 1)
1606+
self.assertEqual(Device.objects.first().id, device.pk)

tests/openwisp2/sample_config/api/views.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,15 @@
2828
from openwisp_controller.config.api.views import (
2929
DeviceListCreateView as BaseDeviceListCreateView,
3030
)
31+
from openwisp_controller.config.api.views import (
32+
ReversionDetailView as BaseReversionDetailView,
33+
)
34+
from openwisp_controller.config.api.views import (
35+
ReversionListView as BaseReversionListView,
36+
)
37+
from openwisp_controller.config.api.views import (
38+
ReversionRestoreView as BaseReversionRestoreView,
39+
)
3140
from openwisp_controller.config.api.views import (
3241
TemplateDetailView as BaseTemplateDetailView,
3342
)
@@ -96,6 +105,18 @@ class DownloadDeviceView(BaseDownloadDeviceView):
96105
pass
97106

98107

108+
class ReversionListView(BaseReversionListView):
109+
pass
110+
111+
112+
class ReversionDetailView(BaseReversionDetailView):
113+
pass
114+
115+
116+
class ReversionRestoreView(BaseReversionRestoreView):
117+
pass
118+
119+
99120
template_list = TemplateListCreateView.as_view()
100121
template_detail = TemplateDetailView.as_view()
101122
download_template_config = DownloadTemplateconfiguration.as_view()
@@ -110,3 +131,6 @@ class DownloadDeviceView(BaseDownloadDeviceView):
110131
devicegroup_list = DeviceGroupListCreateView.as_view()
111132
devicegroup_detail = DeviceGroupDetailView.as_view()
112133
devicegroup_commonname = DeviceGroupCommonName.as_view()
134+
reversion_list = ReversionListView.as_view()
135+
reversion_detail = ReversionDetailView.as_view()
136+
reversion_restore = ReversionRestoreView.as_view()

0 commit comments

Comments
 (0)