Repository: wagtail/wagtail

Issue:
Compare with previous version fails when model has custom primary key field
### Issue Summary

Compare with previous version fails when model has custom primary key field. `ChildRelationComparison.get_mapping` uses `id` instead of `pk`, causing any models with custom primary keys to raise AttributeError.

### Steps to Reproduce

1. Start a new project with `wagtail start myproject`
2. Edit models.py as follows...

```
# -*- coding: utf-8 -*-
from __future__ import absolute_import, division, print_function, unicode_literals

from django.db import models
from modelcluster.fields import ParentalKey
from wagtail.wagtailadmin.edit_handlers import FieldPanel, InlinePanel
from wagtail.wagtailcore.models import Page


class HomePage(Page):
    content_panels = Page.content_panels + [
        InlinePanel('related_models', label='Related models'),
    ]


class RelatedModel(models.Model):
    custom_id = models.AutoField(primary_key=True)
    home_page = ParentalKey('home.HomePage', related_name='related_models')
    value = models.IntegerField()

    panels = [
        FieldPanel('value'),
    ]
```
3. `makemigrations`
4. `migrate`
5. `createsuperuser`
5. `runserver`
6. login to /admin
7. edit home page
8. add a "related model" and press save draft
9. add a "related model" and press publish
10. re-edit home page
11. click revisions
12. click "compare with previous revision"

I expected it to use the correct primary key field. Code in `ChildRelationComparison.get_mapping` should use `pk` instead of `id`.

### Technical details

* Python version: 2.7.10
* Django version: 1.11.9
* Wagtail version: 1.13.1
* Browser version: whatsmybrowser.org/b/XXQTSVL



Discussion:
@webit-mdowney thank you for this awesome issue report, the info helped heaps. I can confirm this is an issue when using a non `id` primary key.

I have put in a PR to fix this.
@lb- thanks mate!

Resolving patch:
diff --git a/wagtail/admin/compare.py b/wagtail/admin/compare.py
--- a/wagtail/admin/compare.py
+++ b/wagtail/admin/compare.py
@@ -180,7 +180,7 @@ def get_mapping(self, objs_a, objs_b):
         This bit of code attempts to match the objects in the A revision with
         their counterpart in the B revision.
 
