Repository: django-oscar/django-oscar

Issue:
Hashing for addresses can break with customised models
This is copy & paste from an [thread](https://groups.google.com/d/msgid/django-oscar/9882624b-c10a-410f-9fe3-b2f9e401b62d%40googlegroups.com?utm_medium=email&utm_source=footer) on the mailing list:
1. A customer places an order when the old (unmodified) hash code is in place. Their ShippingAddress and UserAddress get saved with a hash computed by Oscar, which is identical for both models.
2. We add a field on ShippingAddress and overload `active_address_fields` to take this field into account. As a side-effect, this changes how the hash is computed for ShippingAddress but not for UserAddress.
3. When the customer places an order with the same address as before, the system crashes with an IntegrityError, since ... Django looks up whether they already have an address in the db by computing the ShippingAddress' hash, which is different (and probably doesn't match anything). Django doesn't find a match, so it creates a UserAddress, saves that, and crashes, since the hash for the newly created UserAddress matches the one for the UserAddress that's already in the db.

Thinking about this in some more detail, it feels wrong to me that "active_address_fields" is used for two unrelated purposes (computing hashes and formatting addresses for display). It feels like Oscar should compute hashes using an internal mechanism that isn't easily corruptible by over-eager tweakers ;-)

---

Any fix for this should try to be backwards-compatible with existing deployments.



Discussion:
I just ran into this again in Oscar 1.1.1. The following hotfix fixes the integrity error:

```
    def _hash_base(self):
        """
        Imitation of overridden active_address_fields
        """
        fields = [self.salutation, self.line1, self.line2, self.line3,
                  self.line4, self.state, self.postcode]
        fields = [f.strip() for f in fields if f]
        try:
            fields.append(self.country.name)
        except exceptions.ObjectDoesNotExist:
            pass
        return u", ".join(fields)

    def generate_hash(self):
        """
        Overrides generate_hash to use Oscar's active_address_fields as the base hash
        instead of our customized version.
        Avoids https://github.com/django-oscar/django-oscar/issues/1537
        """
        return zlib.crc32(self._hash_base().strip().upper().encode('UTF8'))
```

and should be backwards-compatible. But the hashing doesn't take into account any new fields. I suppose it's still an improvement over the previous state. If somebody has time to make a PR and test that it's indeed backwards-compatible, I'd still recommend merging it, and just tracking that hashing is still a bit broken because it doesn't respect any new address fields.


Resolving patch:
diff --git a/src/oscar/apps/address/abstract_models.py b/src/oscar/apps/address/abstract_models.py
--- a/src/oscar/apps/address/abstract_models.py
+++ b/src/oscar/apps/address/abstract_models.py
@@ -244,6 +244,9 @@ class AbstractAddress(models.Model):
     search_text = models.TextField(
         _("Search text - used only for searching addresses"), editable=False)
 
+    # Fields, used for `summary` property definition and hash generation.
+    base_fields = hash_fields = ['salutation', 'line1', 'line2', 'line3', 'line4', 'state', 'postcode', 'country']
+
     def __str__(self):
         return self.summary
 
@@ -332,25 +335,47 @@ def name(self):
 
     # Helpers
 
-    def generate_hash(self):
-        """
-        Returns a hash of the address summary
-        """
-        # We use an upper-case version of the summary
-        return zlib.crc32(self.summary.strip().upper().encode('UTF8'))
-
-    def join_fields(self, fields, separator=u", "):
-        """
-        Join a sequence of fields using the specified separator
-        """
+    def get_field_values(self, fields):
         field_values = []
         for field in fields:
             # Title is special case
             if field == 'title':
                 value = self.get_title_display()
+            elif field == 'country':
+                try:
+                    value = self.country.printable_name
+                except exceptions.ObjectDoesNotExist:
+                    value = ''
+            elif field == 'salutation':
+                value = self.salutation
             else:
                 value = getattr(self, field)
             field_values.append(value)
+        return field_values
+
+    def get_address_field_values(self, fields):
+        """
+        Returns set of field values within the salutation and country.
+        """
+        field_values = [f.strip() for f in self.get_field_values(fields) if f]
+        return field_values
+
+    def generate_hash(self):
+        """
+        Returns a hash of the address, based on standard set of fields, listed
+        out in `hash_fields` property.
+        """
+        field_values = self.get_address_field_values(self.hash_fields)
+        # Python 2 and 3 generates CRC checksum in different ranges, so
+        # in order to generate platform-independent value we apply
+        # `& 0xffffffff` expression.
+        return zlib.crc32(', '.join(field_values).upper().encode('UTF8')) & 0xffffffff
+
+    def join_fields(self, fields, separator=u", "):
+        """
+        Join a sequence of fields using the specified separator
+        """
+        field_values = self.get_field_values(fields)
         return separator.join(filter(bool, field_values))
 
     def populate_alternative_model(self, address_model):
@@ -367,21 +392,13 @@ def populate_alternative_model(self, address_model):
             if field_name in destination_field_names and field_name != 'id':
                 setattr(address_model, field_name, getattr(self, field_name))
 
-    def active_address_fields(self, include_salutation=True):
+    def active_address_fields(self):
         """
-        Return the non-empty components of the address, but merging the
-        title, first_name and last_name into a single line.
+        Returns the non-empty components of the address, but merging the
+        title, first_name and last_name into a single line. It uses fields
+        listed out in `base_fields` property.
         """
