Skip to content

Commit acf47b7

Browse files
Add excluded_field_kwargs to support custom OneToOneFields. (#871)
Coercing OneToOneFields to be ForeignKeys on historical models causes issues when the OneToOneField is a custom subclass that includes additional arguments that do not exist on the ForeignKey.__init__ method. These additional arguments can be specified via excluded_field_kwargs to allow these custom OneToOneFields be coerced into ForeignKeys on the historical model. Fixes #870 Co-authored-by: Jim King <jim.king@cloudtruth.com>
1 parent 353a4ab commit acf47b7

File tree

8 files changed

+190
-0
lines changed

8 files changed

+190
-0
lines changed

AUTHORS.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ Authors
107107
- Stefan Borer (`sbor23 <https://github.com/sbor23>`_)
108108
- Steven Buss (`sbuss <https://github.com/sbuss>`_)
109109
- Steven Klass
110+
- Tim Schilling (`tim-schilling <https://github.com/tim-schilling>`_)
110111
- Tommy Beadle (`tbeadle <https://github.com/tbeadle>`_)
111112
- Trey Hunner (`treyhunner <https://github.com/treyhunner>`_)
112113
- Ulysses Vilela

CHANGES.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ Upgrade Implications:
1111
Full list of changes:
1212

1313
- Added index on `history_date` column; opt-out with setting `SIMPLE_HISTORY_DATE_INDEX` (gh-565)
14+
- Added ``excluded_field_kwargs`` to support custom ``OneToOneField`` that have
15+
additional arguments that don't exist on ``ForeignKey``. (gh-870)
1416
- Fixed ``prev_record`` and ``next_record`` performance when using ``excluded_fields`` (gh-791)
1517
- Fixed `update_change_reason` in pk (gh-806)
1618
- Fixed bug where serializer of djangorestframework crashed if used with ``OrderingFilter`` (gh-821)

docs/common_issues.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,25 @@ Working with BitBucket Pipelines
254254

255255
When using BitBucket Pipelines to test your Django project with the
256256
django-simple-history middleware, you will run into an error relating to missing migrations relating to the historic User model from the auth app. This is because the migration file is not held within either your project or django-simple-history. In order to pypass the error you need to add a ```python manage.py makemigrations auth``` step into your YML file prior to running the tests.
257+
258+
259+
Using custom OneToOneFields
260+
---------------------------
261+
262+
If you are using a custom OneToOneField that has additional arguments and receiving
263+
the the following ``TypeError``::
264+
265+
..
266+
TypeError: __init__() got an unexpected keyword argument
267+
268+
This is because Django Simple History coerces ``OneToOneField`` into ``ForeignKey``
269+
on the historical model. You can work around this by excluded those additional
270+
arguments using ``excluded_field_kwargs`` as follows:
271+
272+
.. code-block:: python
273+
274+
class Poll(models.Model):
275+
organizer = CustomOneToOneField(Organizer, ..., custom_argument="some_value")
276+
history = HistoricalRecords(
277+
excluded_field_kwargs={"organizer": set(["custom_argument"])}
278+
)

simple_history/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ def __init__(
7979
related_name=None,
8080
use_base_model_db=False,
8181
user_db_constraint=True,
82+
excluded_field_kwargs=None,
8283
):
8384
self.user_set_verbose_name = verbose_name
8485
self.user_related_name = user_related_name
@@ -101,6 +102,10 @@ def __init__(
101102
if excluded_fields is None:
102103
excluded_fields = []
103104
self.excluded_fields = excluded_fields
105+
106+
if excluded_field_kwargs is None:
107+
excluded_field_kwargs = {}
108+
self.excluded_field_kwargs = excluded_field_kwargs
104109
try:
105110
if isinstance(bases, str):
106111
raise TypeError
@@ -235,6 +240,12 @@ def fields_included(self, model):
235240
fields.append(field)
236241
return fields
237242

243+
def field_excluded_kwargs(self, field):
244+
"""
245+
Find the excluded kwargs for a given field.
246+
"""
247+
return self.excluded_field_kwargs.get(field.name, set())
248+
238249
def copy_fields(self, model):
239250
"""
240251
Creates copies of the model's original fields, returning
@@ -262,6 +273,12 @@ def copy_fields(self, model):
262273
else:
263274
FieldType = type(old_field)
264275

276+
# Remove any excluded kwargs for the field.
277+
# This is useful when a custom OneToOneField is being used that
278+
# has a different set of arguments than ForeignKey
279+
for exclude_arg in self.field_excluded_kwargs(old_field):
280+
field_args.pop(exclude_arg, None)
281+
265282
# If field_args['to'] is 'self' then we have a case where the object
266283
# has a foreign key to itself. If we pass the historical record's
267284
# field to = 'self', the foreign key will point to an historical
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Generated by Django 3.2.6 on 2021-08-24 10:36
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
import simple_history.models
8+
import simple_history.registry_tests.migration_test_app.models
9+
10+
11+
class Migration(migrations.Migration):
12+
13+
dependencies = [
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
(
16+
"migration_test_app",
17+
"0002_historicalmodelwithcustomattrforeignkey_modelwithcustomattrforeignkey",
18+
),
19+
]
20+
21+
operations = [
22+
migrations.CreateModel(
23+
name="ModelWithCustomAttrOneToOneField",
24+
fields=[
25+
(
26+
"id",
27+
models.AutoField(
28+
auto_created=True,
29+
primary_key=True,
30+
serialize=False,
31+
verbose_name="ID",
32+
),
33+
),
34+
(
35+
"what_i_mean",
36+
simple_history.registry_tests.migration_test_app.models.CustomAttrNameOneToOneField(
37+
attr_name="custom_attr_name",
38+
on_delete=django.db.models.deletion.CASCADE,
39+
to="migration_test_app.whatimean",
40+
),
41+
),
42+
],
43+
),
44+
migrations.CreateModel(
45+
name="HistoricalModelWithCustomAttrOneToOneField",
46+
fields=[
47+
(
48+
"id",
49+
models.IntegerField(
50+
auto_created=True, blank=True, db_index=True, verbose_name="ID"
51+
),
52+
),
53+
("history_id", models.AutoField(primary_key=True, serialize=False)),
54+
("history_date", models.DateTimeField()),
55+
("history_change_reason", models.CharField(max_length=100, null=True)),
56+
(
57+
"history_type",
58+
models.CharField(
59+
choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
60+
max_length=1,
61+
),
62+
),
63+
(
64+
"history_user",
65+
models.ForeignKey(
66+
null=True,
67+
on_delete=django.db.models.deletion.SET_NULL,
68+
related_name="+",
69+
to=settings.AUTH_USER_MODEL,
70+
),
71+
),
72+
(
73+
"what_i_mean",
74+
models.ForeignKey(
75+
blank=True,
76+
db_constraint=False,
77+
null=True,
78+
on_delete=django.db.models.deletion.DO_NOTHING,
79+
related_name="+",
80+
to="migration_test_app.whatimean",
81+
),
82+
),
83+
],
84+
options={
85+
"verbose_name": "historical model with custom attr one to one field",
86+
"ordering": ("-history_date", "-history_id"),
87+
"get_latest_by": "history_date",
88+
},
89+
bases=(simple_history.models.HistoricalChanges, models.Model),
90+
),
91+
]

simple_history/registry_tests/migration_test_app/models.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,29 @@ class ModelWithCustomAttrForeignKey(models.Model):
3636
WhatIMean, models.CASCADE, attr_name="custom_attr_name"
3737
)
3838
history = HistoricalRecords()
39+
40+
41+
class CustomAttrNameOneToOneField(models.OneToOneField):
42+
def __init__(self, *args, **kwargs):
43+
self.attr_name = kwargs.pop("attr_name", None)
44+
super(CustomAttrNameOneToOneField, self).__init__(*args, **kwargs)
45+
46+
def get_attname(self):
47+
return self.attr_name or super(CustomAttrNameOneToOneField, self).get_attname()
48+
49+
def deconstruct(self):
50+
name, path, args, kwargs = super(
51+
CustomAttrNameOneToOneField, self
52+
).deconstruct()
53+
if self.attr_name:
54+
kwargs["attr_name"] = self.attr_name
55+
return name, path, args, kwargs
56+
57+
58+
class ModelWithCustomAttrOneToOneField(models.Model):
59+
what_i_mean = CustomAttrNameOneToOneField(
60+
WhatIMean, models.CASCADE, attr_name="custom_attr_name"
61+
)
62+
history = HistoricalRecords(
63+
excluded_field_kwargs={"what_i_mean": set(["attr_name"])}
64+
)

simple_history/registry_tests/tests.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
InheritTracking3,
1818
InheritTracking4,
1919
ModelWithCustomAttrForeignKey,
20+
ModelWithCustomAttrOneToOneField,
2021
ModelWithHistoryInDifferentApp,
2122
Poll,
2223
Restaurant,
@@ -205,6 +206,14 @@ def test_custom_attr(self):
205206
self.assertEqual(field.attr_name, "custom_poll")
206207

207208

209+
class TestCustomAttrOneToOneField(TestCase):
210+
"""https://github.com/jazzband/django-simple-history/issues/870"""
211+
212+
def test_custom_attr(self):
213+
field = ModelWithCustomAttrOneToOneField.history.model._meta.get_field("poll")
214+
self.assertFalse(hasattr(field, "attr_name"))
215+
216+
208217
@override_settings(MIGRATION_MODULES={})
209218
class TestMigrate(TestCase):
210219
def test_makemigration_command(self):

simple_history/tests/models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,28 @@ class ModelWithCustomAttrForeignKey(models.Model):
119119
history = HistoricalRecords()
120120

121121

122+
class CustomAttrNameOneToOneField(models.OneToOneField):
123+
def __init__(self, *args, **kwargs):
124+
self.attr_name = kwargs.pop("attr_name", None)
125+
super(CustomAttrNameOneToOneField, self).__init__(*args, **kwargs)
126+
127+
def get_attname(self):
128+
return self.attr_name or super(CustomAttrNameOneToOneField, self).get_attname()
129+
130+
def deconstruct(self):
131+
name, path, args, kwargs = super(
132+
CustomAttrNameOneToOneField, self
133+
).deconstruct()
134+
if self.attr_name:
135+
kwargs["attr_name"] = self.attr_name
136+
return name, path, args, kwargs
137+
138+
139+
class ModelWithCustomAttrOneToOneField(models.Model):
140+
poll = CustomAttrNameOneToOneField(Poll, models.CASCADE, attr_name="custom_poll")
141+
history = HistoricalRecords(excluded_field_kwargs={"poll": set(["attr_name"])})
142+
143+
122144
class Temperature(models.Model):
123145
location = models.CharField(max_length=200)
124146
temperature = models.IntegerField()

0 commit comments

Comments
 (0)