Repository: jazzband/django-simple-history

Issue:
Record the IP Address that made a change
**Problem Statement**
As described in https://github.com/treyhunner/django-simple-history/issues/420 we use this project for audit. One of the requirements is to record the IP Address that made a change in the system.

**Describe the solution you'd like**
Simple solution: Add IP Address to the history model, update `create_historical_record` to save the REMOTE_ADDR and display it in the admin panel.


Discussion:
Use case makes sense to me. Will look at PR by EOW

Resolving patch:
diff --git a/simple_history/models.py b/simple_history/models.py
--- a/simple_history/models.py
+++ b/simple_history/models.py
@@ -362,21 +362,26 @@ def create_historical_record(self, instance, history_type):
         history_change_reason = getattr(instance, 'changeReason', None)
         manager = getattr(instance, self.manager_name)
 
-        pre_create_historical_record.send(
-            sender=manager.model, instance=instance,
-            history_date=history_date, history_user=history_user,
-            history_change_reason=history_change_reason,
-        )
-
         attrs = {}
         for field in self.fields_included(instance):
             attrs[field.attname] = getattr(instance, field.attname)
-        history_instance = manager.create(
+
+        history_instance = manager.model(
             history_date=history_date, history_type=history_type,
             history_user=history_user,
-            history_change_reason=history_change_reason, **attrs
+            history_change_reason=history_change_reason,
+            **attrs
         )
 
+        pre_create_historical_record.send(
+            sender=manager.model, instance=instance,
+            history_date=history_date, history_user=history_user,
+            history_change_reason=history_change_reason,
+            history_instance=history_instance,
+        )
+
+        history_instance.save()
+
         post_create_historical_record.send(
             sender=manager.model, instance=instance,
             history_instance=history_instance,
diff --git a/simple_history/signals.py b/simple_history/signals.py
--- a/simple_history/signals.py
+++ b/simple_history/signals.py
@@ -2,7 +2,8 @@
 
 
 pre_create_historical_record = django.dispatch.Signal(providing_args=[
-    'instance', 'history_date', 'history_user', 'history_change_reason',
+    'instance', 'history_instance', 'history_date', 'history_user',
+    'history_change_reason',
 ])
 post_create_historical_record = django.dispatch.Signal(providing_args=[
     'instance', 'history_instance', 'history_date', 'history_user',


Test patch:
diff --git a/simple_history/tests/models.py b/simple_history/tests/models.py
--- a/simple_history/tests/models.py
+++ b/simple_history/tests/models.py
@@ -5,10 +5,12 @@
 from django.apps import apps
 from django.conf import settings
 from django.db import models
+from django.dispatch import receiver
 from django.urls import reverse
 
 from simple_history import register
 from simple_history.models import HistoricalRecords
+from simple_history.signals import pre_create_historical_record
 from .custom_user.models import CustomUser as User
 from .external.models.model1 import AbstractExternal
 
@@ -40,6 +42,23 @@ class PollWithExcludedFKField(models.Model):
     history = HistoricalRecords(excluded_fields=['place'])
 
 
+class IPAddressHistoricalModel(models.Model):
+    ip_address = models.GenericIPAddressField()
+
+    class Meta:
+        abstract = True
+
+
+class PollWithHistoricalIPAddress(models.Model):
+    question = models.CharField(max_length=200)
+    pub_date = models.DateTimeField('date published')
+
+    history = HistoricalRecords(bases=[IPAddressHistoricalModel])
+
+    def get_absolute_url(self):
+        return reverse('poll-detail', kwargs={'pk': self.pk})
+
+
 class Temperature(models.Model):
     location = models.CharField(max_length=200)
     temperature = models.IntegerField()
diff --git a/simple_history/tests/tests/test_models.py b/simple_history/tests/tests/test_models.py
--- a/simple_history/tests/tests/test_models.py
+++ b/simple_history/tests/tests/test_models.py
@@ -10,12 +10,15 @@
 from django.core.files.base import ContentFile
 from django.db import models
 from django.db.models.fields.proxy import OrderWrt
-from django.test import TestCase
+from django.test import override_settings, TestCase
+from django.urls import reverse
 
 from simple_history.models import (
     HistoricalRecords,
     ModelChange
 )
+from simple_history.signals import pre_create_historical_record
+from simple_history.tests.tests.utils import middleware_override_settings
 from simple_history.utils import update_change_reason
 from ..external.models import ExternalModel2, ExternalModel4
 from ..models import (
@@ -43,6 +46,7 @@
     HistoricalChoice,
     HistoricalCustomFKError,
     HistoricalPoll,
+    HistoricalPollWithHistoricalIPAddress,
     HistoricalState,
     Library,
     MultiOneToOne,
@@ -52,6 +56,7 @@
     PollInfo,
     PollWithExcludeFields,
     PollWithExcludedFKField,
+    PollWithHistoricalIPAddress,
     Province,
     Restaurant,
     SelfFK,
@@ -1050,6 +1055,81 @@ def test_changed_value_lost(self):
         self.assertEqual(instance.place, new_place)
 
 
+def add_static_history_ip_address(sender, **kwargs):
+    history_instance = kwargs['history_instance']
+    history_instance.ip_address = '192.168.0.1'
+
+
+class ExtraFieldsStaticIPAddressTestCase(TestCase):
+    def setUp(self):
+        pre_create_historical_record.connect(
+            add_static_history_ip_address,
+            sender=HistoricalPollWithHistoricalIPAddress,
+            dispatch_uid='add_static_history_ip_address'
+        )
+
+    def tearDown(self):
+        pre_create_historical_record.disconnect(
+            add_static_history_ip_address,
+            sender=HistoricalPollWithHistoricalIPAddress,
+            dispatch_uid='add_static_history_ip_address'
+        )
+
+    def test_extra_ip_address_field_populated_on_save(self):
+        poll = PollWithHistoricalIPAddress.objects.create(
+            question="Will it blend?", pub_date=today
+        )
+
+        poll_history = poll.history.first()
+
+        self.assertEquals('192.168.0.1', poll_history.ip_address)
+
+    def test_extra_ip_address_field_not_present_on_poll(self):
+        poll = PollWithHistoricalIPAddress.objects.create(
+            question="Will it blend?", pub_date=today
+        )
+
+        with self.assertRaises(AttributeError):
+            poll.ip_address
+
+
+def add_dynamic_history_ip_address(sender, **kwargs):
+    history_instance = kwargs['history_instance']
+    history_instance.ip_address = \
+        HistoricalRecords.thread.request.META['REMOTE_ADDR']
+
+
+@override_settings(**middleware_override_settings)
+class ExtraFieldsDynamicIPAddressTestCase(TestCase):
+    def setUp(self):
+        pre_create_historical_record.connect(
+            add_dynamic_history_ip_address,
+            sender=HistoricalPollWithHistoricalIPAddress,
+            dispatch_uid='add_dynamic_history_ip_address'
+        )
+
+    def tearDown(self):
+        pre_create_historical_record.disconnect(
+            add_dynamic_history_ip_address,
+            sender=HistoricalPollWithHistoricalIPAddress,
+            dispatch_uid='add_dynamic_history_ip_address'
+        )
+
+    def test_signal_is_able_to_retrieve_request_from_thread(self):
+        data = {
+            'question': 'Will it blend?',
+            'pub_date': '2018-10-30'
+        }
+
+        self.client.post(reverse('pollip-add'), data=data)
+
+        polls = PollWithHistoricalIPAddress.objects.all()
+        self.assertEqual(1, polls.count())
+
+        poll_history = polls[0].history.first()
+        self.assertEqual('127.0.0.1', poll_history.ip_address)
+
+
 class WarningOnAbstractModelWithInheritFalseTest(TestCase):
     def test_warning_on_abstract_model_with_inherit_false(self):
 
diff --git a/simple_history/tests/tests/test_signals.py b/simple_history/tests/tests/test_signals.py
--- a/simple_history/tests/tests/test_signals.py
+++ b/simple_history/tests/tests/test_signals.py
@@ -25,6 +25,7 @@ def test_pre_create_historical_record_signal(self):
         def handler(sender, instance, **kwargs):
             self.signal_was_called = True
             self.signal_instance = instance
+            self.signal_history_instance = kwargs['history_instance']
             self.signal_sender = sender
         pre_create_historical_record.connect(handler)
 
@@ -33,6 +34,7 @@ def handler(sender, instance, **kwargs):
 
         self.assertTrue(self.signal_was_called)
         self.assertEqual(self.signal_instance, p)
+        self.assertIsNotNone(self.signal_history_instance)
         self.assertEqual(self.signal_sender, p.history.first().__class__)
 
     def test_post_create_historical_record_signal(self):
@@ -48,5 +50,5 @@ def handler(sender, instance, history_instance, **kwargs):
 
         self.assertTrue(self.signal_was_called)
         self.assertEqual(self.signal_instance, p)
-        self.assertTrue(self.signal_history_instance is not None)
+        self.assertIsNotNone(self.signal_history_instance)
         self.assertEqual(self.signal_sender, p.history.first().__class__)
diff --git a/simple_history/tests/urls.py b/simple_history/tests/urls.py
--- a/simple_history/tests/urls.py
+++ b/simple_history/tests/urls.py
@@ -10,7 +10,8 @@
     PollDelete,
     PollDetail,
     PollList,
-    PollUpdate
+    PollUpdate,
+    PollWithHistoricalIPAddressCreate,
 )
 from . import other_admin
 
@@ -26,6 +27,11 @@
         BucketDataRegisterRequestUserDetail.as_view(),
         name='bucket_data-detail'),
     url(r'^poll/add/$', PollCreate.as_view(), name='poll-add'),
+    url(
+        r'^pollwithhistoricalipaddress/add$',
+        PollWithHistoricalIPAddressCreate.as_view(),
+        name='pollip-add'
+    ),
     url(r'^poll/(?P<pk>[0-9]+)/$', PollUpdate.as_view(), name='poll-update'),
     url(r'^poll/(?P<pk>[0-9]+)/delete/$', PollDelete.as_view(),
         name='poll-delete'),
diff --git a/simple_history/tests/view.py b/simple_history/tests/view.py
--- a/simple_history/tests/view.py
+++ b/simple_history/tests/view.py
@@ -7,7 +7,11 @@
     UpdateView
 )
 
-from simple_history.tests.models import BucketDataRegisterRequestUser, Poll
+from simple_history.tests.models import (
+    BucketDataRegisterRequestUser,
+    Poll,
+    PollWithHistoricalIPAddress
+)
 
 
 class PollCreate(CreateView):
@@ -15,6 +19,11 @@ class PollCreate(CreateView):
     fields = ['question', 'pub_date']
 
 
+class PollWithHistoricalIPAddressCreate(CreateView):
+    model = PollWithHistoricalIPAddress
+    fields = ['question', 'pub_date']
+
+
 class PollUpdate(UpdateView):
     model = Poll
     fields = ['question', 'pub_date']