-        fields = [self.line1, self.line2, self.line3,
-                  self.line4, self.state, self.postcode]
-        if include_salutation:
-            fields = [self.salutation] + fields
-        fields = [f.strip() for f in fields if f]
-        try:
-            fields.append(self.country.printable_name)
-        except exceptions.ObjectDoesNotExist:
-            pass
-        return fields
+        return self.get_address_field_values(self.base_fields)
 
 
 @python_2_unicode_compatible
diff --git a/src/oscar/apps/address/migrations/0005_regenerate_user_address_hashes.py b/src/oscar/apps/address/migrations/0005_regenerate_user_address_hashes.py
new file mode 100644
--- /dev/null
+++ b/src/oscar/apps/address/migrations/0005_regenerate_user_address_hashes.py
@@ -0,0 +1,29 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.8 on 2018-01-30 16:08
+from __future__ import unicode_literals
+
+from django.db import migrations
+
+from oscar.core.loading import get_model
+
+
+ORMUserAddress = get_model('address', 'UserAddress')
+
+
+def regenerate_user_address_hashes(apps, schema_editor):
+    MigrationUserAddress = apps.get_model('address', 'UserAddress')
+    for user_address in MigrationUserAddress.objects.all():
+        orm_user_address = ORMUserAddress.objects.get(id=user_address.id)
+        user_address.hash = orm_user_address.generate_hash()
+        user_address.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('address', '0004_auto_20170226_1122'),
+    ]
+
+    operations = [
+        migrations.RunPython(regenerate_user_address_hashes)
+    ]


Test patch:
diff --git a/tests/_site/model_tests_app/models.py b/tests/_site/model_tests_app/models.py
--- a/tests/_site/model_tests_app/models.py
+++ b/tests/_site/model_tests_app/models.py
@@ -1,7 +1,9 @@
 from django.utils import six
 from django.db import models
-from oscar.models.fields import AutoSlugField
+
+from oscar.apps.address.models import UserAddress
 from oscar.apps.offer.models import Benefit, Condition
+from oscar.models.fields import AutoSlugField
 
 
 class SluggedTestModel(models.Model):
@@ -84,3 +86,19 @@ class CustomConditionWithoutName(Condition):
     class Meta:
         proxy = True
         app_label = 'tests'
+
+
+class UserAddressModelWithCustomBaseFields(UserAddress):
+    class Meta:
+        proxy = True
+        app_label = 'tests'
+
+    base_fields = ['line1', 'line4']
+
+
+class UserAddressModelWithCustomHashFields(UserAddress):
+    class Meta:
+        proxy = True
+        app_label = 'tests'
+
+    hash_fields = ['line1', 'line4']
diff --git a/tests/integration/address/test_models.py b/tests/integration/address/test_models.py
--- a/tests/integration/address/test_models.py
+++ b/tests/integration/address/test_models.py
@@ -8,10 +8,23 @@
 from oscar.apps.address import models
 from oscar.test import factories
 
+from tests._site.model_tests_app.models import (
+    UserAddressModelWithCustomBaseFields, UserAddressModelWithCustomHashFields
+)
 
 User = get_user_model()
 
 
+class UserAddressModelWithCustomBaseFieldsFactory(factories.UserAddressFactory):
+    class Meta:
+        model = UserAddressModelWithCustomBaseFields
+
+
+class UserAddressModelWithCustomHashFieldsFactory(factories.UserAddressFactory):
+    class Meta:
+        model = UserAddressModelWithCustomHashFields
+
+
 class TestUserAddress(TestCase):
 
     def setUp(self):
@@ -73,12 +86,21 @@ def test_summary_includes_country(self):
             "Dr Barry Barrington, 1 King Road, London, SW1 9RE, UNITED KINGDOM",
             a.summary)
 
-    def test_can_be_hashed(self):
+    def test_hash_value(self):
         a = factories.UserAddressFactory.build(
             country=self.country,
             user=self.user)
-        hash = a.generate_hash()
-        self.assertTrue(hash is not None)
+        self.assertEqual(a.generate_hash(), 2668616117)
+
+    def test_hash_and_summary_values_on_model_with_custom_base_fields(self):
+        a = UserAddressModelWithCustomBaseFieldsFactory.build(country=self.country, user=self.user)
+        self.assertEqual(a.summary, '1 King Road, London')
+        self.assertEqual(a.generate_hash(), 2668616117)
+
+    def test_hash_and_summary_values_on_model_with_custom_hash_fields(self):
+        a = UserAddressModelWithCustomHashFieldsFactory.build(country=self.country, user=self.user)
+        self.assertEqual(a.summary, 'Dr Barry Barrington, 1 King Road, London, SW1 9RE, UNITED KINGDOM')
+        self.assertEqual(a.generate_hash(), 2153195893)
 
     def test_can_be_hashed_including_non_ascii(self):
         a = factories.UserAddressFactory.build(
@@ -169,6 +191,12 @@ def test_populated_shipping_address_has_same_summary_user_address(self):
         a.populate_alternative_model(sa)
         self.assertEqual(sa.summary, a.summary)
 
+    def test_summary_value(self):
+        a = factories.UserAddressFactory.build(
+            country=self.country,
+            user=self.user)
+        self.assertEqual(a.summary, 'Dr Barry Barrington, 1 King Road, London, SW1 9RE, UNITED KINGDOM')
+
     def test_summary_is_property(self):
         a = factories.UserAddressFactory.build(
             title='',