Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 101 additions & 1 deletion binder/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from datetime import date, datetime, time
from contextlib import suppress
from decimal import Decimal
from functools import partial

from django import forms
from django.db import models
Expand Down Expand Up @@ -730,6 +731,69 @@ def history_obj_m2m_changed(sender, instance, action, reverse, model, pk_set, **



def history_obj_reverse_relation_pre_save(
sender, instance, parent_model, fk_name, reverse_name, **kwargs
):
if getattr(instance, "_history_skip", False):
return

# New Parent
try:
new_parent = getattr(instance, fk_name)
except parent_model.DoesNotExist:
new_parent = None

# Old Parent
if instance.pk:
try:
# We only need the FK field to identify the old parent
old_instance = sender.objects.only(fk_name).get(pk=instance.pk)
old_parent = getattr(old_instance, fk_name)
except (sender.DoesNotExist, parent_model.DoesNotExist):
old_parent = None
else:
old_parent = None

if old_parent:
history.change(
parent_model,
old_parent.pk,
reverse_name,
history.DeferredM2M,
history.DeferredM2M,
)

if new_parent and new_parent != old_parent:
history.change(
parent_model,
new_parent.pk,
reverse_name,
history.DeferredM2M,
history.DeferredM2M,
)


def history_obj_reverse_relation_pre_delete(
sender, instance, parent_model, fk_name, reverse_name, **kwargs
):
if getattr(instance, "_history_skip", False):
return

try:
parent = getattr(instance, fk_name)
Comment thread
knokko marked this conversation as resolved.
except parent_model.DoesNotExist:
parent = None

if parent:
history.change(
parent_model,
parent.pk,
reverse_name,
history.DeferredM2M,
history.DeferredM2M,
)


# FIXME: remove
def install_m2m_signal_handlers(model):
warnings.warn(DeprecationWarning('install_m2m_signal_handlers() is deprecated, call install_history_signal_handlers() instead!'))
Expand All @@ -748,7 +812,43 @@ def install_history_signal_handlers(model):

for field in model._meta.get_fields():
if field.many_to_many and field.concrete:
signals.m2m_changed.connect(history_obj_m2m_changed, getattr(model, field.name).through)
signals.m2m_changed.connect(
history_obj_m2m_changed, getattr(model, field.name).through
)

for rel_name in getattr(model.Binder, "include_reverse_relations", []):
try:
rel_field = model._meta.get_field(rel_name)
except models.FieldDoesNotExist:
raise ValueError(f'Reverse relation {rel_name} does not exist on model {model.__name__}.')

if not rel_field.is_relation or not rel_field.auto_created:
raise ValueError(f'Field {rel_name} on model {model.__name__} is not a reverse relation.')

child_model = rel_field.related_model
fk_field = rel_field.field
fk_name = fk_field.name

signals.pre_save.connect(
partial(
history_obj_reverse_relation_pre_save,
parent_model=model,
fk_name=fk_name,
reverse_name=rel_name,
),
sender=child_model,
weak=False,
)
signals.pre_delete.connect(
partial(
history_obj_reverse_relation_pre_delete,
parent_model=model,
fk_name=fk_name,
reverse_name=rel_name,
),
sender=child_model,
weak=False,
)

for sub in model.__subclasses__():
install_history_signal_handlers(sub)
Expand Down
16 changes: 16 additions & 0 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,22 @@ class Animal(BinderModel):
exclude_history_fields = ['secret_notes']
```

You can also include reverse relations in history tracking by setting `include_reverse_relations`. This is useful if you want to track when related objects are created or deleted on the parent model.

```python
class Zoo(BinderModel):
# ...

class Binder:
history = True
include_reverse_relations = ['animals']

class Animal(BinderModel):
zoo = models.ForeignKey(Zoo, related_name='animals', on_delete=models.CASCADE)

# History on the child model is not required for this to work
```

Saving the model will result in one changeset. With a changeset, the user that changed it and datetime is saved.

A changeset contains changes for each field that has been changed to a new value. For each change, you can see the old value and the new value.
Expand Down
84 changes: 84 additions & 0 deletions tests/test_reverse_relation_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from django.test import TestCase, Client
from django.contrib.auth.models import User
import json
from binder import history
from .testapp.models import ReverseParent, ReverseChild
from binder.models import install_history_signal_handlers, BinderModel


class ReverseRelationHistoryTest(TestCase):
def setUp(self):
u = User.objects.create_superuser("testuser", "test@example.com", "test")
self.client = Client()
self.client.login(username="testuser", password="test")
# Ensure signals are installed
install_history_signal_handlers(BinderModel)

def test_reverse_relation_history(self):
with history.atomic(source="test"):
parent = ReverseParent.objects.create(name="Mom")

with history.atomic(source="test"):
child = ReverseChild.objects.create(name="Kid", parent=parent)

# Check Parent history
changesets = history.Changeset.objects.filter(
changes__model="ReverseParent",
changes__oid=parent.pk,
changes__field="children",
).distinct()
self.assertTrue(changesets.exists())

change = history.Change.objects.filter(
model="ReverseParent", oid=parent.pk, field="children"
).last()

# Before should be empty or []
before = json.loads(change.before)
after = json.loads(change.after)

self.assertEqual(before, [])
self.assertIn(child.pk, after)

def test_reverse_relation_move(self):
with history.atomic(source="test"):
p1 = ReverseParent.objects.create(name="P1")
p2 = ReverseParent.objects.create(name="P2")
c1 = ReverseChild.objects.create(name="C1", parent=p1)

# Move C1 to P2
with history.atomic(source="test"):
c1.parent = p2
c1.save()

# P1 should show removal
change_p1 = history.Change.objects.filter(
model="ReverseParent", oid=p1.pk, field="children"
).last()
self.assertIn(c1.pk, json.loads(change_p1.before))
self.assertNotIn(c1.pk, json.loads(change_p1.after))

# P2 should show addition
change_p2 = history.Change.objects.filter(
model="ReverseParent", oid=p2.pk, field="children"
).last()
self.assertNotIn(c1.pk, json.loads(change_p2.before))
self.assertIn(c1.pk, json.loads(change_p2.after))

def test_reverse_relation_delete(self):
with history.atomic(source="test"):
p1 = ReverseParent.objects.create(name="P1")
c1 = ReverseChild.objects.create(name="C1", parent=p1)

c1_pk = c1.pk

# Delete child
with history.atomic(source="test"):
c1.delete()

# P1 should show removal
change_p1 = history.Change.objects.filter(
model="ReverseParent", oid=p1.pk, field="children"
).last()
self.assertIn(c1_pk, json.loads(change_p1.before))
self.assertNotIn(c1_pk, json.loads(change_p1.after))
41 changes: 41 additions & 0 deletions tests/test_reverse_relation_no_child_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from django.test import TestCase, Client
from django.contrib.auth.models import User
import json
from binder import history
from .testapp.models import ReverseParentNoChildHistory, ReverseChildNoHistory
from binder.models import install_history_signal_handlers, BinderModel


class ReverseRelationNoChildHistoryTest(TestCase):
def setUp(self):
u = User.objects.create_superuser("testuser", "test@example.com", "test")
self.client = Client()
self.client.login(username="testuser", password="test")
# Ensure signals are installed
install_history_signal_handlers(BinderModel)

def test_reverse_relation_no_child_history(self):
with history.atomic(source="test"):
parent = ReverseParentNoChildHistory.objects.create(name="Mom")

with history.atomic(source="test"):
child = ReverseChildNoHistory.objects.create(name="Kid", parent=parent)

# Check Parent history
changesets = history.Changeset.objects.filter(
changes__model="ReverseParentNoChildHistory",
changes__oid=parent.pk,
changes__field="children",
).distinct()
self.assertTrue(changesets.exists())

change = history.Change.objects.filter(
model="ReverseParentNoChildHistory", oid=parent.pk, field="children"
).last()

# Before should be empty or []
before = json.loads(change.before)
after = json.loads(change.after)

self.assertEqual(before, [])
self.assertIn(child.pk, after)
6 changes: 6 additions & 0 deletions tests/testapp/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
from .country import Country
from .web_page import WebPage
from .pet import Pet
from .reverse_config_models import (
ReverseParent,
ReverseChild,
ReverseParentNoChildHistory,
ReverseChildNoHistory,
)

# This is Postgres-specific
if os.environ.get('BINDER_TEST_MYSQL', '0') != '1':
Comment thread
knokko marked this conversation as resolved.
Expand Down
58 changes: 58 additions & 0 deletions tests/testapp/models/reverse_config_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from django.db import models
from binder.models import BinderModel


class ReverseParent(BinderModel):
name = models.CharField(max_length=64)

class Binder:
history = True
include_reverse_relations = ["children"]

class Meta:
app_label = "testapp"


class ReverseChild(BinderModel):
name = models.CharField(max_length=64)
parent = models.ForeignKey(
ReverseParent,
on_delete=models.CASCADE,
related_name="children",
null=True,
blank=True,
)

class Binder:
history = True

class Meta:
app_label = "testapp"


class ReverseParentNoChildHistory(BinderModel):
name = models.CharField(max_length=64)

class Binder:
history = True
include_reverse_relations = ["children"]

class Meta:
app_label = "testapp"


class ReverseChildNoHistory(BinderModel):
name = models.CharField(max_length=64)
parent = models.ForeignKey(
ReverseParentNoChildHistory,
on_delete=models.CASCADE,
related_name="children",
null=True,
blank=True,
)

class Binder:
history = False

class Meta:
app_label = "testapp"