etianen / django-reversion

django-reversion is an extension to the Django web framework that provides version control for model instances.
https://django-reversion.readthedocs.io
BSD 3-Clause "New" or "Revised" License
3.05k stars 489 forks source link

how to show preview of previous versions in django views. exactly as it is done in admin panel #965

Closed amirj700 closed 8 months ago

amirj700 commented 8 months ago

I'm new to django reversion and there might be a simple solution to what I need, however I was not able to find appropriate solution through web search. In admin panel I can easily get a list of object versions and preview each history before doing any revert.

I want to repeat same procedure for my objects using django Views and forms (like UpdateView). for example I place a history button on my form html and by clicking on it receives a list of versions. by clicking on any of them, it shows preview of the selected version in a form of updateView with no reverting. (as same as admin panel).

Its good to mention that my forms are rendered using crispy and they contain some many to many fields and 2 inline formsets.

etianen commented 8 months ago

There's no exposed functionality for doing this outside of the admin. However, you can adapt the code for rendering the form with a revision here:

https://github.com/etianen/django-reversion/blob/master/reversion/admin.py#L164

The general approach taken is to:

  1. Load the revision
  2. Start a transaction
  3. Revert the revision
  4. Render the reverted model
  5. Roll back the transaction

This approach means that foreign keys and M2M relationships "just work", as step (3) saves everything back in the database as it was.

If there was demand, this could be made into a context manager for easy use in non-admin views.

josidridolfo commented 7 months ago

Boosting this to echo desire for this:

If there was demand, this could be made into a context manager for easy use in non-admin views.

amirj700 commented 7 months ago

Thanks to @etianen, This is my implementation but it would be great if this made easy by lib for non admin views: `def previousview(request, pk): if request.method == 'POST': return HttpResponseNotAllowed(['GET'], ('POST method is not allowed for this view.'))

# Retrieve the object to be updated
instance = get_object_or_404(BaseContract, pk=pk)
versions = Version.objects.get_for_object(instance).order_by('-id')[:2]
# Check the length of the versions queryset
if len(versions) == 1:
    version = versions[0]
    previous_version = None  # Set previous_version to None if there's only one version
elif len(versions) == 2:
    version, previous_version = versions
else:
    raise ValueError("Invalid number of versions returned")

if previous_version:
    # If the form is not submitted, display the form with existing data
    try:
        with transaction.atomic(using=previous_version.db):
            previous_version.revision.revert(delete=True)
            object: BaseContract = previous_version.object
            form = ProductForm(instance=object, user=request.user, menu_disabled=True)
            for key, field in form.fields.items():
                field.disabled = True
            rend = render(request, 'path_to_form/item_form.html', {
                'form': form,
            })
            raise _RollBackRevisionView(rend)
    except _RollBackRevisionView as ex:
        return ex.response # Render the update form template with the form
    return None
else:
    return HttpResponseNotAllowed(['GET'], _('no previous version'))`
josidridolfo commented 7 months ago

Thanks @etianen and @amirj700!

