Skip to content

Commit 024bd42

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 d84383c commit 024bd42

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
@@ -5,6 +5,7 @@
55
from django.db.models import Q
66
from django.utils.translation import gettext_lazy as _
77
from rest_framework import serializers
8+
from reversion.models import Version
89
from swapper import load_model
910

1011
from openwisp_users.api.mixins import FilterSerializerByOrgManaged
@@ -349,3 +350,34 @@ def update(self, instance, validated_data):
349350
instance = super().update(instance, validated_data)
350351
self._save_m2m_templates(instance)
351352
return instance
353+
354+
355+
class ReversionSerializer(BaseSerializer):
356+
user_id = serializers.SerializerMethodField()
357+
date_created = serializers.DateTimeField(
358+
source="revision.date_created", read_only=True
359+
)
360+
comment = serializers.CharField(source="revision.comment", read_only=True)
361+
content_type = serializers.SerializerMethodField()
362+
363+
class Meta:
364+
model = Version
365+
fields = [
366+
"id",
367+
"revision_id",
368+
"object_id",
369+
"content_type",
370+
"db",
371+
"format",
372+
"serialized_data",
373+
"object_repr",
374+
"user_id",
375+
"date_created",
376+
"comment",
377+
]
378+
379+
def get_user_id(self, obj):
380+
return getattr(obj.revision, "user_id", None)
381+
382+
def get_content_type(self, obj):
383+
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
)
@@ -277,6 +282,42 @@ def certificate_delete_invalidates_cache(cls, organization_id, common_name):
277282
cls.get_device_group.invalidate(cls, org_slug, common_name)
278283

279284

285+
class ReversionListView(ProtectedAPIMixin, ListAPIView):
286+
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
292+
293+
294+
class ReversionDetailView(ProtectedAPIMixin, RetrieveAPIView):
295+
serializer_class = ReversionSerializer
296+
queryset = Version.objects.select_related('revision').order_by(
297+
'-revision__date_created'
298+
)
299+
lookup_field = 'pk'
300+
301+
302+
class ReversionRestoreView(ProtectedAPIMixin, GenericAPIView):
303+
serializer_class = serializers.Serializer
304+
queryset = Version.objects.select_related('revision').order_by(
305+
'-revision__date_created'
306+
)
307+
308+
def post(self, request, *args, **kwargs):
309+
version = self.get_object()
310+
with reversion.create_revision():
311+
version.revert()
312+
reversion.set_user(request.user)
313+
reversion.set_comment(
314+
f"Restored to previous revision: {version.revision_id}"
315+
)
316+
317+
serializer = ReversionSerializer(version, context=self.get_serializer_context())
318+
return Response(serializer.data, status=status.HTTP_200_OK)
319+
320+
280321
template_list = TemplateListCreateView.as_view()
281322
template_detail = TemplateDetailView.as_view()
282323
vpn_list = VpnListCreateView.as_view()
@@ -288,3 +329,6 @@ def certificate_delete_invalidates_cache(cls, organization_id, common_name):
288329
devicegroup_list = DeviceGroupListCreateView.as_view()
289330
devicegroup_detail = DeviceGroupDetailView.as_view()
290331
devicegroup_commonname = DeviceGroupCommonName.as_view()
332+
reversion_list = ReversionListView.as_view()
333+
reversion_detail = ReversionDetailView.as_view()
334+
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
@@ -1518,3 +1519,42 @@ def test_device_patch_with_templates_of_same_org(self):
15181519
self.assertEqual(r.status_code, 200)
15191520
self.assertEqual(d1.config.templates.count(), 2)
15201521
self.assertEqual(r.data['config']['templates'], [t1.id, t2.id])
1522+
1523+
def test_reversion_list_and_restore_api(self):
1524+
org = self._get_org()
1525+
with reversion.create_revision():
1526+
device = self._create_device(
1527+
organization=org, name="test", _is_deactivated=True
1528+
)
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)
1533+
1534+
path = reverse("config_api:reversion_list")
1535+
response = self.client.get(path)
1536+
response_json = response.json()
1537+
version_id = response_json[0]["id"]
1538+
self.assertEqual(response.status_code, 200)
1539+
self.assertEqual(len(response_json), 1)
1540+
1541+
with self.subTest("Test filter reversion list with model name"):
1542+
params = {"id": 1, "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])
1550+
response = self.client.get(path)
1551+
self.assertEqual(response.status_code, 200)
1552+
self.assertEqual(response.json()["id"], version_id)
1553+
self.assertEqual(response.json()["object_id"], str(device.pk))
1554+
1555+
with self.subTest("Test reversion restore view"):
1556+
path = reverse("config_api:reversion_restore", args=[version_id])
1557+
response = self.client.post(path)
1558+
self.assertEqual(response.status_code, 200)
1559+
self.assertEqual(Device.objects.count(), 1)
1560+
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)