Skip to content

Commit af48c9b

Browse files
manderjstephane
authored andcommitted
templatetags.sorting_tags: add sorting by nulls first or last
Adding nulls_first=True or nulls_last=True to autosort templatetags (or sort_queryset in case of jinja templates) will sort the null values respectively on first and last places.
1 parent ca683dd commit af48c9b

File tree

10 files changed

+213
-32
lines changed

10 files changed

+213
-32
lines changed

CONTRIBUTORS.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ Karim A. (Directeur) <directeur@gmail.com>
22
Eric Florenzano <floguy@gmail.com>
33
Stéphane Raimbault <stephane.raimbault@webstack.fr>
44
S. Kossouho <artscoop93@gmail.com>
5+
Joffrey M. <joffrey.mander@polyconseil.fr>

testproj/testproj/testapp/fixtures/secretfiles.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,16 @@
4242
"is_secret": false,
4343
"filename": "0-w00t.crt"
4444
}
45+
},
46+
{
47+
"pk": 5,
48+
"model": "testapp.secretfile",
49+
"fields": {
50+
"created_on":"2014-01-09T23:01:30Z",
51+
"size": null,
52+
"order": 1,
53+
"is_secret": true,
54+
"filename": "nulls_size"
55+
}
4556
}
4657
]
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{% load sorting_tags %}
2+
3+
<html>
4+
<body>
5+
<h2>List of files</h2>
6+
{% autosort secret_files nulls_first=True %}
7+
<table>
8+
<thead>
9+
<tr>
10+
<th>{% anchor id "ID" %}</th>
11+
<th>{% anchor filename "Filename" %}</th>
12+
<th>{% anchor created_on "Date" %}</th>
13+
<th>{% anchor size "Size" %}</th>
14+
<th>{% anchor order "Order" %}</th>
15+
<th>{% anchor is_secret "Secret?" %}</th>
16+
</tr>
17+
</thead>
18+
<tbody>
19+
{% for secret_file in secret_files %}
20+
<tr>
21+
<td>{{ secret_file.id }}</td>
22+
<td>{{ secret_file.filename }}</td>
23+
<td>{{ secret_file.created_on }}</td>
24+
<td>{{ secret_file.size }}</td>
25+
<td>{{ secret_file.order }}</td>
26+
<td>{{ secret_file.is_secret|yesno }}</td>
27+
</tr>
28+
{% endfor %}
29+
</tbody>
30+
</table>
31+
</body>
32+
</html>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{% load sorting_tags %}
2+
3+
<html>
4+
<body>
5+
<h2>List of files</h2>
6+
{% autosort secret_files nulls_last=True %}
7+
<table>
8+
<thead>
9+
<tr>
10+
<th>{% anchor id "ID" %}</th>
11+
<th>{% anchor filename "Filename" %}</th>
12+
<th>{% anchor created_on "Date" %}</th>
13+
<th>{% anchor size "Size" %}</th>
14+
<th>{% anchor order "Order" %}</th>
15+
<th>{% anchor is_secret "Secret?" %}</th>
16+
</tr>
17+
</thead>
18+
<tbody>
19+
{% for secret_file in secret_files %}
20+
<tr>
21+
<td>{{ secret_file.id }}</td>
22+
<td>{{ secret_file.filename }}</td>
23+
<td>{{ secret_file.created_on }}</td>
24+
<td>{{ secret_file.size }}</td>
25+
<td>{{ secret_file.order }}</td>
26+
<td>{{ secret_file.is_secret|yesno }}</td>
27+
</tr>
28+
{% endfor %}
29+
</tbody>
30+
</table>
31+
</body>
32+
</html>

testproj/testproj/testapp/tests.py

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import django.template as django_template
2+
from django.template.engine import Engine
3+
from django.template.response import SimpleTemplateResponse
14
from django.urls import reverse
2-
from django.test import TestCase, Client
5+
from django.test import TestCase
36

47
from . import models
58

69

710
class IndexTest(TestCase):
11+
812
def setUp(self):
9-
self.client = Client()
1013
self.url = reverse("secret_list")
14+
1115
models.SecretFile.objects.create(filename="foo.txt", order=1, size=1024)
1216
models.SecretFile.objects.create(filename="bar.txt", order=2, size=512)
1317

@@ -34,3 +38,70 @@ def test_sorting_argument(self):
3438
# Nothing wrong happens with invalid sort argument
3539
response = self.client.get(self.url, {"sort": "NOT EXISTING"})
3640
self.assertContains(response, "foo.txt")
41+
42+
43+
class NullsTestCase(TestCase):
44+
def setUp(self):
45+
self.nulls_first_url = reverse("nulls_first")
46+
self.nulls_last_url = reverse("nulls_last")
47+
48+
models.SecretFile.objects.create(filename="foo.txt", order=1, size=1024)
49+
models.SecretFile.objects.create(filename="bar.txt", order=2, size=512)
50+
51+
def test_sorting_nulls_first(self):
52+
""" Verify None sorted field_name is in firsts places when sorting in asc and desc order """
53+
54+
models.SecretFile.objects.create(filename=None, order=3, size=512)
55+
# asc order
56+
values = ["<SecretFile: #3 None>", "<SecretFile: #2 bar.txt>", "<SecretFile: #1 foo.txt>"]
57+
response = self.client.get(
58+
self.nulls_first_url,
59+
{"sort": "filename", "nulls_first": True, "dir": "asc"}
60+
)
61+
self.assertQuerysetEqual(list(response.context["secret_files"]), values)
62+
63+
# desc order
64+
values = ["<SecretFile: #3 None>", "<SecretFile: #1 foo.txt>", "<SecretFile: #2 bar.txt>"]
65+
response = self.client.get(
66+
self.nulls_first_url,
67+
{"sort": "filename", "nulls_first": True, "dir": "desc"}
68+
)
69+
self.assertQuerysetEqual(list(response.context["secret_files"]), values)
70+
71+
def test_sorting_nulls_last(self):
72+
""" Verify None sorted field_name is in lasts places when sorting in asc and desc order """
73+
74+
models.SecretFile.objects.create(filename=None, order=3, size=512)
75+
# asc order
76+
values = ["<SecretFile: #2 bar.txt>", "<SecretFile: #1 foo.txt>", "<SecretFile: #3 None>"]
77+
response = self.client.get(
78+
self.nulls_last_url,
79+
{"sort": "filename", "nulls_last": True, "dir": "asc"}
80+
)
81+
self.assertQuerysetEqual(list(response.context["secret_files"]), values)
82+
83+
# desc order
84+
values = ["<SecretFile: #1 foo.txt>", "<SecretFile: #2 bar.txt>", "<SecretFile: #3 None>"]
85+
response = self.client.get(
86+
self.nulls_last_url,
87+
{"sort": "filename", "nulls_last": True, "dir": "desc"}
88+
)
89+
self.assertQuerysetEqual(list(response.context["secret_files"]), values)
90+
91+
def test_sorting_nulls_first_and_last(self):
92+
""" Verify nulls_first and nulls_last autosort params can't be used at the same time """
93+
94+
engine = Engine(
95+
libraries={'sorting_tags': 'webstack_django_sorting.templatetags.sorting_tags'},
96+
context_processors=['django.template.context_processors.request'],
97+
)
98+
with self.assertRaises(django_template.TemplateSyntaxError) as exc:
99+
template = engine.from_string("""
100+
{% load sorting_tags %}
101+
{% autosort secret_files nulls_first=True nulls_last=True %}
102+
""")
103+
response = SimpleTemplateResponse(
104+
template,
105+
context={'secret_files': models.SecretFile.objects.all()}
106+
)
107+
self.assertIn("Can't set nulls_first and nulls_last simultaneously.", exc.exception.args)

testproj/testproj/testapp/views.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,15 @@ def secret_list_jinja2(request):
1313
return render(
1414
request, "secret_list.jinja2", {"secret_files": models.SecretFile.objects.all()}
1515
)
16+
17+
18+
def secret_list_nulls_first(request):
19+
return render(
20+
request, "secret_list_nulls_first.html", {"secret_files": models.SecretFile.objects.all()}
21+
)
22+
23+
24+
def secret_list_nulls_last(request):
25+
return render(
26+
request, "secret_list_nulls_last.html", {"secret_files": models.SecretFile.objects.all()}
27+
)

testproj/testproj/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
urlpatterns = [
77
path("", views.secret_list, name="secret_list"),
8+
path("nulls_first", views.secret_list_nulls_first, name="nulls_first"),
9+
path("nulls_last", views.secret_list_nulls_last, name="nulls_last"),
810
path("jinja2", views.secret_list_jinja2, name="secret_list_jinja2"),
911
path("admin/", admin.site.urls),
1012
]

webstack_django_sorting/common.py

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""
22
Common to Django tags (sorting_tags) and Jinja2 globals (jinja2_globals)
33
"""
4+
from django.db.models import F
5+
46
from operator import attrgetter
57

68
from .settings import SORT_DIRECTIONS
@@ -51,30 +53,32 @@ def need_python_sorting(queryset, order_by):
5153
return field not in field_names
5254

5355

54-
def sort_queryset(queryset, order_by):
56+
def sort_queryset(queryset, order_by, null_ordering):
5557
"""order_by is an Django ORM order_by argument"""
58+
5659
if not order_by:
5760
return queryset
5861

62+
# The field name can be prefixed by the minus sign and we need to
63+
# extract this information if we want to sort on simple object
64+
# attributes
65+
if order_by[0] == "-":
66+
if len(order_by) == 1:
67+
# Prefix without field name
68+
raise ValueError
69+
70+
reverse = True
71+
name = order_by[1:]
72+
else:
73+
reverse = False
74+
name = order_by
75+
5976
if need_python_sorting(queryset, order_by):
6077
# Fallback on pure Python sorting (much slower on large data)
61-
62-
# The field name can be prefixed by the minus sign and we need to
63-
# extract this information if we want to sort on simple object
64-
# attributes (non-model fields)
65-
if order_by[0] == "-":
66-
if len(order_by) == 1:
67-
# Prefix without field name
68-
raise ValueError
69-
70-
reverse = True
71-
name = order_by[1:]
72-
else:
73-
reverse = False
74-
name = order_by
7578
if hasattr(queryset[0], name):
7679
return sorted(queryset, key=attrgetter(name), reverse=reverse)
77-
else:
78-
raise AttributeError
79-
else:
80-
return queryset.order_by(order_by)
80+
raise AttributeError
81+
ordering_exp = (
82+
F(name).desc if reverse else F(name).asc
83+
)(**null_ordering)
84+
return queryset.order_by(ordering_exp)

webstack_django_sorting/jinja2_globals.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ def sorting_anchor(request, field_name, title):
77
return Markup(common.render_sort_anchor(request, field_name, title))
88

99

10-
def sort_queryset(request, queryset):
10+
def sort_queryset(request, queryset, **null_ordering):
11+
if not null_ordering:
12+
null_ordering = {}
1113
order_by = common.get_order_by_from_request(request)
12-
return common.sort_queryset(queryset, order_by)
14+
return common.sort_queryset(queryset, order_by, null_ordering)

webstack_django_sorting/templatetags/sorting_tags.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -77,25 +77,39 @@ def autosort(parser, token):
7777
)
7878
context_var = None
7979

80-
# Check if has not required "as new_context_var" part
81-
if len(bits) == 4 and bits[2] == "as":
82-
context_var = bits[3]
83-
del bits[2:]
84-
85-
if len(bits) != 2:
80+
# Check if their is some optional parameter (as new_context_var, nulls_first, nulls_last)
81+
if 2 > len(bits) > 7:
8682
raise template.TemplateSyntaxError(help_msg)
8783

88-
return SortedDataNode(bits[1], context_var=context_var)
84+
85+
context_var = None
86+
null_ordering = {}
87+
88+
for index, bit in enumerate(bits):
89+
if index > 1:
90+
if bit == 'as' and index + 1 < len(bits):
91+
context_var = bits[index + 1]
92+
del bits[index:index + 1]
93+
if bit.startswith('nulls_first'):
94+
null_ordering['nulls_first'] = True if bit[len('nulls_first='):] == "True" else False
95+
if bit.startswith('nulls_last'):
96+
null_ordering['nulls_last'] = True if bit[len('nulls_last='):] == "True" else False
97+
98+
if len(null_ordering) > 1 and all(null_ordering.values()):
99+
raise template.TemplateSyntaxError("Can't set nulls_first and nulls_last simultaneously.")
100+
101+
return SortedDataNode(bits[1], null_ordering, context_var=context_var)
89102

90103

91104
class SortedDataNode(template.Node):
92105
"""
93106
Automatically sort a queryset with {% autosort queryset %}
94107
"""
95108

96-
def __init__(self, queryset_var, context_var=None):
109+
def __init__(self, queryset_var, null_ordering, context_var=None):
97110
self.queryset_var = template.Variable(queryset_var)
98111
self.context_var = context_var
112+
self.null_ordering = null_ordering
99113

100114
def render(self, context):
101115
if self.context_var is not None:
@@ -107,7 +121,7 @@ def render(self, context):
107121
order_by = common.get_order_by_from_request(context["request"])
108122

109123
try:
110-
context[key] = common.sort_queryset(queryset, order_by)
124+
context[key] = common.sort_queryset(queryset, order_by, self.null_ordering)
111125
except ValueError as e:
112126
raise template.TemplateSyntaxError from e
113127
except AttributeError:

0 commit comments

Comments
 (0)