My issue is a little bit different from what's here, and I'm not sure that I have the experience or knowledge to refactor the above snippet for a Class-Based View that inherits a DetailView (which is what I'm currently using).

I have a number of objects that inherit a Stateful abstract class. When an object is modified by a user, its state goes from APPROVED to PENDING. Until the object is approved by a privileged user, the most recent version with an APPROVED state should be visible for all viewers on all pages except for the 'review/approve' page to which only privileged users have access.

So, I'm trying to retrieve the most recent version with the APPROVED state, get that specific object, and return that object to a Detail view. I think my confusion is in the except block and how to return the object to the CBV.

Here's what I have so far (much of this is taken from your snippet @amirj700, so thank you for that!)

First, the abstract model:

user = get_user_model()

class Stateful(models.Model):
    state = FSMField(
        default=NEW,
        choices=STATES,
        protected=True,
    )
    user = models.ForeignKey(
        user,
        on_delete=models.DO_NOTHING,
        related_name='%(class)s_user',
        null=True,
        blank=True,
    )

And, a class that's meant to return an object as the exception

class MostRecentApprovedObject(Exception):
    def __init__(self, object):
        self.object = object

Then, the related method in the abstract Stateful class:

    def get_most_recent_approved(self, under_review=False):
        """
        Returns the most recent version of an object (that inherits the Stateful class) with an APPROVED state,
        or the original object if no such record exists or if the object is under review by a privileged user.

        Params:
         under_review - bool - True iff this getter is used in an ObjectReview view (i.e., if a privileged user is on a page 
                intended to review and approve or reject changes, under_review should be True)
        """
        original = type(self).objects.get(pk=self.pk)
        if original.state == APPROVED or under_review:
            return original  # Current object is approved or new, return it directly.
        # Retrieve all version records for this instance
        versions = Version.objects.get_for_object(original) # get the VersionQuerySet
        print(f'The original object\'s state is: {original.state}')
        print(f'The original object\'s invited suppliers are: {original.suppliers_invited.all()}')
        for version in versions: # Iterate over VersionQuerySet until an APPROVED Version is found
            version_object = version._object_version.object
            if getattr(version_object, 'state', None) == APPROVED:
                print("Approved object found!")
                try:
                    print("TRying!")
                    with transaction.atomic(using=version.db):
                        print("in try/with block")
                        version.revision.revert(delete=True)
                        object: type(self) = version.object
                        raise MostRecentApprovedObject(object)
                except MostRecentApprovedObject as ex:
                    print(f'The returned object\'s state is: {ex.object.state}')
                    print(f'The returned object\'s invited suppliers are: {ex.object.suppliers_invited.all()}')
                    return ex.object
                else:
                    return HttpResponseNotAllowed(['GET'], _('No previous version'))
        return original

This is one implementation of it:

The first time in execution, when the object is approved:

web-1  | At start of get_object, the object's state is : APPROVED
web-1  | At start of get_object, the object's invited suppliers are : <QuerySet [<Supplier: Adkins, Rice and Ramirez>, <Supplier: Alexander-Ward>, <Supplier: Bailey, Cobb and Holt>]>
web-1  | At end of get_object, the object's state is : APPROVED
web-1  | At end of get_object, the object's invited suppliers are : <QuerySet [<Supplier: Adkins, Rice and Ramirez>, <Supplier: Alexander-Ward>, <Supplier: Bailey, Cobb and Holt>]>

Then, I change the object and remove all the invited suppliers. When I return to the page, I get the following:

web-1  | At start of get_object, the object's state is : PENDING
web-1  | At start of get_object, the object's invited suppliers are : <QuerySet []>
web-1  | The original object's state is: PENDING
web-1  | The original object's invited suppliers are: <QuerySet []>
web-1  | Approved object found!
web-1  | TRying!
web-1  | in try/with block
web-1  | The returned object's state is: APPROVED
web-1  | The returned object's invited suppliers are: <QuerySet []> # This should be the same QuerySet as above, with three suppliers....but it isn't.
web-1  | At end of get_object, the object's state is : APPROVED
web-1  | At end of get_object, the object's invited suppliers are : <QuerySet []>

I'm honestly at a loss here. Any guidance and assistance would be very much appreciated!

josidridolfo commented 7 months ago

Made some quick changes to try to trace what's happening. It looks like the object with all of its m2m fields is not being passed as expected to the exception handler:

...
                    with transaction.atomic(using=version.db):
                        version.revert()
                        # with reversion.create_revision():
                        object: type(self) = version.object
                        print(f'The found object is of type: {type(object)}')
                        print(f'The found object has the following state {object.state}')
                        print(f'The found object has the following suppliers_invited: {object.suppliers_invited.all()}')
                        print(f'Passing the above object to the Exception handler')
                        raise MostRecentApprovedObject(object)
                except MostRecentApprovedObject as ex:
                    print('Handling the exception! Returning the object')
                    print(f'The returned object\'s state is: {ex.object.state}')
                    print(f'The returned object\'s invited suppliers are: {ex.object.suppliers_invited.all()}')
                    return ex.object
...
web-1  | The found object is of type: <class 'bids.models.Bid'>
web-1  | The found object has the following state APPROVED
web-1  | The found object has the following suppliers_invited: <QuerySet [<Supplier: Adkins, Rice and Ramirez>, <Supplier: Alexander-Ward>, <Supplier: Bailey, Cobb and Holt>]> # GREAT! This is what we want to see!
web-1  | Passing the above object to the Exception handler
web-1  | Handling the exception! Returning the object
web-1  | The returned object's state is: APPROVED
web-1  | The returned object's invited suppliers are: <QuerySet []> # Boooooo - why didn't the Queryset from earlier get passed?

Any ideas on how to resolve this?

amirj700 commented 7 months ago

in cbv, m2m fields and inline forms are rendered after main form procedure by their managers, and when you revert as exception raise and get back to current state of db, their render will be with latest version thanks to their manager. thats why I changed from cbv to view function to get a complete version as final render.

josidridolfo commented 7 months ago

Thanks for your quick and helpful responses @amirj700!

So all of my CBVs should now be function-based views? Looks like I have a lot of refactoring ahead of me...

Assume the CBVs are just for DetailViews: given your experience, do you think that overriding the dispatch() or get() methods will enable me to keep CBVs and monkey-patch a solution? Or do you think refactoring and making CBVs are the way to go?

amirj700 commented 7 months ago

I did not changed all of my cbvs, I only changed the old version view request (one view), but it was good for my senario, for you it might differ. I tried overriding get and dispatch but because I had inlines forms it was way harder than I thought.

josidridolfo commented 7 months ago

I only changed the old version view request (one view)

This is a great idea!

So, instead of refactoring and changing my CBVs, create separate FBVs only for PENDING objects. CBVs should redirect users to the pending page iff the object is PENDING, and then handle the view separately.

I'm very new to all this and greatly appreciate your help and guidance!

josidridolfo commented 7 months ago

@etianen and @amirj700:

@etianen - thank you again for creating such an extremely useful tool!

@amirj700 - thank you so much for your guidance and advice! I've been struggling with this for a week and thanks in large part to your help I've made it work!

amirj700 commented 7 months ago

Great! you are welcome.