Skip to content
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

m2m: Admin doesn't show m2m of historic object #1063

Open
duebbert opened this issue Nov 25, 2022 · 5 comments
Open

m2m: Admin doesn't show m2m of historic object #1063

duebbert opened this issue Nov 25, 2022 · 5 comments
Assignees
Labels
bug Issues related to confirmed bugs

Comments

@duebbert
Copy link

duebbert commented Nov 25, 2022

Describe the bug

Sidenote: thank you so much for the m2m field tracking! Stopped me migrating a large project to django-pghistory.

Unfortunately it doesn't work correctly in the admin integration. When I view a historic item, it will always show the current selection on an m2m field instead of the selection at the specific time.

The same is happening when using as_of() and I suspect the issues are related.

To Reproduce

Steps to reproduce the behavior:

  1. Create a model with an m2m field
  2. In the admin, create an object and select one item on the m2m field and save.
  3. Then edit again and unselect that item and select another one and save.
  4. When going into the history, it will always show the latest selection even for older version.

Code to reproduce:

# models.py
from django.db import models
from simple_history.models import HistoricalRecords


class Tag(models.Model):
    name = models.TextField()

    def __str__(self):
        return self.name


class Testing(models.Model):
    tags = models.ManyToManyField(Tag)
    history = HistoricalRecords(m2m_fields=[tags])
# admin.py
from django.contrib import admin
from simple_history.admin import SimpleHistoryAdmin

from .models import Tag, Testing

@admin.register(Testing)
class TestingAdmin(SimpleHistoryAdmin):
    pass


@admin.register(Tag)
class TestingTagAdmin(SimpleHistoryAdmin):
    pass

Expected behavior

It should show the selection as it was at the time.

Environment

  • Django Simple History Version: 3.2.0 and latest commit 6d09c3e
  • Django Version: 4.1.3
@ddabble ddabble self-assigned this Dec 30, 2022
ddabble added a commit to MAKENTNU/web that referenced this issue Mar 4, 2023
...that exist on models that already have history tracking.

Note that the many-to-many selection for these fields *are* correctly saved, they're just currently not correctly displayed in the objects' history
page in Django admin (the *current* M2M selection is always shown); see jazzband/django-simple-history#1063.
@ddabble ddabble added the bug Issues related to confirmed bugs label Sep 15, 2023
@ademirqueiroga
Copy link

This is still happening on 3.5.0. After changing a m2m relationship via the admin, all objects from the history displays the current m2m state

@ddabble
Copy link
Member

ddabble commented Mar 18, 2024

Yeah, it seems fixing it requires a lot more changes than initially expected, and so it's been postponed. However, #1128 will alleviate it somewhat, by listing the (correct) M2M relations in the change history table 🙂

@ademirqueiroga
Copy link

No worries! Thanks for the heads up and for the great work!

@ddabble ddabble mentioned this issue Jun 21, 2024
11 tasks
@trumpet2012
Copy link
Contributor

I was able to get this working by creating a new M2M field that adds some additional logic to the related manager that gets created to filter down the results based on whether the instance is from historical data. This is being used to support showing the historical m2m relations when viewing history instances in the admin.

from django.db.models import ManyToManyField
from django.db.models.fields import checks
from django.db.models.fields.related import ManyToManyDescriptor
from django.utils.functional import cached_property
from simple_history.models import to_historic


class HistoryManyToManyDescriptor(ManyToManyDescriptor):
    """Used by HistoryManyToManyField to support querying historical m2m relations.

    We extend the django related manager to add support for returning m2m relations with their historical values.
    """

    @cached_property
    def related_manager_cls(self):
        superclass = super().related_manager_cls
        rel = self.rel

        class HistoryManyRelatedManager(superclass):
            @property
            def core_filters(self):
                if history := to_historic(self.instance):
                    # Filter the m2m field to only the values that existed on the historical version
                    # `rel` is the manytomany relation on the model
                    # `self.target_field` is the field on the through table that points to the underlying model
                    # being linked by the relation. E.g. This would be the image FK on a through model that linked tickets to many images
                    return {
                        "pk__in": getattr(history, rel.field.attname).values(
                            self.target_field.attname
                        )
                    }
                # Not viewing history so use normal m2m filters
                return self._core_filters

            @core_filters.setter
            def core_filters(self, value):
                # Allow the normal filters that django sets up to be saved
                self._core_filters = value

        return HistoryManyRelatedManager


class HistoryManyToManyField(ManyToManyField):
    """Many to many field that supports querying relations for their historical values.

    Using this field necessitates also including the field name in the models `_history_m2m_fields` list.
    """

    def contribute_to_class(self, cls, name, **kwargs):
        super().contribute_to_class(cls, name, **kwargs)
        setattr(
            cls,
            self.name,
            HistoryManyToManyDescriptor(self.remote_field, reverse=False),
        )

    def check(self, **kwargs):
        return [*super().check(**kwargs), *self._check_field_in_history_m2m_fields()]

    def _check_field_in_history_m2m_fields(self):
        history_m2m_fields_list = getattr(self.model, "_history_m2m_fields", [])
        if self.attname not in history_m2m_fields_list:
            return [
                checks.Error(
                    "HistoryManyToManyFields must be included in models"
                    " _history_m2m_fields list.",
                    hint=(
                        f'Add "{self.attname}" to the property "_history_m2m_fields" on'
                        f' "{self.model}".'
                    ),
                )
            ]
        return []

To use this, replace your models.ManyToManyField field definitions with HistoryManyToManyField and also include the field in the _history_m2m_fields list on the model.

@duebbert
Copy link
Author

@trumpet2012 Thank you so much for this! It seems to work fine and is a great workaround for now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Issues related to confirmed bugs
Projects
None yet
Development

No branches or pull requests

4 participants