Repository: wagtail/wagtail

Issue:
Audit log AttributeError if page type has been removed
### Issue Summary
If a Page model is removed from project the audit log will crash when trying to display events related to this page type.

### Steps to Reproduce

1. Create a new page type
2. Create a page of this type, make edits
3. Delete the page
4. Remove the page type from code, migrate database
5. Navigate to Audit log

### Technical details

> Traceback (most recent call last):
>  File "/path/to/virtualenv/lib/python3.8/site-packages/django/core/handlers/exception.py", line 47, in inner
>     response = get_response(request)
>   File "/path/to/virtualenv/lib/python3.8/site-packages/django/core/handlers/base.py", line 181, in _get_response
>     response = wrapped_callback(request, *callback_args, **callback_kwargs)
>   File "/usr/lib/python3.8/contextlib.py", line 75, in inner
>     return func(*args, **kwds)
>   File "/path/to/virtualenv/lib/python3.8/site-packages/django/views/decorators/cache.py", line 44, in _wrapped_view_func
>     response = view_func(request, *args, **kwargs)
>   File "/path/to/virtualenv/lib/python3.8/site-packages/wagtail/admin/urls/__init__.py", line 125, in wrapper
>     return view_func(request, *args, **kwargs)
>   File "/path/to/virtualenv/lib/python3.8/site-packages/wagtail/admin/auth.py", line 174, in decorated_view
>     response = view_func(request, *args, **kwargs)
>   File "/path/to/virtualenv/lib/python3.8/site-packages/django/views/generic/base.py", line 70, in view
>     return self.dispatch(request, *args, **kwargs)
>   File "/path/to/virtualenv/lib/python3.8/site-packages/django/views/generic/base.py", line 98, in dispatch
>     return handler(request, *args, **kwargs)
>   File "/path/to/virtualenv/lib/python3.8/site-packages/wagtail/admin/views/reports/base.py", line 40, in get
>     self.object_list = self.decorate_paginated_queryset(self.object_list)
>   File "/path/to/virtualenv/lib/python3.8/site-packages/wagtail/admin/views/reports/audit_logging.py", line 174, in decorate_paginated_queryset
>     for log_entry, instance in log_entries:
>   File "/path/to/virtualenv/lib/python3.8/site-packages/wagtail/core/models/audit_log.py", line 66, in with_instances
>     model_instances = model.objects.in_bulk(object_ids)
> 
> Exception Type: AttributeError at /admin/reports/site-history/
> Exception Value: 'NoneType' object has no attribute 'objects'



Discussion:
I can confirm this on Wagtail 4.2, Django 4.1.7. Here's a simple test case:

1. `wagtail start myproject && cd myproject`
2. `./manage.py migrate && ./manage.py createsuperuser`
3. `./manage.py runserver`
4. Navigate to http://localhost:8000/admin/pages/3/edit/, logging in using the superuser you created.
5. Make an edit to the page, for example changing its title, and Save draft.
6. Navigate to the Site history report at http://localhost:8000/admin/reports/site-history/ and see your edit of the page, of type HomePage.
7. Stop your server, and delete home/models.py, removing the HomePage model.
8. `./manage.py makemigrations && ./manage.py migrate` to delete that model.
9. `./manage.py runserver` again and navigate back to http://localhost:8000/admin/reports/site-history/

You'll get the following error:

> AttributeError at /admin/reports/site-history/
> 'NoneType' object has no attribute 'objects'

This is because of these lines:

https://github.com/wagtail/wagtail/blob/ab05f7dd3c843d6af53c5ea0dbb67298c8541562/wagtail/models/audit_log.py#L68-L69

because `content_type_id` refers to a model which has been deleted. There are actually two possible issues here: if the stale content type for the deleted model is still in the database, you get the error above because the model class no longer exists. But if you also run `./manage.py remove_stale_contentypes` after migrating you'll get an alternate error: "ContentType matching query does not exist.", because there isn't even a ContentType to retrieve.

Theoretically we might be able to substitute the base Page model in cases like these, but that is tricky to generalize because LogEntry can log any models, and there isn't always a "base" model to substitute. The site history report also [assumes](https://github.com/wagtail/wagtail/blob/0187a1494bc56bab4592dc0d3d314121edf590d0/wagtail/admin/templates/wagtailadmin/reports/site_history.html#L37) that there's a valid content type to render; it could say "None" or "Removed" or something to inform the user that the log entry refers to a model that is no longer retrievable.

Resolving patch:
diff --git a/wagtail/coreutils.py b/wagtail/coreutils.py
--- a/wagtail/coreutils.py
+++ b/wagtail/coreutils.py
@@ -19,6 +19,7 @@
 from django.utils.encoding import force_str
 from django.utils.text import capfirst, slugify
 from django.utils.translation import check_for_language, get_supported_language_variant
