Browse Source

Pagination boilerplate. #256 #307

Rafał Pitoń 11 years ago
parent
commit
dcf7ff2c48

+ 25 - 0
docs/developers/shortcuts.rst

@@ -7,6 +7,31 @@ Just like `Django <https://docs.djangoproject.com/en/dev/topics/http/shortcuts/>
 This module lives in :py:mod:`misago.views.shortcuts` and in addition to Misago-native shortcut functions, it imports whole of :py:mod:`django.shortcuts`, so you don't have to import it separately in your views.
 
 
+paginate
+-------------
+
+.. function:: paginate(object_list, page, per_page, orphans=0, allow_empty_first_page=True)
+
+This function is a factory that validates received data and returns `Django's Page <https://docs.djangoproject.com/en/dev/topics/pagination/#page-objects>`_ object. In adition it also translates ``EmptyPage`` errors into ``Http404`` errors and validated is first page number was explictly defined in url parameter, or not.
+
+``paginate`` function has certain requirements on handling views that use it. Firstly, views with pagination should have two links instead of one::
+
+    # inside blog.urls.py
+    urlpatterns += patterns('blog.views',
+        url(r'^/$', 'index', name='index'),
+        url(r'^/(?P<page>[1-9][0-9]*)/$', 'index', name='index'),
+    )
+
+    # inside blog.views.py
+    def index(request, page=None):
+    	# your view that calls paginate()
+
+.. warning::
+   Giving ``page`` argument default value of 1 will make ``paginate`` function assume that first page was reached via link with explicit first page number and cause redirect loop.
+
+   Error handler expects link parameter that contains current page number to be named "page". Otherwise it will fail to create new link and raise ``KeyError``.
+
+
 validate_slug
 -------------
 

+ 23 - 6
docs/developers/views_errors.rst

@@ -13,16 +13,33 @@ To solve this problem you would have to write custom error views and handlers th
 Misago views too have to solve this problem and this reason is why error handling boilerplate is part of framework.
 
 
-OutdatedUrl
-===========
+Views Exceptions
+================
 
-:py:class:`misago.core.exceptions.OutdatedUrl`
 
-OutdatedUrl exception is special "message" that tells Misago to return `permanent <http://en.wikipedia.org/wiki/HTTP_301>`_ redirection as response instead of intended view.
+While Misago raises plenty of exceptions, only four are allowed to reach views. Two of those are django's ``Http404`` and ``PermissionDenied`` exceptions. In addition to those, Misago defines its own two exceptions that act as "messages" for it's error handler that link user clicked to get to view is not up-to-date and could use 301 request to make sure bookmarks and crawlers get clean link.
 
-This exception is raised by view utility that compares link's "slug" part against one from database. If check fails OutdatedUrl exception is raised with parameter name and valid slug as message that Misago's exception handler then uses to construct redirection response to valid link.
+.. note::
+   You should never raise those exceptions yourself. If you want to redirect user to certain page, return proper redirect response instead.
 
-You should never raise this exception yourself, instead always return proper redirect response from your code.
+
+ExplicitFirstPage
+-----------------
+
+:py:class:`misago.core.exceptions.ExplicitFirstPage`
+
+This exception is raised by :py:func:`misago.core.shortcuts.paginate` helper function that creates pagination for given data, page number and configuration. If first page is explicit ("eg. somewhere/1/") instead implicit ("somewhere/"), this exception is raised for error handler to return redirect to link with implicit first page.
+
+.. warning::
+   This is reason why Misago views pass this function ``None`` as page number when no page was passed through link.
+
+
+OutdatedSlug
+------------
+
+:py:class:`misago.core.exceptions.OutdatedSlug`
+
+This exception is raised by :py:func:`misago.core.shortcuts.validate_slug` helper function that compares link's "slug" part against one from database. If check fails OutdatedSlug exception is raised with parameter name and valid slug as message that Misago's exception handler then uses to construct redirection response to valid link.
 
 
 Exception Handler

+ 16 - 2
misago/core/exceptionhandler.py

@@ -2,16 +2,29 @@ from django.core.exceptions import PermissionDenied
 from django.core.urlresolvers import reverse
 from django.http import Http404, HttpResponsePermanentRedirect
 from misago.core import errorpages