-        A match is firstly attempted by ID (where a matching ID indicates they're the same).
+        A match is firstly attempted by PK (where a matching ID indicates they're the same).
         We compare remaining the objects by their field data; the objects with the fewest
         fields changed are matched until there are no more possible matches left.
 
@@ -218,13 +218,13 @@ def get_mapping(self, objs_a, objs_b):
         added = []
         deleted = []
 
-        # Match child objects on ID
+        # Match child objects on PK (ID)
         for a_idx, a_child in enumerate(objs_a):
             for b_idx, b_child in enumerate(objs_b):
                 if b_idx in map_backwards:
                     continue
 
-                if a_child.id is not None and b_child.id is not None and a_child.id == b_child.id:
+                if a_child.pk is not None and b_child.pk is not None and a_child.pk == b_child.pk:
                     map_forwards[a_idx] = b_idx
                     map_backwards[b_idx] = a_idx
 
@@ -234,8 +234,8 @@ def get_mapping(self, objs_a, objs_b):
             if a_idx not in map_forwards:
                 for b_idx, b_child in enumerate(objs_b):
                     if b_idx not in map_backwards:
-                        # If they both have an ID that is different, they can't be the same child object
-                        if a_child.id and b_child.id and a_child.id != b_child.id:
+                        # If they both have a PK (ID) that is different, they can't be the same child object
+                        if a_child.pk and b_child.pk and a_child.pk != b_child.pk:
                             continue
 
                         comparison = self.get_child_comparison(objs_a[a_idx], objs_b[b_idx])


Test patch:
diff --git a/wagtail/admin/tests/test_compare.py b/wagtail/admin/tests/test_compare.py
--- a/wagtail/admin/tests/test_compare.py
+++ b/wagtail/admin/tests/test_compare.py
@@ -4,12 +4,13 @@
 from django.utils.functional import curry
 from django.utils.safestring import SafeText
 
-from wagtail.tests.testapp.models import (
-    EventCategory, EventPage, EventPageSpeaker, SimplePage, StreamPage, TaggedPage)
 from wagtail.admin import compare
 from wagtail.core.blocks import StreamValue
 from wagtail.images import get_image_model
 from wagtail.images.tests.utils import get_test_image_file
+from wagtail.tests.testapp.models import (
+    EventCategory, EventPage, EventPageSpeaker, HeadCountRelatedModelUsingPK,
+    SimplePage, StreamPage, TaggedPage)
 
 
 class TestFieldComparison(TestCase):
@@ -649,3 +650,87 @@ def test_deletion(self):
         self.assertFalse(comparison.has_changed())
         self.assertIsNone(comparison.get_position_change())
         self.assertEqual(comparison.get_num_differences(), 0)
+
+
+class TestChildRelationComparisonUsingPK(TestCase):
+    """Test related objects can be compred if they do not use id for primary key"""
+
+    field_comparison_class = compare.FieldComparison
+    comparison_class = compare.ChildRelationComparison
+
+    def test_has_changed_with_same_id(self):
+        # Head Count was changed but the PK of the child object remained the same.
+        # It should be detected as the same object
+
+        event_page = EventPage(title="Semi Finals", slug="semi-finals-2018")
+        event_page.head_counts.add(HeadCountRelatedModelUsingPK(
+            custom_id=1,
+            head_count=22,
+        ))
+
+        modified_event_page = EventPage(title="Semi Finals", slug="semi-finals-2018")
+        modified_event_page.head_counts.add(HeadCountRelatedModelUsingPK(
+            custom_id=1,
+            head_count=23,
+        ))
+        modified_event_page.head_counts.add(HeadCountRelatedModelUsingPK(
+            head_count=25,
+        ))
+
+        comparison = self.comparison_class(
+            EventPage._meta.get_field('head_counts'),
+            [curry(self.field_comparison_class, HeadCountRelatedModelUsingPK._meta.get_field('head_count'))],
+            event_page,
+            modified_event_page,
+        )
+
+        self.assertFalse(comparison.is_field)
+        self.assertTrue(comparison.is_child_relation)
+        self.assertEqual(comparison.field_label(), 'Head counts')
+        self.assertTrue(comparison.has_changed())
+
+        # Check mapping
+        objs_a = list(comparison.val_a.all())
+        objs_b = list(comparison.val_b.all())
+        map_forwards, map_backwards, added, deleted = comparison.get_mapping(objs_a, objs_b)
+        self.assertEqual(map_forwards, {0: 0})  # map head count 22 to 23
+        self.assertEqual(map_backwards, {0: 0})  # map head count 23 to 22
+        self.assertEqual(added, [1])  # add second head count
+        self.assertEqual(deleted, [])
+
+
+    def test_hasnt_changed_with_different_id(self):
+        # Both of the child objects have the same field content but have a
+        # different PK (ID) so they should be detected as separate objects
+        event_page = EventPage(title="Finals", slug="finals-event-abc")
+        event_page.head_counts.add(HeadCountRelatedModelUsingPK(
+            custom_id=1,
+            head_count=220
+        ))
+
+        modified_event_page = EventPage(title="Finals", slug="finals-event-abc")
+        modified_event_page.head_counts.add(HeadCountRelatedModelUsingPK(
+            custom_id=2,
+            head_count=220
+        ))
+
+        comparison = self.comparison_class(
+            EventPage._meta.get_field('head_counts'),
+            [curry(self.field_comparison_class, HeadCountRelatedModelUsingPK._meta.get_field('head_count'))],
+            event_page,
+            modified_event_page,
+        )
+
+        self.assertFalse(comparison.is_field)
+        self.assertTrue(comparison.is_child_relation)
+        self.assertEqual(comparison.field_label(), "Head counts")
+        self.assertTrue(comparison.has_changed())
+
+        # Check mapping
+        objs_a = list(comparison.val_a.all())
+        objs_b = list(comparison.val_b.all())
+        map_forwards, map_backwards, added, deleted = comparison.get_mapping(objs_a, objs_b)
+        self.assertEqual(map_forwards, {})
+        self.assertEqual(map_backwards, {})
+        self.assertEqual(added, [0])  # Add new head count
+        self.assertEqual(deleted, [0])  # Delete old head count
diff --git a/wagtail/admin/tests/test_pages_views.py b/wagtail/admin/tests/test_pages_views.py
--- a/wagtail/admin/tests/test_pages_views.py
+++ b/wagtail/admin/tests/test_pages_views.py
@@ -1759,6 +1759,10 @@ def test_reorder(self):
             'speakers-MAX_NUM_FORMS': 1000,
             'speakers-TOTAL_FORMS': 0,
 
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 1000,
+            'head_counts-TOTAL_FORMS': 0,
+
             'carousel_items-INITIAL_FORMS': 3,
             'carousel_items-MAX_NUM_FORMS': 1000,
             'carousel_items-TOTAL_FORMS': 3,
@@ -1801,6 +1805,10 @@ def test_reorder_with_validation_error(self):
             'speakers-MAX_NUM_FORMS': 1000,
             'speakers-TOTAL_FORMS': 0,
 
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 1000,
+            'head_counts-TOTAL_FORMS': 0,
+
             'carousel_items-INITIAL_FORMS': 3,
             'carousel_items-MAX_NUM_FORMS': 1000,
             'carousel_items-TOTAL_FORMS': 3,
@@ -3826,6 +3834,10 @@ def test_page_edit_post_publish_url(self):
             'related_links-INITIAL_FORMS': 0,
             'related_links-MIN_NUM_FORMS': 0,
             'related_links-MAX_NUM_FORMS': 0,
+            'head_counts-TOTAL_FORMS': 0,
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MIN_NUM_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 0,
         }
         response = self.client.post(
             reverse('wagtailadmin_pages:edit', args=(self.child_page.id, )),
@@ -4133,6 +4145,10 @@ def test_create_and_save(self):
             'related_links-INITIAL_FORMS': 0,
             'related_links-MIN_NUM_FORMS': 0,
             'related_links-MAX_NUM_FORMS': 0,
+            'head_counts-TOTAL_FORMS': 0,
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MIN_NUM_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 0,
             'categories': [self.holiday_category.id, self.men_with_beards_category.id]
         }
         response = self.client.post(
@@ -4167,6 +4183,10 @@ def test_create_and_publish(self):
             'related_links-INITIAL_FORMS': 0,
             'related_links-MIN_NUM_FORMS': 0,
             'related_links-MAX_NUM_FORMS': 0,
+            'head_counts-TOTAL_FORMS': 0,
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MIN_NUM_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 0,
             'categories': [self.holiday_category.id, self.men_with_beards_category.id]
         }
         response = self.client.post(
@@ -4199,6 +4219,10 @@ def test_edit_and_save(self):
             'related_links-INITIAL_FORMS': 0,
             'related_links-MIN_NUM_FORMS': 0,
             'related_links-MAX_NUM_FORMS': 0,
+            'head_counts-TOTAL_FORMS': 0,
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MIN_NUM_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 0,
             'categories': [self.holiday_category.id, self.men_with_beards_category.id]
         }
         response = self.client.post(
@@ -4236,6 +4260,10 @@ def test_edit_and_publish(self):
             'related_links-INITIAL_FORMS': 0,
             'related_links-MIN_NUM_FORMS': 0,
             'related_links-MAX_NUM_FORMS': 0,
+            'head_counts-TOTAL_FORMS': 0,
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MIN_NUM_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 0,
             'categories': [self.holiday_category.id, self.men_with_beards_category.id]
         }
         response = self.client.post(
@@ -4278,6 +4306,10 @@ def test_field_error(self):
             'related_links-INITIAL_FORMS': 0,
             'related_links-MIN_NUM_FORMS': 0,
             'related_links-MAX_NUM_FORMS': 0,
+            'head_counts-TOTAL_FORMS': 0,
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MIN_NUM_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 0,
         }
         response = self.client.post(
             reverse('wagtailadmin_pages:edit', args=(self.christmas_page.id, )),
@@ -4312,6 +4344,10 @@ def test_non_field_error(self):
             'related_links-INITIAL_FORMS': 0,
             'related_links-MIN_NUM_FORMS': 0,
             'related_links-MAX_NUM_FORMS': 0,
+            'head_counts-TOTAL_FORMS': 0,
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MIN_NUM_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 0,
         }
         response = self.client.post(
             reverse('wagtailadmin_pages:edit', args=(self.christmas_page.id, )),
@@ -4348,6 +4384,10 @@ def test_field_and_non_field_error(self):
             'related_links-INITIAL_FORMS': 0,
             'related_links-MIN_NUM_FORMS': 0,
             'related_links-MAX_NUM_FORMS': 0,
+            'head_counts-TOTAL_FORMS': 0,
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MIN_NUM_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 0,
         }
         response = self.client.post(
             reverse('wagtailadmin_pages:edit', args=(self.christmas_page.id, )),
@@ -4455,6 +4495,10 @@ def setUp(self):
             'related_links-INITIAL_FORMS': 0,
             'related_links-MIN_NUM_FORMS': 0,
             'related_links-MAX_NUM_FORMS': 0,
+            'head_counts-TOTAL_FORMS': 0,
+            'head_counts-INITIAL_FORMS': 0,
+            'head_counts-MIN_NUM_FORMS': 0,
+            'head_counts-MAX_NUM_FORMS': 0,
             'categories': [self.parties_category.id, self.holidays_category.id],
         }
 
diff --git a/wagtail/tests/testapp/migrations/0027_headcountrelatedmodelusingpk.py b/wagtail/tests/testapp/migrations/0027_headcountrelatedmodelusingpk.py
new file mode 100644
--- /dev/null
+++ b/wagtail/tests/testapp/migrations/0027_headcountrelatedmodelusingpk.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.6 on 2018-01-10 00:25
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+import modelcluster.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tests', '0026_auto_20171207_1657'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='HeadCountRelatedModelUsingPK',
+            fields=[
+                ('custom_id', models.AutoField(primary_key=True, serialize=False)),
+                ('head_count', models.IntegerField()),
+                ('event_page', modelcluster.fields.ParentalKey(on_delete=django.db.models.deletion.CASCADE, related_name='head_counts', to='tests.EventPage')),
+            ],
+        ),
+    ]
diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py
--- a/wagtail/tests/testapp/models.py
+++ b/wagtail/tests/testapp/models.py
@@ -287,6 +287,8 @@ class EventPage(Page):
     InlinePanel('speakers', label="Speakers", heading="Speaker lineup"),
     InlinePanel('related_links', label="Related links"),
     FieldPanel('categories'),
+    # InlinePanel related model uses `pk` not `id`
+    InlinePanel('head_counts', label='Head Counts'),
 ]
 
 EventPage.promote_panels = [
@@ -295,6 +297,18 @@ class EventPage(Page):
 ]
 
 
+class HeadCountRelatedModelUsingPK(models.Model):
+    """Related model that uses a custom primary key (pk) not id"""
+    custom_id = models.AutoField(primary_key=True)
+    event_page = ParentalKey(
+        EventPage,
+        on_delete=models.CASCADE,
+        related_name='head_counts'
+    )
+    head_count = models.IntegerField()
+    panels = [FieldPanel('head_count')]
+
+
 # Just to be able to test multi table inheritance
 class SingleEventPage(EventPage):
     excerpt = models.TextField(