+from django.utils.translation import gettext_lazy as _
 
 if TYPE_CHECKING:
     from wagtail.models import Site
@@ -156,6 +157,9 @@ def get_content_type_label(content_type):
     Return a human-readable label for a content type object, suitable for display in the admin
     in place of the default 'wagtailcore | page' representation
     """
+    if content_type is None:
+        return _("Unknown content type")
+
     model = content_type.model_class()
     if model:
         return str(capfirst(model._meta.verbose_name))
diff --git a/wagtail/models/audit_log.py b/wagtail/models/audit_log.py
--- a/wagtail/models/audit_log.py
+++ b/wagtail/models/audit_log.py
@@ -65,8 +65,19 @@ def with_instances(self):
             {}
         )  # lookup of (content_type_id, stringified_object_id) to instance
         for content_type_id, object_ids in ids_by_content_type.items():
-            model = ContentType.objects.get_for_id(content_type_id).model_class()
-            model_instances = model.objects.in_bulk(object_ids)
+            try:
+                content_type = ContentType.objects.get_for_id(content_type_id)
+                model = content_type.model_class()
+            except ContentType.DoesNotExist:
+                model = None
+
+            if model:
+                model_instances = model.objects.in_bulk(object_ids)
+            else:
+                # The model class for the logged instance no longer exists,
+                # so we have no instance to return. Return None instead.
+                model_instances = {object_id: None for object_id in object_ids}
+
             for object_id, instance in model_instances.items():
                 instances_by_id[(content_type_id, str(object_id))] = instance
 


Test patch:
diff --git a/wagtail/admin/tests/test_reports_views.py b/wagtail/admin/tests/test_reports_views.py
--- a/wagtail/admin/tests/test_reports_views.py
+++ b/wagtail/admin/tests/test_reports_views.py
@@ -4,6 +4,7 @@
 from django.conf import settings
 from django.conf.locale import LANG_INFO
 from django.contrib.auth.models import Permission
+from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test.utils import override_settings
 from django.urls import reverse
@@ -11,7 +12,7 @@
 from openpyxl import load_workbook
 
 from wagtail.admin.views.mixins import ExcelDateFormatter
-from wagtail.models import Page, PageLogEntry
+from wagtail.models import ModelLogEntry, Page, PageLogEntry
 from wagtail.test.utils import WagtailTestUtils
 
 
@@ -279,6 +280,34 @@ def test_hide_commenting_actions(self):
             ],
         )
 
+    def test_log_entry_with_stale_content_type(self):
+        stale_content_type = ContentType.objects.create(
+            app_label="fake_app", model="deleted model"
+        )
+
+        ModelLogEntry.objects.create(
+            object_id=123,
+            content_type=stale_content_type,
+            label="This instance's model was deleted, but its content type was not",
+            action="wagtail.create",
+            timestamp=timezone.now(),
+        )
+
+        response = self.get()
+        self.assertContains(response, "Deleted model")
+
+    def test_log_entry_with_null_content_type(self):
+        ModelLogEntry.objects.create(
+            object_id=123,
+            content_type=None,
+            label="This instance's model was deleted, and so was its content type",
+            action="wagtail.create",
+            timestamp=timezone.now(),
+        )
+
+        response = self.get()
+        self.assertContains(response, "Unknown content type")
+
 
 @override_settings(
     USE_L10N=True,
diff --git a/wagtail/tests/test_utils.py b/wagtail/tests/test_utils.py
--- a/wagtail/tests/test_utils.py
+++ b/wagtail/tests/test_utils.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*
 import pickle
 
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
 from django.test import SimpleTestCase, TestCase, override_settings
 from django.utils.text import slugify
@@ -14,6 +15,7 @@
     cautious_slugify,
     find_available_slug,
     get_content_languages,
+    get_content_type_label,
     get_dummy_request,
     get_supported_content_language_variant,
     multigetattr,
@@ -295,6 +297,21 @@ def test_must_be_subset_of_django_languages(self):
         )
 
 
+def TestGetContentTypeLabel(TestCase):
+    def test_none(self):
+        self.assertEqual(get_content_type_label(None), "Unknown content type")
+
+    def test_valid_content_type(self):
+        page_content_type = ContentType.objects.get_for_model(Page)
+        self.assertEqual(get_content_type_label(page_content_type), "Page")
+
+    def test_stale_content_type(self):
+        stale_content_type = ContentType.objects.create(
+            app_label="fake_app", model="deleted model"
+        )
+        self.assertEqual(get_content_type_label(stale_content_type), "Deleted model")
+
+
 @override_settings(
     USE_I18N=True,
     WAGTAIL_I18N_ENABLED=True,