-from misago.core.exceptions import OutdatedSlug
+from misago.core.exceptions import ExplicitFirstPage, OutdatedSlug
 
 
-HANDLED_EXCEPTIONS = (Http404, OutdatedSlug, PermissionDenied,)
+HANDLED_EXCEPTIONS = (ExplicitFirstPage, Http404,
+                      OutdatedSlug, PermissionDenied,)
 
 
 def is_misago_exception(exception):
     return exception.__class__ in HANDLED_EXCEPTIONS
 
 
+def handle_explicit_first_page_exception(request, exception):
+    matched_url = request.resolver_match.url_name
+    if request.resolver_match.namespace:
+        matched_url = '%s:%s' % (request.resolver_match, matched_url)
+
+    url_kwargs = request.resolver_match.kwargs
+    del url_kwargs['page']
+
+    new_url = reverse(matched_url, kwargs=url_kwargs)
+    return HttpResponsePermanentRedirect(new_url)
+
+
 def handle_http404_exception(request, exception):
     return errorpages.page_not_found(request)
 
@@ -41,6 +54,7 @@ def handle_permission_denied_exception(request, exception):
 
 EXCEPTION_HANDLERS = (
     (Http404, handle_http404_exception),
+    (ExplicitFirstPage, handle_explicit_first_page_exception),
     (OutdatedSlug, handle_outdated_slug_exception),
     (PermissionDenied, handle_permission_denied_exception),
 )

+ 5 - 0
misago/core/exceptions.py

@@ -1,3 +1,8 @@
+class ExplicitFirstPage(Exception):
+    """The url that was used to reach view contained explicit first page"""
+    pass
+
+
 class OutdatedSlug(Exception):
     """The url that was used to reach view contained outdated slug"""
     pass

+ 20 - 1
misago/core/shortcuts.py

@@ -1,7 +1,26 @@
 from django.shortcuts import *
-from misago.core.exceptions import OutdatedSlug
+
+
+def paginate(object_list, page, per_page, orphans=0,
+             allow_empty_first_page=True):
+    from django.http import Http404
+    from django.core.paginator import Paginator, EmptyPage
+    from misago.core.exceptions import ExplicitFirstPage
+
+    if page in (1, "1"):
+        raise ExplicitFirstPage()
+    elif not page:
+        page = 1
+
+    try:
+        return Paginator(
+            object_list, per_page, orphans=orphans,
+            allow_empty_first_page=allow_empty_first_page).page(page)
+    except EmptyPage:
+        raise Http404()
 
 
 def validate_slug(model, slug):
+    from misago.core.exceptions import OutdatedSlug
     if model.slug != slug:
         raise OutdatedSlug(model)

+ 3 - 1
misago/core/testproject/urls.py

@@ -5,9 +5,11 @@ urlpatterns = patterns('',
 )
 
 urlpatterns += patterns('misago.core.testproject.views',
+    url(r'^forum/test-pagination/$', 'test_pagination', name='test_pagination'),
+    url(r'^forum/test-pagination/(?P<page>[1-9][0-9]*)/$', 'test_pagination', name='test_pagination'),
+    url(r'^forum/test-valid-slug/(?P<model_slug>[a-z0-9\-]+)-(?P<model_id>\d+)/$', 'validate_slug_view', name='validate_slug_view'),
     url(r'^forum/test-403/$', 'raise_misago_403', name='raise_misago_403'),
     url(r'^forum/test-404/$', 'raise_misago_404', name='raise_misago_404'),
-    url(r'^forum/test-valid-slug/(?P<model_slug>[a-z0-9\-]+)-(?P<model_id>\d+)/$', 'validate_slug_view', name='validate_slug_view'),
     url(r'^test-403/$', 'raise_403', name='raise_403'),
     url(r'^test-404/$', 'raise_404', name='raise_404'),
 )

+ 13 - 7
misago/core/testproject/views.py

@@ -1,10 +1,22 @@
 from django.core.exceptions import PermissionDenied
 from django.http import Http404, HttpResponse
 from misago.core import errorpages
-from misago.core.shortcuts import validate_slug
+from misago.core.shortcuts import paginate, validate_slug
 from misago.core.testproject.models import Model
 
 
+def test_pagination(request, page=None):
+    items = range(15)
+    page = paginate(items, page, 5)
+    return HttpResponse(",".join([str(x) for x in page.object_list]))
+
+
+def validate_slug_view(request, model_id, model_slug):
+    model = Model(int(model_id), 'eric-the-fish')
+    validate_slug(model, model_slug)
+    return HttpResponse("Allright!")
+
+
 def raise_misago_403(request):
     raise PermissionDenied('Misago 403')
 
