Repository: wagtail/wagtail

Issue:
PageTypeField API serializer should gracefully handle missing page models
### Issue Summary

If a page model is deleted while pages of that type still exist, any API calls for page listings that include that page type will fail with `AttributeError: 'NoneType' object has no attribute '_meta'`. This results in a server error when using the explorer popout menu. (Related: #3569, which fixed the page explorer views for this case, but did not address the explorer menu.)

### Steps to Reproduce

From a new bakerydemo install, delete the `LocationPage` model and run makemigrations / migrate. Use the popout menu to navigate into the Locations section. This fails with "Server error" in the UI, and the following stack trace:

    Internal Server Error: /admin/api/v2beta/pages/
    Traceback (most recent call last):
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/django/core/handlers/exception.py", line 35, in inner
        response = get_response(request)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/django/core/handlers/base.py", line 128, in _get_response
        response = self.process_exception_by_middleware(e, request)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/django/core/handlers/base.py", line 126, in _get_response
        response = wrapped_callback(request, *callback_args, **callback_kwargs)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/django/views/decorators/cache.py", line 31, in _cache_controlled
        response = viewfunc(request, *args, **kw)
      File "/vagrant/wagtail/wagtail/admin/urls/__init__.py", line 102, in wrapper
        return view_func(request, *args, **kwargs)
      File "/vagrant/wagtail/wagtail/admin/decorators.py", line 34, in decorated_view
        return view_func(request, *args, **kwargs)
      File "/vagrant/wagtail/wagtail/api/v2/router.py", line 63, in wrapped
        return func(request, *args, **kwargs)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/django/views/decorators/csrf.py", line 54, in wrapped_view
        return view_func(*args, **kwargs)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/rest_framework/viewsets.py", line 95, in view
        return self.dispatch(request, *args, **kwargs)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/rest_framework/views.py", line 494, in dispatch
        response = self.handle_exception(exc)
      File "/vagrant/wagtail/wagtail/api/v2/endpoints.py", line 116, in handle_exception
        return super().handle_exception(exc)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/rest_framework/views.py", line 454, in handle_exception
        self.raise_uncaught_exception(exc)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/rest_framework/views.py", line 491, in dispatch
        response = handler(request, *args, **kwargs)
      File "/vagrant/wagtail/wagtail/admin/api/endpoints.py", line 94, in listing_view
        response = super().listing_view(request)
      File "/vagrant/wagtail/wagtail/api/v2/endpoints.py", line 74, in listing_view
        return self.get_paginated_response(serializer.data)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/rest_framework/serializers.py", line 742, in data
        ret = super(ListSerializer, self).data
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/rest_framework/serializers.py", line 262, in data
        self._data = self.to_representation(self.instance)
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/rest_framework/serializers.py", line 660, in to_representation
        self.child.to_representation(item) for item in iterable
      File "/home/vagrant/.virtualenvs/bakerydemo/lib/python3.4/site-packages/rest_framework/serializers.py", line 660, in <listcomp>
        self.child.to_representation(item) for item in iterable
      File "/vagrant/wagtail/wagtail/api/v2/serializers.py", line 275, in to_representation
        meta[field.field_name] = field.to_representation(attribute)
      File "/vagrant/wagtail/wagtail/api/v2/serializers.py", line 82, in to_representation
        name = page.specific_class._meta.app_label + '.' + page.specific_class.__name__
    AttributeError: 'NoneType' object has no attribute '_meta'

The error is in `PageTypeField.to_representation`, which accesses properties of `page.specific_class` without checking for `page.specific_class` being `None`. It would make sense for `PageTypeField.to_representation` to return `None` in this case.

### Technical details

* Python version: 3.4.3
* Django version: 2.0.4
* Wagtail version: 2.2 dev



Discussion:

Resolving patch:
diff --git a/wagtail/api/v2/serializers.py b/wagtail/api/v2/serializers.py
--- a/wagtail/api/v2/serializers.py
+++ b/wagtail/api/v2/serializers.py
@@ -79,6 +79,8 @@ def get_attribute(self, instance):
         return instance
 
     def to_representation(self, page):
+        if page.specific_class is None:
+            return None
         name = page.specific_class._meta.app_label + '.' + page.specific_class.__name__
         self.context['view'].seen_types[name] = page.specific_class
         return name


Test patch:
diff --git a/wagtail/api/v2/tests/test_pages.py b/wagtail/api/v2/tests/test_pages.py
--- a/wagtail/api/v2/tests/test_pages.py
+++ b/wagtail/api/v2/tests/test_pages.py
@@ -1,7 +1,8 @@
 import collections
 import json
-
 import mock
+
+from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.test.utils import override_settings
 from django.urls import reverse
@@ -80,6 +81,17 @@ def test_private_pages_dont_appear_in_list(self):
         content = json.loads(response.content.decode('UTF-8'))
         self.assertEqual(content['meta']['total_count'], new_total_count)
 
+    def test_page_listing_with_missing_page_model(self):
+        # Create a ContentType that doesn't correspond to a real model
+        missing_page_content_type = ContentType.objects.create(app_label='tests', model='missingpage')
+
+        # Turn a BlogEntryPage into this content_type
+        models.BlogEntryPage.objects.filter(id=16).update(content_type=missing_page_content_type)
+
+        # get page listing with missing model
+        response = self.get_response()
+        self.assertEqual(response.status_code, 200)
+
     # TYPE FILTER
 
     def test_type_filter_items_are_all_blog_entries(self):
@@ -905,6 +917,17 @@ def test_null_foreign_key(self):
         self.assertIn('related_links', content)
         self.assertEqual(content['feed_image'], None)
 
+    def test_page_with_missing_page_model(self):
+        # Create a ContentType that doesn't correspond to a real model
+        missing_page_content_type = ContentType.objects.create(app_label='tests', model='missingpage')
+
+        # Turn a BlogEntryPage into this content_type
+        models.BlogEntryPage.objects.filter(id=16).update(content_type=missing_page_content_type)
+
+        # get missing model page
+        response = self.get_response(16)
+        self.assertEqual(response.status_code, 200)
+
     # FIELDS
 
     def test_remove_fields(self):