Repository: wagtail/wagtail

Issue:
wagtail.admin.views.generic.EditView uses object.id which is not present in every object.
### Issue Summary

As mentioned in https://github.com/wagtail/wagtail/pull/9204#discussion_r971114461, `wagtail.admin.views.generic.EditView` uses `object.id` in order to refer to an URL. However, not every object has the `id` field.

### Steps to Reproduce

1. Use `wagtail.admin.views.generic.EditView` instead of `wagtail.users.views.users.create`.
2. Runtests using `./runtests.py wagtail.users.tests.test_admin_views`.
3. As the testrunner reaches the `get_delete_url`, it throws the following error:
> AttributeError: 'CustomUser' object has no attribute 'id'

**Note: The same issue exists with `get_success_buttons` in addition to `get_delete_url`.**

### Technical details

-   Wagtail version: 4.0a0.



Discussion:

Resolving patch:
diff --git a/wagtail/admin/views/generic/models.py b/wagtail/admin/views/generic/models.py
--- a/wagtail/admin/views/generic/models.py
+++ b/wagtail/admin/views/generic/models.py
@@ -412,7 +412,7 @@ def get_success_message(self, instance):
     def get_success_buttons(self):
         return [
             messages.button(
-                reverse(self.edit_url_name, args=(self.object.id,)), _("Edit")
+                reverse(self.edit_url_name, args=(quote(self.object.pk),)), _("Edit")
             )
         ]
 
@@ -543,6 +543,7 @@ def get_action(self, request):
     def get_object(self, queryset=None):
         if "pk" not in self.kwargs:
             self.kwargs["pk"] = self.args[0]
+        self.kwargs["pk"] = unquote(str(self.kwargs["pk"]))
         self.live_object = super().get_object(queryset)
 
         # Cannot use self.draftstate_enabled here as there are subclasses
@@ -560,11 +561,11 @@ def get_edit_url(self):
                 "Subclasses of wagtail.admin.views.generic.models.EditView must provide an "
                 "edit_url_name attribute or a get_edit_url method"
             )
-        return reverse(self.edit_url_name, args=(self.object.id,))
+        return reverse(self.edit_url_name, args=(quote(self.object.pk),))
 
     def get_delete_url(self):
         if self.delete_url_name:
-            return reverse(self.delete_url_name, args=(self.object.id,))
+            return reverse(self.delete_url_name, args=(quote(self.object.pk),))
 
     def get_success_url(self):
         if not self.index_url_name:
@@ -640,7 +641,7 @@ def get_success_message(self):
     def get_success_buttons(self):
         return [
             messages.button(
-                reverse(self.edit_url_name, args=(self.object.id,)), _("Edit")
+                reverse(self.edit_url_name, args=(quote(self.object.pk),)), _("Edit")
             )
         ]
 
@@ -734,6 +735,7 @@ class DeleteView(
     def get_object(self, queryset=None):
         if "pk" not in self.kwargs:
             self.kwargs["pk"] = self.args[0]
+        self.kwargs["pk"] = unquote(str(self.kwargs["pk"]))
         return super().get_object(queryset)
 
     def get_success_url(self):
@@ -753,7 +755,7 @@ def get_delete_url(self):
                 "Subclasses of wagtail.admin.views.generic.models.DeleteView must provide a "
                 "delete_url_name attribute or a get_delete_url method"
             )
-        return reverse(self.delete_url_name, args=(self.object.id,))
+        return reverse(self.delete_url_name, args=(quote(self.object.pk),))
 
     def get_success_message(self):
         if self.success_message is None:
diff --git a/wagtail/users/views/users.py b/wagtail/users/views/users.py
--- a/wagtail/users/views/users.py
+++ b/wagtail/users/views/users.py
@@ -8,7 +8,6 @@
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy
 