@@ -21,12 +33,6 @@ def raise_404(request):
     raise Http404()
 
 
-def validate_slug_view(request, model_id, model_slug):
-    model = Model(int(model_id), 'eric-the-fish')
-    validate_slug(model, model_slug)
-    return HttpResponse("Allright!")
-
-
 @errorpages.shared_403_exception_handler
 def mock_custom_403_error_page(request):
     return HttpResponse("Custom 403", status=403)

+ 0 - 4
misago/core/tests/test_exceptionhandler.py

@@ -1,9 +1,5 @@
-from django.http import Http404
 from django.core import exceptions as django_exceptions
-from django.core.exceptions import PermissionDenied
 from django.test import TestCase
-from django.test.client import RequestFactory
-from misago.core.exceptions import OutdatedSlug
 from misago.core import exceptionhandler
 
 

+ 33 - 36
misago/core/tests/test_shortcuts.py

@@ -1,51 +1,48 @@
 from django.core.urlresolvers import reverse
 from django.test import TestCase
-from misago.core.shortcuts import validate_slug, OutdatedSlug
-from misago.core.testproject.models import Model
+
+
+class PaginateTests(TestCase):
+    urls = 'misago.core.testproject.urls'
+
+    def test_valid_page_handling(self):
+        """Valid page number causes no errors"""
+        response = self.client.get(
+            reverse('test_pagination', kwargs={'page': 2}))
+        self.assertEqual("5,6,7,8,9", response.content)
+
+    def test_invalid_page_handling(self):
+        """Invalid page number results in 404 error"""
+        response = self.client.get(
+            reverse('test_pagination', kwargs={'page': 42}))
+        self.assertEqual(response.status_code, 404)
+
+    def test_implicit_page_handling(self):
+        """Implicit page number causes no errors"""
+        response = self.client.get(
+            reverse('test_pagination'))
+        self.assertEqual("0,1,2,3,4", response.content)
+
+    def test_explicit_page_handling(self):
+        """Explicit page number results in redirect"""
+        response = self.client.get(
+            reverse('test_pagination', kwargs={'page': 1}))
+        valid_url = "http://testserver/forum/test-pagination/"
+        self.assertEqual(response['Location'], valid_url)
 
 
 class ValidateSlugTests(TestCase):
-    def test_is_outdated_slug_exception_not_raised_for_valid_slug(self):
-        """
-        check_object_slug doesn't raise OutdatedSlug when slugs match
-        """
-        model = Model(1, "test-slug")
-        validate_slug(model, "test-slug")
-
-    def test_is_outdated_slug_exception_raised_for_invalid_slug(self):
-        """
-        check_object_slug raises OutdatedSlug when slugs mismatch
-        """
-        model = Model(1, "test-slug")
-
-        with self.assertRaises(OutdatedSlug):
-            validate_slug(model, "wrong-slug")
-
-    def test_is_outdated_slug_exception_raised_with_valid_message(self):
-        """
-        check_object_slug raises OutdatedSlug with valid message
-        """
-        correct_slug = "test-slug"
-        model = Model(1, correct_slug)
-
-        try:
-            validate_slug(model, "wrong-slug")
-        except OutdatedSlug as e:
-            self.assertEqual(model, e.args[0])
-
-
-class CheckSlugHandler(TestCase):
     urls = 'misago.core.testproject.urls'
 
-    def test_valid_slug_handle(self):
-        """valid slug causes no interruption in view processing"""
+    def test_valid_slug_handling(self):
+        """Valid slug causes no interruption in view processing"""
         test_kwargs = {'model_slug': 'eric-the-fish', 'model_id': 1}
         response = self.client.get(
             reverse('validate_slug_view', kwargs=test_kwargs))
         self.assertIn("Allright", response.content)
 
-    def test_invalid_slug_handle(self):
-        """invalid slug returns in redirect to valid page"""
+    def test_invalid_slug_handling(self):
+        """Invalid slug returns in redirect to valid page"""
         test_kwargs = {'model_slug': 'lion-the-eric', 'model_id': 1}
         response = self.client.get(
             reverse('validate_slug_view', kwargs=test_kwargs))