Skip to content

Commit 2375bec

Browse files
authored
Updated new pagination feature. (#523)
1 parent 7590ec7 commit 2375bec

31 files changed

+392
-141
lines changed
File renamed without changes.

base/exceptions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
class IncorectLookupParameter(Exception):
2+
"""
3+
Raised when a query parameter contains an incorrect value.
4+
"""
5+
6+
pass

base/migrations/__init__.py

Whitespace-only changes.

base/pagination.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from django.core.paginator import InvalidPage, Paginator
2+
3+
from .exceptions import IncorectLookupParameter
4+
5+
PAGE_VAR = "page"
6+
7+
8+
class Pagination:
9+
def __init__(
10+
self,
11+
request,
12+
model,
13+
queryset,
14+
list_per_page,
15+
):
16+
self.model = model
17+
self.opts = model._meta
18+
self.queryset = queryset
19+
self.list_per_page = list_per_page
20+
try:
21+
# Get the current page from the query string.
22+
self.page_num = int(request.GET.get(PAGE_VAR, 1))
23+
except ValueError:
24+
self.page_num = 1
25+
self.params = dict(request.GET.lists())
26+
self.setup()
27+
28+
@property
29+
def page_range(self):
30+
"""
31+
Returns the full range of pages.
32+
"""
33+
return (
34+
self.paginator.get_elided_page_range(self.page_num)
35+
if self.multi_page
36+
else []
37+
)
38+
39+
def setup(self):
40+
paginator = Paginator(self.queryset, self.list_per_page)
41+
result_count = paginator.count
42+
# Determine use pagination.
43+
multi_page = result_count > self.list_per_page
44+
45+
self.result_count = result_count
46+
self.multi_page = multi_page
47+
self.paginator = paginator
48+
self.page = paginator.get_page(self.page_num)
49+
50+
def get_objects(self):
51+
if not self.multi_page:
52+
result_list = self.queryset._clone()
53+
else:
54+
try:
55+
result_list = self.paginator.page(self.page_num).object_list
56+
except InvalidPage:
57+
raise IncorectLookupParameter
58+
return result_list

base/templatetags/__init__.py

Whitespace-only changes.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
from collections.abc import Iterable, Mapping
2+
3+
from django import template
4+
from django.http import QueryDict
5+
from django.template.exceptions import TemplateSyntaxError
6+
7+
register = template.Library()
8+
9+
10+
# This template tag is scheduled to be added in Django 6.0.
11+
# Imported for use before the release of Django 6.0.
12+
@register.simple_tag(name="querystring", takes_context=True)
13+
def querystring(context, *args, **kwargs):
14+
"""
15+
Build a query string using `args` and `kwargs` arguments.
16+
17+
This tag constructs a new query string by adding, removing, or modifying
18+
parameters from the given positional and keyword arguments. Positional
19+
arguments must be mappings (such as `QueryDict` or `dict`), and
20+
`request.GET` is used as the starting point if `args` is empty.
21+
22+
Keyword arguments are treated as an extra, final mapping. These mappings
23+
are processed sequentially, with later arguments taking precedence.
24+
25+
A query string prefixed with `?` is returned.
26+
27+
Raise TemplateSyntaxError if a positional argument is not a mapping or if
28+
keys are not strings.
29+
30+
For example::
31+
32+
{# Set a parameter on top of `request.GET` #}
33+
{% querystring foo=3 %}
34+
35+
{# Remove a key from `request.GET` #}
36+
{% querystring foo=None %}
37+
38+
{# Use with pagination #}
39+
{% querystring page=page_obj.next_page_number %}
40+
41+
{# Use a custom ``QueryDict`` #}
42+
{% querystring my_query_dict foo=3 %}
43+
44+
{# Use multiple positional and keyword arguments #}
45+
{% querystring my_query_dict my_dict foo=3 bar=None %}
46+
"""
47+
if not args:
48+
args = [context.request.GET]
49+
params = QueryDict(mutable=True)
50+
for d in [*args, kwargs]:
51+
if not isinstance(d, Mapping):
52+
raise TemplateSyntaxError(
53+
"querystring requires mappings for positional arguments (got "
54+
"%r instead)." % d
55+
)
56+
for key, value in d.items():
57+
if not isinstance(key, str):
58+
raise TemplateSyntaxError(
59+
"querystring requires strings for mapping keys (got %r "
60+
"instead)." % key
61+
)
62+
if value is None:
63+
params.pop(key, None)
64+
elif isinstance(value, Iterable) and not isinstance(value, str):
65+
params.setlist(key, value)
66+
else:
67+
params[key] = value
68+
query_string = params.urlencode() if params else ""
69+
return f"?{query_string}"

base/templatetags/components.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from django import template
2+
from django.utils.html import format_html
3+
from django.utils.safestring import mark_safe
4+
5+
from base.pagination import PAGE_VAR
6+
7+
from .base_templatetags import querystring
8+
9+
register = template.Library()
10+
11+
12+
@register.simple_tag
13+
def pagination_number(pagination, i):
14+
"""
15+
Generate an individual page index link in a paginated list.
16+
"""
17+
if i == pagination.paginator.ELLIPSIS:
18+
return format_html("{} ", pagination.paginator.ELLIPSIS)
19+
elif i == pagination.page_num:
20+
return format_html('<em class="current-page" aria-current="page">{}</em> ', i)
21+
else:
22+
link = querystring(None, pagination.params, {PAGE_VAR: i})
23+
return format_html(
24+
'<a href="{}" aria-label="page {}" {}>{}</a> ',
25+
link,
26+
i,
27+
mark_safe(' class="end"' if i == pagination.paginator.num_pages else ""),
28+
i,
29+
)
30+
31+
32+
@register.inclusion_tag("base/components/pagination.html", name="pagination")
33+
def pagination_tag(pagination):
34+
previous_page_link = f"?{PAGE_VAR}={pagination.page_num - 1}"
35+
next_page_link = f"?{PAGE_VAR}={pagination.page_num + 1}"
36+
return {
37+
"pagination": pagination,
38+
"previous_page_link": previous_page_link,
39+
"next_page_link": next_page_link,
40+
}

base/tests/__init__.py

Whitespace-only changes.

base/tests/migrations/0002_fish.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 3.2.15 on 2025-06-04 05:19
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('tests', '0001_initial'),
10+
]
11+
12+
operations = [
13+
migrations.CreateModel(
14+
name='Fish',
15+
fields=[
16+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
17+
('name', models.CharField(max_length=255)),
18+
('price', models.IntegerField()),
19+
],
20+
),
21+
]

base/tests/migrations/__init__.py

Whitespace-only changes.

ratings/tests/models.py renamed to base/tests/models.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from django.db import models
22

3-
from ..models import RatedItemBase, Ratings
3+
from ratings.models import RatedItemBase, Ratings
4+
5+
6+
class Fish(models.Model):
7+
name = models.CharField(max_length=255)
8+
price = models.IntegerField()
49

510

611
class Food(models.Model):

base/tests/tests.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from django.test import RequestFactory, TestCase
2+
3+
from base.pagination import Pagination
4+
5+
from .models import Fish
6+
7+
8+
class PaginationTestCase(TestCase):
9+
@classmethod
10+
def setUpTestData(cls):
11+
fishs = [Fish(name=f"fish-{i}", price=i * 100) for i in range(1, 101)]
12+
Fish.objects.bulk_create(fishs)
13+
cls.queryset = Fish.objects.all()
14+
cls.factory = RequestFactory()
15+
16+
def test_pagination_attributes(self):
17+
request = self.factory.get("/fake-url/")
18+
pagination = Pagination(request, Fish, self.queryset, 5)
19+
self.assertEqual(pagination.result_count, 100)
20+
self.assertTrue(pagination.multi_page)
21+
pagination = Pagination(request, Fish, self.queryset, 200)
22+
self.assertFalse(pagination.multi_page)
23+
24+
def test_pagination_page_range(self):
25+
request = self.factory.get("/fake-url/")
26+
ELLIPSIS = "…"
27+
case = [
28+
(2, 6, [1, 2, 3, 4, 5, 6, 7, 8, 9, ELLIPSIS, 49, 50]),
29+
(3, 10, [1, 2, ELLIPSIS, 7, 8, 9, 10, 11, 12, 13, ELLIPSIS, 33, 34]),
30+
(4, 23, [1, 2, ELLIPSIS, 20, 21, 22, 23, 24, 25]),
31+
(5, 20, [1, 2, ELLIPSIS, 17, 18, 19, 20]),
32+
(10, 8, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
33+
(20, 1, [1, 2, 3, 4, 5]),
34+
]
35+
for list_per_page, current_page, expected_page_range in case:
36+
with self.subTest(list_per_page=list_per_page, current_page=current_page):
37+
pagination = Pagination(request, Fish, self.queryset, list_per_page)
38+
pagination.page_num = current_page
39+
self.assertEqual(list(pagination.page_range), expected_page_range)
40+
41+
def test_pagination_result_objects(self):
42+
request = self.factory.get("/fake-url/")
43+
case = [
44+
(2, 25, ["49", "50"]),
45+
(4, 12, ["45", "46", "47", "48"]),
46+
(5, 10, ["46", "47", "48", "49", "50"]),
47+
(7, 11, ["71", "72", "73", "74", "75", "76", "77"]),
48+
(10, 10, ["91", "92", "93", "94", "95", "96", "97", "98", "99", "100"]),
49+
(200, 1, [str(i) for i in range(1, 101)]),
50+
]
51+
Fish.objects.all().delete()
52+
fishs = [Fish(name=i, price=i * 100) for i in range(1, 101)]
53+
Fish.objects.bulk_create(fishs)
54+
queryset = Fish.objects.all().order_by("id")
55+
for list_per_page, current_page, expect_object_names in case:
56+
pagination = Pagination(request, Fish, queryset, list_per_page)
57+
pagination.page_num = current_page
58+
objects = pagination.get_objects()
59+
object_names = list(objects.values_list("name", flat=True))
60+
self.assertEqual(object_names, expect_object_names)

0 commit comments

Comments
 (0)