-from wagtail.admin import messages
 from wagtail.admin.views.generic import CreateView, DeleteView, EditView, IndexView
 from wagtail.compat import AUTH_USER_APP_LABEL, AUTH_USER_MODEL_NAME
 from wagtail.permission_policies import ModelPermissionPolicy
@@ -166,13 +165,6 @@ def run_after_hook(self):
     def get_add_url(self):
         return None
 
-    def get_success_buttons(self):
-        return [
-            messages.button(
-                reverse(self.edit_url_name, args=(self.object.pk,)), _("Edit")
-            )
-        ]
-
 
 class Edit(EditView):
     """
@@ -232,13 +224,6 @@ def run_after_hook(self):
             self.object,
         )
 
-    def get_success_buttons(self):
-        return [
-            messages.button(
-                reverse(self.edit_url_name, args=(self.object.pk,)), _("Edit")
-            )
-        ]
-
     def get_edit_url(self):
         return reverse(self.edit_url_name, args=(self.object.pk,))
 


Test patch:
diff --git a/wagtail/admin/tests/test_views.py b/wagtail/admin/tests/test_views.py
--- a/wagtail/admin/tests/test_views.py
+++ b/wagtail/admin/tests/test_views.py
@@ -1,3 +1,4 @@
+from django.contrib.admin.utils import quote
 from django.test import TestCase, override_settings
 from django.urls import reverse
 
@@ -124,3 +125,52 @@ def test_password_reset_page_renders_extra_fields(self):
         self.assertContains(
             response, '<input type="text" name="captcha" required id="id_captcha">'
         )
+
+
+class TestGenericIndexView(TestCase, WagtailTestUtils):
+
+    fixtures = ["test.json"]
+
+    def test_with_non_integer_primary_key(self):
+        response = self.client.get(reverse("testapp_generic_index"))
+        self.assertEqual(response.status_code, 200)
+        response_object_count = response.context_data["object_list"].count()
+        self.assertEqual(response_object_count, 3)
+        self.assertContains(response, "first modelwithstringtypeprimarykey model")
+        self.assertContains(response, "second modelwithstringtypeprimarykey model")
+
+
+class TestGenericEditView(TestCase, WagtailTestUtils):
+
+    fixtures = ["test.json"]
+
+    def test_with_non_integer_primary_key(self):
+        object_pk = "string-pk-2"
+        response = self.client.get(reverse("testapp_generic_edit", args=(object_pk,)))
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "second modelwithstringtypeprimarykey model")
+
+    def test_with_non_url_safe_primary_key(self):
+        object_pk = 'string-pk-:#?;@&=+$,"[]<>%'
+        object_pk = quote(object_pk)
+        response = self.client.get(reverse("testapp_generic_edit", args=(object_pk,)))
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(
+            response, "non-url-safe pk modelwithstringtypeprimarykey model"
+        )
+
+
+class TestGenericDeleteView(TestCase, WagtailTestUtils):
+
+    fixtures = ["test.json"]
+
+    def test_with_non_integer_primary_key(self):
+        object_pk = "string-pk-2"
+        response = self.client.get(reverse("testapp_generic_delete", args=(object_pk,)))
+        self.assertEqual(response.status_code, 200)
+
+    def test_with_non_url_safe_primary_key(self):
+        object_pk = 'string-pk-:#?;@&=+$,"[]<>%'
+        object_pk = quote(object_pk)
+        response = self.client.get(reverse("testapp_generic_delete", args=(object_pk,)))
+        self.assertEqual(response.status_code, 200)
diff --git a/wagtail/test/testapp/fixtures/test.json b/wagtail/test/testapp/fixtures/test.json
--- a/wagtail/test/testapp/fixtures/test.json
+++ b/wagtail/test/testapp/fixtures/test.json
@@ -979,5 +979,26 @@
       "text": "test_advert",
       "url": "http://www.example.com"
     }
+  },
+  {
+    "pk": "string-pk-1",
+    "model": "tests.modelwithstringtypeprimarykey",
+    "fields": {
+      "content": "first modelwithstringtypeprimarykey model"
+    }
+  },
+  {
+    "pk": "string-pk-2",
+    "model": "tests.modelwithstringtypeprimarykey",
+    "fields": {
+      "content": "second modelwithstringtypeprimarykey model"
+    }
+  },
+  {
+    "pk": "string-pk-:#?;@&=+$,\"[]<>%",
+    "model": "tests.modelwithstringtypeprimarykey",
+    "fields": {
+      "content": "non-url-safe pk modelwithstringtypeprimarykey model"
+    }
   }
 ]
diff --git a/wagtail/test/testapp/migrations/0008_modelwithstringtypeprimarykey.py b/wagtail/test/testapp/migrations/0008_modelwithstringtypeprimarykey.py
new file mode 100644
--- /dev/null
+++ b/wagtail/test/testapp/migrations/0008_modelwithstringtypeprimarykey.py
@@ -0,0 +1,23 @@
+# Generated by Django 4.0.7 on 2022-09-28 15:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("tests", "0007_draftstatecustomprimarykeymodel"),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name="ModelWithStringTypePrimaryKey",
+            fields=[
+                (
+                    "custom_id",
+                    models.CharField(max_length=255, primary_key=True, serialize=False),
+                ),
+                ("content", models.CharField(max_length=255)),
+            ],
+        ),
+    ]
diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py
--- a/wagtail/test/testapp/models.py
+++ b/wagtail/test/testapp/models.py
@@ -1960,3 +1960,12 @@ class CustomRestaurantImage(AbstractImage):
         help_text=None, blank=True, verbose_name="tags", through=TaggedRestaurantImage
     )
     admin_form_fields = Image.admin_form_fields
+
+
+class ModelWithStringTypePrimaryKey(models.Model):
+    """
+    This model intentionally uses `CharField` as a primary key for testing purpose.
+    """
+
+    custom_id = models.CharField(max_length=255, primary_key=True)
+    content = models.CharField(max_length=255)
diff --git a/wagtail/test/testapp/templates/tests/generic_view_templates/delete.html b/wagtail/test/testapp/templates/tests/generic_view_templates/delete.html
new file mode 100644
--- /dev/null
+++ b/wagtail/test/testapp/templates/tests/generic_view_templates/delete.html
@@ -0,0 +1,11 @@
+{% extends "wagtailadmin/generic/base.html" %}
+
+{% block content %}
+    <div class="nice-padding">
+        <p>Are you sure you want to delete this object?</p>
+        <form action="{% url 'testapp_generic_delete' test_object.pk %}" method="POST">
+            {% csrf_token %}
+            <input type="submit" value="Yes, delete" class="button serious" />
+        </form>
+    </div>
+{% endblock %}
diff --git a/wagtail/test/testapp/templates/tests/generic_view_templates/edit.html b/wagtail/test/testapp/templates/tests/generic_view_templates/edit.html
new file mode 100644
--- /dev/null
+++ b/wagtail/test/testapp/templates/tests/generic_view_templates/edit.html
@@ -0,0 +1,17 @@
+{% extends "wagtailadmin/generic/base.html" %}
+
+{% block content %}
+
+    <form action="{% url 'testapp_generic_edit' test_object.pk %}" method="POST" >
+        <div class="tab-content">
+            {% csrf_token %}
+            <ul class="fields">
+                {% include "wagtailadmin/shared/field_as_li.html" with field=form.content %}
+                <li>
+                    <input type="submit" value="Save" class="button"/>
+                </li>
+            </ul>
+        </div>
+    </form>
+
+{% endblock %}
diff --git a/wagtail/test/testapp/templates/tests/generic_view_templates/index.html b/wagtail/test/testapp/templates/tests/generic_view_templates/index.html
new file mode 100644
--- /dev/null
+++ b/wagtail/test/testapp/templates/tests/generic_view_templates/index.html
@@ -0,0 +1,7 @@
+{% extends "wagtailadmin/generic/base.html" %}
+
+{% block content %}
+    {% for obj in test_object %}
+        <p>{{ obj.content }}</p>
+    {% endfor %}
+{% endblock %}
diff --git a/wagtail/test/testapp/urls.py b/wagtail/test/testapp/urls.py
--- a/wagtail/test/testapp/urls.py
+++ b/wagtail/test/testapp/urls.py
@@ -1,8 +1,17 @@
 from django.urls import path
 
-from wagtail.test.testapp.views import bob_only_zone, message_test
+from wagtail.test.testapp import views
 
 urlpatterns = [
-    path("bob-only-zone", bob_only_zone, name="testapp_bob_only_zone"),
-    path("messages/", message_test, name="testapp_message_test"),
+    path("bob-only-zone", views.bob_only_zone, name="testapp_bob_only_zone"),
+    path("messages/", views.message_test, name="testapp_message_test"),
+    path("test-index/", views.TestIndexView.as_view(), name="testapp_generic_index"),
+    path(
+        "test-edit/<str:pk>/", views.TestEditView.as_view(), name="testapp_generic_edit"
+    ),
+    path(
+        "test-delete/<str:pk>/",
+        views.TestDeleteView.as_view(),
+        name="testapp_generic_delete",
+    ),
 ]
diff --git a/wagtail/test/testapp/views.py b/wagtail/test/testapp/views.py
--- a/wagtail/test/testapp/views.py
+++ b/wagtail/test/testapp/views.py
@@ -1,11 +1,16 @@
+from django import forms
 from django.http import HttpResponse
 from django.shortcuts import redirect
 from django.template.response import TemplateResponse
+from django.utils.translation import gettext_lazy
 
 from wagtail.admin import messages
 from wagtail.admin.auth import user_passes_test
+from wagtail.admin.views.generic import DeleteView, EditView, IndexView
 from wagtail.contrib.forms.views import SubmissionsListView
 
+from .models import ModelWithStringTypePrimaryKey
+
 
 def user_is_called_bob(user):
     return user.first_name == "Bob"
@@ -34,3 +39,45 @@ def get_csv_filename(self):
         """Returns the filename for CSV file with page title at start"""
         filename = super().get_csv_filename()
         return self.form_page.slug + "-" + filename
+
+
+class TestIndexView(IndexView):
+
+    model = ModelWithStringTypePrimaryKey
+    index_url_name = "testapp_generic_index"
+    template_name = "tests/generic_view_templates/index.html"
+    paginate_by = 20
+    context_object_name = "test_object"
+    page_title = gettext_lazy("test index view")
+
+
+class CustomModelEditForm(forms.ModelForm):
+    class Meta:
+        model = ModelWithStringTypePrimaryKey
+        fields = ("content",)
+
+
+class TestEditView(EditView):
+
+    model = ModelWithStringTypePrimaryKey
+    context_object_name = "test_object"
+    template_name = "tests/generic_view_templates/edit.html"
+    index_url_name = "testapp_generic_index"
+    success_url = "testapp_generic_index"
+    edit_url_name = "testapp_generic_edit"
+    delete_url_name = "testapp_generic_delete"
+    form_class = CustomModelEditForm
+    success_message = "User '{0}' updated."
+    page_title = gettext_lazy("test edit view")
+
+
+class TestDeleteView(DeleteView):
+
+    model = ModelWithStringTypePrimaryKey
+    context_object_name = "test_object"
+    template_name = "tests/generic_view_templates/delete.html"
+    index_url_name = "testapp_generic_index"
+    edit_url_name = "testapp_generic_edit"
+    delete_url_name = "testapp_generic_delete"
+    success_message = "User '{0}' updated."
+    page_title = gettext_lazy("test delete view")