diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 983e4b2..4d7d525 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -2,3 +2,4 @@ Karim A. (Directeur) Eric Florenzano Stéphane Raimbault S. Kossouho +Joffrey M. diff --git a/testproj/README b/testproj/README index 2d5c3ef..36d089c 100644 --- a/testproj/README +++ b/testproj/README @@ -3,6 +3,7 @@ 3 pip install .. 4. ./manage.py migrate 5. ./manage.py loaddata secretfiles -6. ./manage.py runserver +6. ./manage.py collectstatic +7. ./manage.py runserver To run tests: ./manage.py test diff --git a/testproj/testproj/settings.py b/testproj/testproj/settings.py index 9445d0d..848b22f 100644 --- a/testproj/testproj/settings.py +++ b/testproj/testproj/settings.py @@ -121,6 +121,7 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.1/howto/static-files/ +STATIC_ROOT = "static/" STATIC_URL = "/static/" DEFAULT_AUTO_FIELD = "django.db.models.AutoField" diff --git a/testproj/testproj/testapp/fixtures/secretfiles.json b/testproj/testproj/testapp/fixtures/secretfiles.json index 359b488..045ce4a 100644 --- a/testproj/testproj/testapp/fixtures/secretfiles.json +++ b/testproj/testproj/testapp/fixtures/secretfiles.json @@ -42,5 +42,16 @@ "is_secret": false, "filename": "0-w00t.crt" } +}, +{ + "pk": 5, + "model": "testapp.secretfile", + "fields": { + "created_on":"2014-01-09T23:01:30Z", + "size": null, + "order": 1, + "is_secret": true, + "filename": "nulls_size" + } } ] diff --git a/testproj/testproj/testapp/jinja2/env.py b/testproj/testproj/testapp/jinja2/env.py index 5f9e62a..738e32e 100644 --- a/testproj/testproj/testapp/jinja2/env.py +++ b/testproj/testproj/testapp/jinja2/env.py @@ -1,6 +1,7 @@ from jinja2.environment import Environment from django.template.defaultfilters import yesno +from django.templatetags.static import static from webstack_django_sorting.jinja2_globals import ( sorting_anchor, sort_queryset, @@ -13,3 +14,4 @@ def __init__(self, **kwargs): self.filters["yesno"] = yesno self.globals["sorting_anchor"] = sorting_anchor self.globals["sort_queryset"] = sort_queryset + self.globals["static"] = static diff --git a/testproj/testproj/testapp/jinja2/secret_list.jinja2 b/testproj/testproj/testapp/jinja2/secret_list.jinja2 index f7a4862..6e504dc 100644 --- a/testproj/testproj/testapp/jinja2/secret_list.jinja2 +++ b/testproj/testproj/testapp/jinja2/secret_list.jinja2 @@ -1,36 +1,6 @@ - +

List of files

diff --git a/testproj/testproj/testapp/templates/secret_list.html b/testproj/testproj/testapp/templates/secret_list.html index f46bf17..fca17b5 100644 --- a/testproj/testproj/testapp/templates/secret_list.html +++ b/testproj/testproj/testapp/templates/secret_list.html @@ -1,38 +1,8 @@ -{% load sorting_tags %} +{% load static sorting_tags %} - +

List of files

diff --git a/testproj/testproj/testapp/templates/secret_list_nulls_first.html b/testproj/testproj/testapp/templates/secret_list_nulls_first.html new file mode 100644 index 0000000..a58b139 --- /dev/null +++ b/testproj/testproj/testapp/templates/secret_list_nulls_first.html @@ -0,0 +1,35 @@ +{% load static sorting_tags %} + + + + + + +

List of files

+ {% autosort secret_files nulls_first=True %} + + + + + + + + + + + + + {% for secret_file in secret_files %} + + + + + + + + + {% endfor %} + +
{% anchor id "ID" %}{% anchor filename "Filename" %}{% anchor created_on "Date" %}{% anchor size "Size" %}{% anchor order "Order" %}{% anchor is_secret "Secret?" %}
{{ secret_file.id }}{{ secret_file.filename }}{{ secret_file.created_on }}{{ secret_file.size }}{{ secret_file.order }}{{ secret_file.is_secret|yesno }}
+ + diff --git a/testproj/testproj/testapp/templates/secret_list_nulls_last.html b/testproj/testproj/testapp/templates/secret_list_nulls_last.html new file mode 100644 index 0000000..0b1494b --- /dev/null +++ b/testproj/testproj/testapp/templates/secret_list_nulls_last.html @@ -0,0 +1,35 @@ +{% load static sorting_tags %} + + + + + + +

List of files

+ {% autosort secret_files nulls_last=True %} + + + + + + + + + + + + + {% for secret_file in secret_files %} + + + + + + + + + {% endfor %} + +
{% anchor id "ID" %}{% anchor filename "Filename" %}{% anchor created_on "Date" %}{% anchor size "Size" %}{% anchor order "Order" %}{% anchor is_secret "Secret?" %}
{{ secret_file.id }}{{ secret_file.filename }}{{ secret_file.created_on }}{{ secret_file.size }}{{ secret_file.order }}{{ secret_file.is_secret|yesno }}
+ + diff --git a/testproj/testproj/testapp/tests.py b/testproj/testproj/testapp/tests.py index fd032c8..8b65bc7 100644 --- a/testproj/testproj/testapp/tests.py +++ b/testproj/testproj/testapp/tests.py @@ -1,13 +1,17 @@ +import django.template as django_template +from django.template.engine import Engine +from django.template.response import SimpleTemplateResponse from django.urls import reverse -from django.test import TestCase, Client +from django.test import TestCase from . import models class IndexTest(TestCase): + def setUp(self): - self.client = Client() self.url = reverse("secret_list") + models.SecretFile.objects.create(filename="foo.txt", order=1, size=1024) models.SecretFile.objects.create(filename="bar.txt", order=2, size=512) @@ -34,3 +38,70 @@ def test_sorting_argument(self): # Nothing wrong happens with invalid sort argument response = self.client.get(self.url, {"sort": "NOT EXISTING"}) self.assertContains(response, "foo.txt") + + +class NullsTestCase(TestCase): + def setUp(self): + self.nulls_first_url = reverse("nulls_first") + self.nulls_last_url = reverse("nulls_last") + + models.SecretFile.objects.create(filename="foo.txt", order=1, size=1024) + models.SecretFile.objects.create(filename="bar.txt", order=2, size=512) + + def test_sorting_nulls_first(self): + """ Verify None sorted field_name is in firsts places when sorting in asc and desc order """ + + models.SecretFile.objects.create(filename=None, order=3, size=512) + # asc order + values = ["", "", ""] + response = self.client.get( + self.nulls_first_url, + {"sort": "filename", "nulls_first": True, "dir": "asc"} + ) + self.assertQuerysetEqual(list(response.context["secret_files"]), values) + + # desc order + values = ["", "", ""] + response = self.client.get( + self.nulls_first_url, + {"sort": "filename", "nulls_first": True, "dir": "desc"} + ) + self.assertQuerysetEqual(list(response.context["secret_files"]), values) + + def test_sorting_nulls_last(self): + """ Verify None sorted field_name is in lasts places when sorting in asc and desc order """ + + models.SecretFile.objects.create(filename=None, order=3, size=512) + # asc order + values = ["", "", ""] + response = self.client.get( + self.nulls_last_url, + {"sort": "filename", "nulls_last": True, "dir": "asc"} + ) + self.assertQuerysetEqual(list(response.context["secret_files"]), values) + + # desc order + values = ["", "", ""] + response = self.client.get( + self.nulls_last_url, + {"sort": "filename", "nulls_last": True, "dir": "desc"} + ) + self.assertQuerysetEqual(list(response.context["secret_files"]), values) + + def test_sorting_nulls_first_and_last(self): + """ Verify nulls_first and nulls_last autosort params can't be used at the same time """ + + engine = Engine( + libraries={'sorting_tags': 'webstack_django_sorting.templatetags.sorting_tags'}, + context_processors=['django.template.context_processors.request'], + ) + with self.assertRaises(django_template.TemplateSyntaxError) as exc: + template = engine.from_string(""" + {% load sorting_tags %} + {% autosort secret_files nulls_first=True nulls_last=True %} + """) + response = SimpleTemplateResponse( + template, + context={'secret_files': models.SecretFile.objects.all()} + ) + self.assertIn("Can't set nulls_first and nulls_last simultaneously.", exc.exception.args) diff --git a/testproj/testproj/testapp/views.py b/testproj/testproj/testapp/views.py index 230d4c9..a933446 100644 --- a/testproj/testproj/testapp/views.py +++ b/testproj/testproj/testapp/views.py @@ -13,3 +13,15 @@ def secret_list_jinja2(request): return render( request, "secret_list.jinja2", {"secret_files": models.SecretFile.objects.all()} ) + + +def secret_list_nulls_first(request): + return render( + request, "secret_list_nulls_first.html", {"secret_files": models.SecretFile.objects.all()} + ) + + +def secret_list_nulls_last(request): + return render( + request, "secret_list_nulls_last.html", {"secret_files": models.SecretFile.objects.all()} + ) diff --git a/testproj/testproj/urls.py b/testproj/testproj/urls.py index 59608fc..a0fdc1e 100644 --- a/testproj/testproj/urls.py +++ b/testproj/testproj/urls.py @@ -1,10 +1,14 @@ from django.contrib import admin +from django.contrib.staticfiles.urls import staticfiles_urlpatterns from django.urls import path from .testapp import views + urlpatterns = [ path("", views.secret_list, name="secret_list"), + path("nulls_first", views.secret_list_nulls_first, name="nulls_first"), + path("nulls_last", views.secret_list_nulls_last, name="nulls_last"), path("jinja2", views.secret_list_jinja2, name="secret_list_jinja2"), path("admin/", admin.site.urls), -] +] + staticfiles_urlpatterns() diff --git a/webstack_django_sorting/common.py b/webstack_django_sorting/common.py index 6688968..bf37438 100644 --- a/webstack_django_sorting/common.py +++ b/webstack_django_sorting/common.py @@ -1,6 +1,8 @@ """ Common to Django tags (sorting_tags) and Jinja2 globals (jinja2_globals) """ +from django.db.models import F + from operator import attrgetter from .settings import SORT_DIRECTIONS @@ -11,7 +13,9 @@ def render_sort_anchor(request, field_name, title): sort_by = get_params.get("sort", None) if sort_by == field_name: # Render anchor link to next direction - current_direction = SORT_DIRECTIONS[get_params.get("dir", "")] + current_direction = SORT_DIRECTIONS.get( + get_params.get("dir", ""), SORT_DIRECTIONS[""] + ) icon = current_direction["icon"] next_direction_code = current_direction["next"] else: @@ -49,30 +53,32 @@ def need_python_sorting(queryset, order_by): return field not in field_names -def sort_queryset(queryset, order_by): +def sort_queryset(queryset, order_by, null_ordering): """order_by is an Django ORM order_by argument""" + if not order_by: return queryset + # The field name can be prefixed by the minus sign and we need to + # extract this information if we want to sort on simple object + # attributes + if order_by[0] == "-": + if len(order_by) == 1: + # Prefix without field name + raise ValueError + + reverse = True + name = order_by[1:] + else: + reverse = False + name = order_by + if need_python_sorting(queryset, order_by): # Fallback on pure Python sorting (much slower on large data) - - # The field name can be prefixed by the minus sign and we need to - # extract this information if we want to sort on simple object - # attributes (non-model fields) - if order_by[0] == "-": - if len(order_by) == 1: - # Prefix without field name - raise ValueError - - reverse = True - name = order_by[1:] - else: - reverse = False - name = order_by if hasattr(queryset[0], name): return sorted(queryset, key=attrgetter(name), reverse=reverse) - else: - raise AttributeError - else: - return queryset.order_by(order_by) + raise AttributeError + ordering_exp = ( + F(name).desc if reverse else F(name).asc + )(**null_ordering) + return queryset.order_by(ordering_exp) diff --git a/webstack_django_sorting/jinja2_globals.py b/webstack_django_sorting/jinja2_globals.py index 42074cc..8ebfa9c 100644 --- a/webstack_django_sorting/jinja2_globals.py +++ b/webstack_django_sorting/jinja2_globals.py @@ -7,6 +7,8 @@ def sorting_anchor(request, field_name, title): return Markup(common.render_sort_anchor(request, field_name, title)) -def sort_queryset(request, queryset): +def sort_queryset(request, queryset, **null_ordering): + if not null_ordering: + null_ordering = {} order_by = common.get_order_by_from_request(request) - return common.sort_queryset(queryset, order_by) + return common.sort_queryset(queryset, order_by, null_ordering) diff --git a/webstack_django_sorting/templatetags/sorting_tags.py b/webstack_django_sorting/templatetags/sorting_tags.py index c2874d4..70f3e21 100644 --- a/webstack_django_sorting/templatetags/sorting_tags.py +++ b/webstack_django_sorting/templatetags/sorting_tags.py @@ -77,15 +77,28 @@ def autosort(parser, token): ) context_var = None - # Check if has not required "as new_context_var" part - if len(bits) == 4 and bits[2] == "as": - context_var = bits[3] - del bits[2:] - - if len(bits) != 2: + # Check if their is some optional parameter (as new_context_var, nulls_first, nulls_last) + if 2 > len(bits) > 7: raise template.TemplateSyntaxError(help_msg) - return SortedDataNode(bits[1], context_var=context_var) + + context_var = None + null_ordering = {} + + for index, bit in enumerate(bits): + if index > 1: + if bit == 'as' and index + 1 < len(bits): + context_var = bits[index + 1] + del bits[index:index + 1] + if bit.startswith('nulls_first'): + null_ordering['nulls_first'] = True if bit[len('nulls_first='):] == "True" else False + if bit.startswith('nulls_last'): + null_ordering['nulls_last'] = True if bit[len('nulls_last='):] == "True" else False + + if len(null_ordering) > 1 and all(null_ordering.values()): + raise template.TemplateSyntaxError("Can't set nulls_first and nulls_last simultaneously.") + + return SortedDataNode(bits[1], null_ordering, context_var=context_var) class SortedDataNode(template.Node): @@ -93,9 +106,10 @@ class SortedDataNode(template.Node): Automatically sort a queryset with {% autosort queryset %} """ - def __init__(self, queryset_var, context_var=None): + def __init__(self, queryset_var, null_ordering, context_var=None): self.queryset_var = template.Variable(queryset_var) self.context_var = context_var + self.null_ordering = null_ordering def render(self, context): if self.context_var is not None: @@ -107,7 +121,7 @@ def render(self, context): order_by = common.get_order_by_from_request(context["request"]) try: - context[key] = common.sort_queryset(queryset, order_by) + context[key] = common.sort_queryset(queryset, order_by, self.null_ordering) except ValueError as e: raise template.TemplateSyntaxError from e except AttributeError: