Skip to content

[feature] Add support to reversion to the REST API #994

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

dee077
Copy link
Contributor

@dee077 dee077 commented Mar 20, 2025

Checklist

  • I have read the OpenWISP Contributing Guidelines.
  • I have manually tested the changes proposed in this pull request.
  • I have written new test cases for new code and/or updated existing tests for changes to existing code.
  • I have updated the documentation.

Reference to Existing Issue

Closes #894.

Description of Changes

Introduces three new REST API endpoints for managing revisions:

  • List all revisions with optional filtering by model:
    GET /controller/reversion/?model=<model_name>

  • Inspect a specific revision using its ID:
    GET /controller/reversion/<revision_id>/

  • Restore a revision based on its ID:
    POST /controller/reversion/<revision_id>/restore/

Let me know if any changes are needed in the response data whether anything should be included, excluded, or modified. Also, please share any suggestions for adding more filters or adjusting the response format for the restore endpoint

@dee077 dee077 force-pushed the feature/894-rest-api-revisions branch from 8690016 to 3fdfcaf Compare March 21, 2025 08:34
@coveralls
Copy link

coveralls commented Mar 21, 2025

Coverage Status

coverage: 98.828% (+0.01%) from 98.816%
when pulling 3afb759 on dee077:feature/894-rest-api-revisions
into 85eee35 on openwisp:master.

@dee077 dee077 force-pushed the feature/894-rest-api-revisions branch from 3fdfcaf to 98ca619 Compare March 21, 2025 09:03
Copy link
Member

@nemesifier nemesifier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work! The code is clean and well-structured.

However, this PR seems to be missing its main goal: ensuring that every versioned model admin has a corresponding versioned REST API that logs revisions—tracking who changed what—each time a modification is made.

Please check my comments below for further details.

@dee077 dee077 changed the title [feature] Rivision for Rest Api changes [feature] Revisions for Rest Api changes Mar 25, 2025
@nemesifier nemesifier changed the title [feature] Revisions for Rest Api changes [feature] Add support to reversion to the REST API Mar 27, 2025
@dee077 dee077 force-pushed the feature/894-rest-api-revisions branch from 84a68d6 to df5c3fa Compare April 12, 2025 13:56
@dee077
Copy link
Contributor Author

dee077 commented Apr 12, 2025

I’ve added REST API revision support using django-reversion. Here’s a summary

What’s Added

  • AutoRevisionMixin uses RevisionMixin suggested by @pandafy which wraps API requests in reversion.create_revision().
  • Automatically sets the user and a comment for each revision.

New Endpoints:

  • controller/<str:model_slug>/revision/ – List revisions
  • controller/<str:model_slug>/revision/<str:pk>/ – Revision detail
  • controller/<str:model_slug>/revision/<str:pk>/restore/ – Restore a revision
    These endpoints are placed above similar paths like controller/device/<str:pk>/ to avoid conflicts, as pk will never be revision.

How It Works:

  • Each change creates an entry in the reversion_revision table.
  • The corresponding object snapshot is stored in reversion_version with the same revision_id.

Issues Noticed

  • When using AutoRevisionMixin in the DeviceDetailView, the devicegroup_change_handler behaves unexpectedly, so the template doesn’t update on group change.
  • Fails the test: test_device_api_change_group.
  • Tried the mixin in other modules (connection, geo, pki), observed complexity increase (some entries show +10 additional SQL queries).

Question

Given the unexpected behavior in signals and added complexity:
Is this approach okay to proceed with, or alternative design for API revision support is needed?

Copy link
Member

@nemesifier nemesifier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks on the right track.

I will do a manual round of testing soon, in the meantime see my comments below.

We'll need to add this to pki, geo and connection.

'controller/<str:model_slug>/revision/<str:pk>/restore/',
api_views.revision_restore,
name='revision_restore',
),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we shorten this to just model? I think it would be clear enough and more concise. It's a small thing but nonetheless over time these simplifications help to keep maintenance sane.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have another doubt on this, how are we enforcing that we are enabling this only on the models that support reversions in this app? Eg: Device, Template, Vpn, etc. Would this work for any model?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, empty revisions are being stored with only user and comment entries, which does not offer anything.

Copy link
Contributor Author

@dee077 dee077 May 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a fix for this
For reference these are the models that are getting revisions

Screenshot from 2025-05-22 02-40-35

@pandafy pandafy moved this from To do (general) to In progress in OpenWISP Contributor's Board May 6, 2025
@dee077
Copy link
Contributor Author

dee077 commented May 16, 2025

Updates

  • Fix single quotes
  • Added the AutoRevisionMixin across the module. This caused 2 big issues
  • The test was failing due to AutoRevisionMixin wrapping the view in a transaction.atomic() block, which delayed the post_save signal (registered via transaction.on_commit) until the outermost transaction committed. Since the outer transaction wasn’t triggered when PUT request generating the response,that returned outdated data while the database was updated afterward. Resolved by setting revision_atomic = False in the mixin to avoid unnecessary nesting.
  • Some GeoTestsApi were inconsistently counting queries due to Django Reversion caching the ContentType lookup (used for versioning). On the first run, the lookup incurred extra queries, on subsequent runs, caching reduced them, leading to assertion failures. Fixed by adding ContentType.objects.clear_cache() in the setUp method of the test case to ensure consistent query counts.

Copy link
Member

@nemesifier nemesifier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you point to the lines of code that are generating the additional queries?

Have you found out what additional queries are being generated that before weren't?

If we can see the query it would be faster to verify whether all the queries are needed, the increase seems pretty significant.

@dee077 dee077 force-pushed the feature/894-rest-api-revisions branch from 34dbfcf to 47e1c70 Compare May 17, 2025 08:29
Copy link
Member

@pandafy pandafy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you point to the lines of code that are generating the additional queries?
Have you found out what additional queries are being generated that before weren't?

Here's a diff of the queries generated by the test_patch_deviceconnectoin_detail,

https://www.diffchecker.com/ToUeQeyZ/

From my analysis, it appears that django-rivision creates a version for all related objects (here - DeviceConnection, Config and Device). This is inline with VersionAdmin behaviour.

@dee077
Copy link
Contributor Author

dee077 commented May 21, 2025

Updates

  • Again added test with increased queries.
  • The queryset uses Version objects because there is no reverse relationship from Revision to Version, but each Version is linked to a Revision.
  • Rename RevisionSerializer to VersionSerializer
  • In VersionSerializer directly defined the fields instead of using SerializerMethodField
  • Added a filter by revision_id in RevisionListView to allow retrieving versions linked to the same revision, which helps group related changes made at the same time.
  • Added test for revision_id filter
  • Small qa fix for double quotes.

@dee077
Copy link
Contributor Author

dee077 commented May 21, 2025

Updates

  • Removed RevisionFilter class as no longer using model filter by params
  • Added additional check for registered model

With this approach, only registered models will store revisions, while unregistered models can still include the mixin without any side effects. This allows in the future to safely add the mixin to new POST, PUT, or PATCH requests without needing to worry about whether the model is registered with reversion.

@dee077 dee077 force-pushed the feature/894-rest-api-revisions branch 2 times, most recently from 36b017e to 92b7954 Compare May 22, 2025 08:26
Copy link
Member

@nemesifier nemesifier left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dee077 can you please run openwisp-qa-format here again with the latest version of openwisp-utils? See openwisp/openwisp-utils#456.

dee077 added 6 commits June 4, 2025 14:11
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
@dee077 dee077 force-pushed the feature/894-rest-api-revisions branch from 92b7954 to fd1faab Compare June 4, 2025 09:13
@dee077 dee077 force-pushed the feature/894-rest-api-revisions branch from fd1faab to 3afb759 Compare June 4, 2025 09:49
Copy link
Member

@pandafy pandafy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did a round of manual testing with the Templates model. Changing the model fields from the API endpoints creates revisions as expected. Kudos!


Is it possible to list all revisions of a particular object? Similar to this view
https://demo.openwisp.io/admin/config/template/e450012e-a482-48ad-9b2b-f0faa93b9cac/history/

Screenshot from 2025-06-10 18-42-38

If not, it really hurts the functionality of the REST API endpoints.

This end point is missing from the issue description. Let's discuss it with @nemesifier first before proceeding on this front.

Comment on lines +312 to +318
def get_queryset(self):
model = self.kwargs.get("model").lower()
queryset = self.queryset.filter(content_type__model=model)
revision_id = self.request.query_params.get("revision_id")
if revision_id:
queryset = queryset.filter(revision_id=revision_id)
return self.queryset.filter(content_type__model=model)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way this method is written breaks the convention of Django/DRF. It is also inconsistent with OpenWISP.

Suggested change
def get_queryset(self):
model = self.kwargs.get("model").lower()
queryset = self.queryset.filter(content_type__model=model)
revision_id = self.request.query_params.get("revision_id")
if revision_id:
queryset = queryset.filter(revision_id=revision_id)
return self.queryset.filter(content_type__model=model)
def get_queryset(self):
queryset = super().get_queryset()
model = self.kwargs.get("model", "").lower()
queryset = queryset.filter(content_type__model=model)
if revision_id := self.request.query_params.get("revision_id"):
queryset = queryset.filter(revision_id=revision_id)
return queryset

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, it is not filtering the queryset with the revision_id, since the return statement filters the queryset with just content_type__model.

And, there's no test which verifies the filtering. This is bad.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filtering using revision_id should be done with django_filters instead.

https://django-filter.readthedocs.io/en/stable/guide/rest_framework.html#quickstart

You can check out other API views for details.

Comment on lines +327 to +329
def get_queryset(self):
model = self.kwargs.get("model").lower()
return self.queryset.filter(content_type__model=model)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def get_queryset(self):
model = self.kwargs.get("model").lower()
return self.queryset.filter(content_type__model=model)
def get_queryset(self):
model = self.kwargs.get("model").lower()
return super().get_querset().filter(content_type__model=model)

Maybe, we can create a Mixin class which filters the queryset like this.

Comment on lines +338 to +340
def get_queryset(self):
model = self.kwargs.get("model").lower()
return self.queryset.filter(content_type__model=model)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here!

Comment on lines +345 to +352
with transaction.atomic():
with reversion.create_revision():
for version in versions:
version.revert()
reversion.set_user(request.user)
reversion.set_comment(
f"Restored to previous revision: {self.kwargs.get('pk')}"
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we use Revision.revert() instead of reverting individual versions?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: In progress
Development

Successfully merging this pull request may close these issues.

[feature] REST API should store revisions with django-reversion
4 participants