Skip to content

Commit 53d32cd

Browse files
rwlogelRoss Mechanic
authored andcommitted
Allow alternative user model for tracking history_user (#371)
* Allow alternative user model for tracking history_user * Add support for custom get_user option * Re-order arguments to keep original order for existing arguments * Rename test classes to be more descriptive * Remove history argument for get_user and fix typo
1 parent a387bec commit 53d32cd

File tree

10 files changed

+242
-21
lines changed

10 files changed

+242
-21
lines changed

CHANGES.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
Changes
22
=======
33

4+
Unreleased
5+
----------
6+
- Add ability to specify alternative user_model for tracking
7+
48
2.1.1 (2018-06-15)
59
------------------
610
- Fixed out-of-memory exception when running populate_history management command (gh-408)

docs/advanced.rst

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,65 @@ referencing the ``changed_by`` field:
140140
def _history_user(self, value):
141141
self.changed_by = value
142142
143-
Admin integration requires that you use a ``_history_user.setter`` attribute with your custom ``_history_user`` property (see :ref:`admin_integration`).
143+
Admin integration requires that you use a ``_history_user.setter`` attribute with
144+
your custom ``_history_user`` property (see :ref:`admin_integration`).
145+
146+
Another option for identifying the change user is by providing a function via ``get_user``.
147+
If provided it will be called everytime that the ``history_user`` needs to be
148+
identified with the following key word arguments:
149+
150+
* ``instance``: The current instance being modified
151+
* ``request``: If using the middleware the current request object will be provided if they are authenticated.
152+
153+
This is very helpful when using ``register``:
154+
155+
.. code-block:: python
156+
157+
from django.db import models
158+
from simple_history.models import HistoricalRecords
159+
160+
class Poll(models.Model):
161+
question = models.CharField(max_length=200)
162+
pub_date = models.DateTimeField('date published')
163+
changed_by = models.ForeignKey('auth.User')
164+
165+
166+
def get_poll_user(instance, **kwargs):
167+
return instance.changed_by
168+
169+
register(Poll, get_user=get_poll_user)
170+
171+
172+
Change User Model
173+
------------------------------------
174+
175+
If you need to use a different user model then ``settings.AUTH_USER_MODEL``,
176+
pass in the required model to ``user_model``. Doing this requires ``_history_user``
177+
or ``get_user`` is provided as detailed above.
178+
179+
.. code-block:: python
180+
181+
from django.db import models
182+
from simple_history.models import HistoricalRecords
183+
184+
class PollUser(models.Model):
185+
user_id = models.ForeignKey('auth.User')
186+
187+
188+
# Only PollUsers should be modifying a Poll
189+
class Poll(models.Model):
190+
question = models.CharField(max_length=200)
191+
pub_date = models.DateTimeField('date published')
192+
changed_by = models.ForeignKey(PollUser)
193+
history = HistoricalRecords(user_model=PollUser)
194+
195+
@property
196+
def _history_user(self):
197+
return self.changed_by
198+
199+
@_history_user.setter
200+
def _history_user(self, value):
201+
self.changed_by = value
144202
145203
Custom ``history_id``
146204
---------------------

simple_history/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,6 @@ def register(
2929
records.manager_name = manager_name
3030
records.table_name = table_name
3131
records.module = app and ("%s.models" % app) or model.__module__
32+
records.cls = model
3233
records.add_extra_methods(model)
3334
records.finalize(model)
34-
models.registered_models[model._meta.db_table] = model

simple_history/models.py

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,29 @@
2424
registered_models = {}
2525

2626

27+
def default_get_user(request, **kwargs):
28+
try:
29+
return request.user
30+
except AttributeError:
31+
return None
32+
33+
2734
class HistoricalRecords(object):
2835
thread = threading.local()
2936

3037
def __init__(self, verbose_name=None, bases=(models.Model,),
3138
user_related_name='+', table_name=None, inherit=False,
32-
excluded_fields=None,
33-
history_id_field=None,
34-
history_change_reason_field=None):
39+
excluded_fields=None, history_id_field=None,
40+
history_change_reason_field=None,
41+
user_model=None, get_user=default_get_user):
3542
self.user_set_verbose_name = verbose_name
3643
self.user_related_name = user_related_name
3744
self.table_name = table_name
3845
self.inherit = inherit
3946
self.history_id_field = history_id_field
4047
self.history_change_reason_field = history_change_reason_field
48+
self.user_model = user_model
49+
self.get_user = get_user
4150
if excluded_fields is None:
4251
excluded_fields = []
4352
self.excluded_fields = excluded_fields
@@ -74,15 +83,11 @@ def save_without_historical_record(self, *args, **kwargs):
7483

7584
def finalize(self, sender, **kwargs):
7685
inherited = False
77-
try:
78-
hint_class = self.cls
79-
except AttributeError: # called via `register`
80-
pass
81-
else:
82-
if hint_class is not sender: # set in concrete
83-
inherited = (self.inherit and issubclass(sender, hint_class))
84-
if not inherited:
85-
return # set in abstract
86+
if self.cls is not sender: # set in concrete
87+
inherited = (self.inherit and issubclass(sender, self.cls))
88+
if not inherited:
89+
return # set in abstract
90+
8691
if hasattr(sender._meta, 'simple_history_manager_attribute'):
8792
raise exceptions.MultipleRegistrationsError(
8893
'{}.{} registered multiple times for history tracking.'.format(
@@ -207,7 +212,9 @@ def copy_fields(self, model):
207212
def get_extra_fields(self, model, fields):
208213
"""Return dict of extra fields added to the historical record model"""
209214

210-
user_model = getattr(settings, 'AUTH_USER_MODEL', 'auth.User')
215+
user_model = self.user_model or getattr(
216+
settings, 'AUTH_USER_MODEL', 'auth.User'
217+
)
211218

212219
def revert_url(self):
213220
"""URL for this change in the default admin site."""
@@ -349,12 +356,14 @@ def get_history_user(self, instance):
349356
try:
350357
return instance._history_user
351358
except AttributeError:
359+
request = None
352360
try:
353361
if self.thread.request.user.is_authenticated:
354-
return self.thread.request.user
355-
return None
362+
request = self.thread.request
356363
except AttributeError:
357-
return None
364+
pass
365+
366+
return self.get_user(instance=instance, request=request)
358367

359368

360369
def transform_field(field):

simple_history/tests/models.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,71 @@ class InheritTracking4(TrackedAbstractBaseA):
415415
pass
416416

417417

418+
class BucketMember(models.Model):
419+
name = models.CharField(max_length=30)
420+
user = models.OneToOneField(
421+
User,
422+
related_name="bucket_member",
423+
on_delete=models.CASCADE
424+
)
425+
426+
427+
class BucketData(models.Model):
428+
changed_by = models.ForeignKey(
429+
BucketMember,
430+
on_delete=models.SET_NULL,
431+
null=True, blank=True,
432+
)
433+
history = HistoricalRecords(user_model=BucketMember)
434+
435+
@property
436+
def _history_user(self):
437+
return self.changed_by
438+
439+
440+
def get_bucket_member_changed_by(instance, **kwargs):
441+
try:
442+
return instance.changed_by
443+
except AttributeError:
444+
return None
445+
446+
447+
class BucketDataRegisterChangedBy(models.Model):
448+
changed_by = models.ForeignKey(
449+
BucketMember,
450+
on_delete=models.SET_NULL,
451+
null=True, blank=True,
452+
)
453+
454+
455+
register(
456+
BucketDataRegisterChangedBy,
457+
user_model=BucketMember,
458+
get_user=get_bucket_member_changed_by
459+
)
460+
461+
462+
def get_bucket_member_request_user(request, **kwargs):
463+
try:
464+
return request.user.bucket_member
465+
except AttributeError:
466+
return None
467+
468+
469+
class BucketDataRegisterRequestUser(models.Model):
470+
data = models.CharField(max_length=30)
471+
472+
def get_absolute_url(self):
473+
return reverse('bucket_data-detail', kwargs={'pk': self.pk})
474+
475+
476+
register(
477+
BucketDataRegisterRequestUser,
478+
user_model=BucketMember,
479+
get_user=get_bucket_member_request_user
480+
)
481+
482+
418483
class UUIDModel(models.Model):
419484
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
420485
history = HistoricalRecords(

simple_history/tests/tests/test_admin.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from simple_history.tests.tests.utils import middleware_override_settings
1717
from ..models import (
1818
Book,
19+
BucketData,
20+
BucketMember,
1921
Choice,
2022
ConcreteExternal,
2123
Employee,
@@ -286,7 +288,7 @@ def test_other_admin(self):
286288
change_url = get_history_url(state, 0, site="other_admin")
287289
self.app.get(change_url)
288290

289-
def test_deleteting_user(self):
291+
def test_deleting_user(self):
290292
"""Test deletes of a user does not cascade delete the history"""
291293
self.login()
292294
poll = Poll(question="why?", pub_date=today)
@@ -301,6 +303,21 @@ def test_deleteting_user(self):
301303
historical_poll = poll.history.all()[0]
302304
self.assertEqual(historical_poll.history_user, None)
303305

306+
def test_deleteting_member(self):
307+
"""Test deletes of a BucketMember doesn't cascade delete the history"""
308+
self.login()
309+
member = BucketMember.objects.create(name="member1", user=self.user)
310+
bucket_data = BucketData(changed_by=member)
311+
bucket_data.save()
312+
313+
historical_poll = bucket_data.history.all()[0]
314+
self.assertEqual(historical_poll.history_user, member)
315+
316+
member.delete()
317+
318+
historical_poll = bucket_data.history.all()[0]
319+
self.assertEqual(historical_poll.history_user, None)
320+
304321
def test_missing_one_to_one(self):
305322
"""A relation to a missing one-to-one model should still show
306323
history"""

simple_history/tests/tests/test_middleware.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from django.urls import reverse
55

66
from simple_history.tests.custom_user.models import CustomUser
7-
from simple_history.tests.models import Poll
7+
from simple_history.tests.models import (
8+
BucketDataRegisterRequestUser,
9+
BucketMember,
10+
Poll
11+
)
812
from simple_history.tests.tests.utils import middleware_override_settings
913

1014

@@ -160,3 +164,18 @@ def test_user_is_not_set_on_delete_view_when_not_logged_in(self):
160164

161165
self.assertListEqual([ph.history_user_id for ph in poll_history],
162166
[None, None])
167+
168+
def test_bucket_member_is_set_on_create_view_when_logged_in(self):
169+
self.client.force_login(self.user)
170+
member1 = BucketMember.objects.create(name="member1", user=self.user)
171+
data = {
172+
'data': 'Test Data',
173+
}
174+
self.client.post(reverse('bucket_data-add'), data=data)
175+
bucket_datas = BucketDataRegisterRequestUser.objects.all()
176+
self.assertEqual(bucket_datas.count(), 1)
177+
178+
history = bucket_datas.first().history.all()
179+
180+
self.assertListEqual([h.history_user_id for h in history],
181+
[member1.id])

simple_history/tests/tests/test_models.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
AdminProfile,
2121
Book,
2222
Bookcase,
23+
BucketData,
24+
BucketDataRegisterChangedBy,
25+
BucketMember,
2326
Choice,
2427
City,
2528
ConcreteAttr,
@@ -375,6 +378,34 @@ def test_model_with_excluded_fields(self):
375378
self.assertIn('question', all_fields_names)
376379
self.assertNotIn('pub_date', all_fields_names)
377380

381+
def test_user_model_override(self):
382+
user1 = User.objects.create_user('user1', '[email protected]')
383+
user2 = User.objects.create_user('user2', '[email protected]')
384+
member1 = BucketMember.objects.create(name="member1", user=user1)
385+
member2 = BucketMember.objects.create(name="member2", user=user2)
386+
bucket_data = BucketData.objects.create(changed_by=member1)
387+
bucket_data.changed_by = member2
388+
bucket_data.save()
389+
bucket_data.changed_by = None
390+
bucket_data.save()
391+
self.assertEqual([d.history_user for d in bucket_data.history.all()],
392+
[None, member2, member1])
393+
394+
def test_user_model_override_registered(self):
395+
user1 = User.objects.create_user('user1', '[email protected]')
396+
user2 = User.objects.create_user('user2', '[email protected]')
397+
member1 = BucketMember.objects.create(name="member1", user=user1)
398+
member2 = BucketMember.objects.create(name="member2", user=user2)
399+
bucket_data = BucketDataRegisterChangedBy.objects.create(
400+
changed_by=member1
401+
)
402+
bucket_data.changed_by = member2
403+
bucket_data.save()
404+
bucket_data.changed_by = None
405+
bucket_data.save()
406+
self.assertEqual([d.history_user for d in bucket_data.history.all()],
407+
[None, member2, member1])
408+
378409
def test_uuid_history_id(self):
379410
entry = UUIDModel.objects.create()
380411

simple_history/tests/urls.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from django.contrib import admin
55

66
from simple_history.tests.view import (
7+
BucketDataRegisterRequestUserCreate,
8+
BucketDataRegisterRequestUserDetail,
79
PollCreate,
810
PollDelete,
911
PollDetail,
@@ -17,6 +19,12 @@
1719
urlpatterns = [
1820
url(r'^admin/', admin.site.urls),
1921
url(r'^other-admin/', other_admin.site.urls),
22+
url(r'^bucket_data/add/$',
23+
BucketDataRegisterRequestUserCreate.as_view(),
24+
name='bucket_data-add'),
25+
url(r'^bucket_data/(?P<pk>[0-9]+)/$',
26+
BucketDataRegisterRequestUserDetail.as_view(),
27+
name='bucket_data-detail'),
2028
url(r'^poll/add/$', PollCreate.as_view(), name='poll-add'),
2129
url(r'^poll/(?P<pk>[0-9]+)/$', PollUpdate.as_view(), name='poll-update'),
2230
url(r'^poll/(?P<pk>[0-9]+)/delete/$', PollDelete.as_view(),

0 commit comments

Comments
 (0)