Merge lp:~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting into lp:~maas-committers/maas/trunk

Proposed by Dimiter Naydenov
Status: Rejected
Rejected by: Dimiter Naydenov
Proposed branch: lp:~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 783 lines (+623/-10)
10 files modified
src/maasserver/static/css/components/table_list.css (+11/-0)
src/maasserver/templates/maasserver/node_list.html (+1/-1)
src/maasserver/templates/maasserver/nodes_listing.html (+9/-2)
src/maasserver/templates/maasserver/tag_view.html (+1/-1)
src/maasserver/templatetags/sortable.py (+168/-0)
src/maasserver/templatetags/tests/test_sortable.py (+130/-0)
src/maasserver/tests/test_views_nodes.py (+111/-6)
src/maasserver/utils/sortable/__init__.py (+25/-0)
src/maasserver/utils/sortable/sortable.py (+161/-0)
src/maasserver/views/nodes.py (+6/-0)
To merge this branch: bzr merge lp:~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting
Reviewer Review Type Date Requested Status
Huw Wilkins (community) ui Approve
Martin Packman (community) Abstain
Review via email: mp+130600@code.launchpad.net

Commit message

Fix bug #994887: Nodes list view now supports sorting by hostname(mac) or status columns.

Description of the change

This is still here for reference only and history. The same bug is fixed with a simpler solution in https://code.launchpad.net/~dimitern/maas/bug-994887-alternate-nodes-list-sorting/+merge/131177. So I'm marking this as Rejected.

-------------------

Implemented sorting for /MAAS/nodes/ list - by hostname or status (asc/desc). As advised, I pulled only what was needed from the project django-sortable and I'm using it in the views/templates. Since django-sortable is not in main archive, we cannot use it, but it's only a tiny package, so I extracted what was needed and added it to maasserver (also did some style formatting so it'll conform to our code).

There are a couple of very minor UI changes, which I was advised also to request a separate review from Huw Wilkins. The added elements are 2 up/down arrows appearing on the right of the two table columns - MAC and Status. Here are screenshots how it looks in all states:

http://people.canonical.com/~dimitern/maas-nodes-sort-none.png
http://people.canonical.com/~dimitern/maas-nodes-sort-mac-asc.png
http://people.canonical.com/~dimitern/maas-nodes-sort-mac-desc.png
http://people.canonical.com/~dimitern/maas-nodes-sort-status-asc.png
http://people.canonical.com/~dimitern/maas-nodes-sort-status-desc.png

To post a comment you must log in.
Revision history for this message
John A Meinel (jameinel) wrote :

I haven't gone through this in detail, but my first concern is how this
interacts with pagination. Does this just sort the visible nodes, or does
it re do the query with a new sort order? (It is nice to nit have to reload
the page, but with >1k nodes, we won't display them all at once, and then
sorting wouldn't be very useful)

John
=:->
On Oct 19, 2012 8:31 PM, "Dimiter Naydenov" <email address hidden>
wrote:

> The proposal to merge
> lp:~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting into
> lp:maas has been updated.
>
> Description changed to:
>
> Implemented sorting for /MAAS/nodes/ list - by hostname or status
> (asc/desc). As advised, I pulled only what was needed from the project
> django-sortable and I'm using it in the views/templates. Since
> django-sortable is not in main archive, we cannot use it, but it's only a
> tiny package, so I extracted what was needed and added it to maasserver
> (also did some style formatting so it'll conform to our code).
>
> There are a couple of very minor UI changes, which I was advised also to
> request a separate review from Huw Wilkins. The added elements are 2
> up/down arrows appearing on the right of the two table columns - MAC and
> Status. Here are screenshots how it looks in all states:
>
> http://people.canonical.com/~dimitern/maas-nodes-sort-none.png
> http://people.canonical.com/~dimitern/maas-nodes-sort-mac-asc.png
> http://people.canonical.com/~dimitern/maas-nodes-sort-mac-desc.png
> http://people.canonical.com/~dimitern/maas-nodes-sort-status-asc.png
> http://people.canonical.com/~dimitern/maas-nodes-sort-status-desc.png
>
> For more details, see:
>
> https://code.launchpad.net/~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting/+merge/130600
> --
>
> https://code.launchpad.net/~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting/+merge/130600
> You are subscribed to branch lp:maas.
>

Revision history for this message
John A Meinel (jameinel) wrote :

I guess my specific point is, people expect inline controls like a triangle
to be cheap, but at this point to do it accurately we have to reload the
page. Should we present this in a way to make it clearer what will happen?
Would Ajax be enough to make the reload lightweight?. We should probably
test with a large dataset and see how long it takes to reload.

John
=:->
On Oct 20, 2012 7:52 AM, "John A Meinel" <email address hidden> wrote:

> I haven't gone through this in detail, but my first concern is how this
> interacts with pagination. Does this just sort the visible nodes, or does
> it re do the query with a new sort order? (It is nice to nit have to reload
> the page, but with >1k nodes, we won't display them all at once, and then
> sorting wouldn't be very useful)
>
> John
> =:->
> On Oct 19, 2012 8:31 PM, "Dimiter Naydenov" <
> <email address hidden>>
> wrote:
>
> > The proposal to merge
> > lp:~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting into
> > lp:maas has been updated.
> >
> > Description changed to:
> >
> > Implemented sorting for /MAAS/nodes/ list - by hostname or status
> > (asc/desc). As advised, I pulled only what was needed from the project
> > django-sortable and I'm using it in the views/templates. Since
> > django-sortable is not in main archive, we cannot use it, but it's only a
> > tiny package, so I extracted what was needed and added it to maasserver
> > (also did some style formatting so it'll conform to our code).
> >
> > There are a couple of very minor UI changes, which I was advised also to
> > request a separate review from Huw Wilkins. The added elements are 2
> > up/down arrows appearing on the right of the two table columns - MAC and
> > Status. Here are screenshots how it looks in all states:
> >
> > http://people.canonical.com/~dimitern/maas-nodes-sort-none.png
> > http://people.canonical.com/~dimitern/maas-nodes-sort-mac-asc.png
> > http://people.canonical.com/~dimitern/maas-nodes-sort-mac-desc.png
> > http://people.canonical.com/~dimitern/maas-nodes-sort-status-asc.png
> > http://people.canonical.com/~dimitern/maas-nodes-sort-status-desc.png
> >
> > For more details, see:
> >
> >
> https://code.launchpad.net/~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting/+merge/130600
> > --
> >
> >
> https://code.launchpad.net/~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting/+merge/130600
> > You are subscribed to branch lp:maas.
> >
>
> --
>
> https://code.launchpad.net/~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting/+merge/130600
> You are subscribed to branch lp:maas.
>

Revision history for this message
Dimiter Naydenov (dimitern) wrote :

It works quite well with paging - transparently, and because it modifies the queryset, it actually works for both visible and invisible nodes in the list. Paging links include the ?sort= and dir= parameters, so sorting spans pages properly. I tested this manually by setting paginate_by = 2, and then realized it'll be good to have a test for that. So I added test_node_list_paginates_while_sorted, which does just that.

Also, I noticed a small omission on my part - the tags view shows matching nodes in a list, using the same template, so I modified the included snippet to skip header paging links when not explicitly required (only /nodes/ list uses it now).

With regards to speed, I don't think it'll be any slower than sorting by -created by default, provided we have indexes on both hostname and status columns in the DB. I thought about implementing the sort using AJAX/in-line with JS, but what I did seemed like a better option.

Thanks for the feedback and I'm eager to see the review :)

Revision history for this message
Raphaël Badin (rvb) wrote :

I've run the tests in test_views_nodes.py and the test coverage report says that 25% of the module sortable.py is untested. This means that some tests are missing or that some of the code in sortable.py is unnecessary and thus you should consider removing it.

$ ./bin/maas test src/maasserver/tests/test_views_nodes.py --with-coverage --cover-package=maasserver.templatetags
[...]
........................................................................S...
Name Stmts Miss Cover Missing
------------------------------------------------------------------
maasserver.templatetags 0 0 100%
maasserver.templatetags.field_type 8 0 100%
maasserver.templatetags.sortable 83 21 75% 47, 51-55, 65-66, 68-69, 124-125, 133-134, 141-142, 151-152, 156-157, 161-162
------------------------------------------------------------------
TOTAL 91 21 77%
----------------------------------------------------------------------
Ran 76 tests in 23.961s

Revision history for this message
Dimiter Naydenov (dimitern) wrote :

As discussed on IRC, I decided it's better to have the complete functionality of sortable, if we need to use it for other views. So I added separate unit tests src/maasserver/templatetags/tests/test_sortable.py with all the needed test cases to ensure 100% coverage.

Revision history for this message
Martin Packman (gz) wrote :

The project much of this code comes from only has a license specified in setup.py (GPLv3, which is okay to redistribute as AGPLv3), but we probably want the copyright header in that file to read "Drew Yeaton" instead of/as well as Canonical.

A fair bit of that code wiggles my wtf-meter, is the best way of doing this really writing a custom template snippet parser (parse_tag_token... no wonder django templating is fragile), then constructing the link (SortableLinkNode.make_link) and html (SortableLinkNode.render) with string interpolation? I wouldn't trust this to, for instance, correctly escape '&' -> '&amp;', but maybe I underestimate django magic.

Unlike John I don't worry about the headers being page reloads for sorting rather than fast JS things, this way it at least combines correctly the pagination, and has predictable behaviour.

review: Abstain
Revision history for this message
John A Meinel (jameinel) wrote :

As long as the reload speed is reasonable, I'm happy to have it done by an extra round trip. 100ms is reasonable, 10s is not, somewhere inbetween is the cutoff :).

Hence why I was mentioning measuring it, rather than just saying 'it works fine when you have 5 nodes'.

Revision history for this message
Huw Wilkins (huwshimi) wrote :

The arrows look fine. To fit in with the styles they should probably be a shade of our warm grey, but I'll fix that up in a further branch.

review: Approve (ui)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/maasserver/static/css/components/table_list.css'
--- src/maasserver/static/css/components/table_list.css 2012-04-05 06:23:59 +0000
+++ src/maasserver/static/css/components/table_list.css 2012-10-22 09:32:21 +0000
@@ -30,6 +30,17 @@
30 border-color: #dd4814;30 border-color: #dd4814;
31 cursor: pointer;31 cursor: pointer;
32 }32 }
33.sort-none, .sort-asc, .sort-desc {
34 background-repeat: no-repeat;
35 background-position: right 0.4em;
36 padding-right: 1.2em;
37 }
38.sort-asc {
39 background-image: url('/static/img/up_arrow_dark.png');
40 }
41.sort-desc {
42 background-image: url('/static/img/down_arrow_dark.png');
43 }
33/* ul list */44/* ul list */
34ul.list {45ul.list {
35 list-style: none;46 list-style: none;
3647
=== added file 'src/maasserver/static/img/down_arrow_dark.png'
37Binary files src/maasserver/static/img/down_arrow_dark.png 1970-01-01 00:00:00 +0000 and src/maasserver/static/img/down_arrow_dark.png 2012-10-22 09:32:21 +0000 differ48Binary files src/maasserver/static/img/down_arrow_dark.png 1970-01-01 00:00:00 +0000 and src/maasserver/static/img/down_arrow_dark.png 2012-10-22 09:32:21 +0000 differ
=== added file 'src/maasserver/static/img/up_arrow_dark.png'
38Binary files src/maasserver/static/img/up_arrow_dark.png 1970-01-01 00:00:00 +0000 and src/maasserver/static/img/up_arrow_dark.png 2012-10-22 09:32:21 +0000 differ49Binary files src/maasserver/static/img/up_arrow_dark.png 1970-01-01 00:00:00 +0000 and src/maasserver/static/img/up_arrow_dark.png 2012-10-22 09:32:21 +0000 differ
=== modified file 'src/maasserver/templates/maasserver/node_list.html'
--- src/maasserver/templates/maasserver/node_list.html 2012-10-12 13:33:09 +0000
+++ src/maasserver/templates/maasserver/node_list.html 2012-10-22 09:32:21 +0000
@@ -38,7 +38,7 @@
38 {% if input_query_error %}38 {% if input_query_error %}
39 <p class="form-errors">{{input_query_error}}</p>39 <p class="form-errors">{{input_query_error}}</p>
40 {% endif %}40 {% endif %}
41 {% include "maasserver/nodes_listing.html" %}41 {% include "maasserver/nodes_listing.html" with sortable="true" %}
42 {% include "maasserver/pagination.html" %}42 {% include "maasserver/pagination.html" %}
43 <a id="addnode" href="#" class="button right space-top">+ Add node</a>43 <a id="addnode" href="#" class="button right space-top">+ Add node</a>
44 <div class="clear"></div>44 <div class="clear"></div>
4545
=== modified file 'src/maasserver/templates/maasserver/nodes_listing.html'
--- src/maasserver/templates/maasserver/nodes_listing.html 2012-10-10 08:13:01 +0000
+++ src/maasserver/templates/maasserver/nodes_listing.html 2012-10-22 09:32:21 +0000
@@ -1,9 +1,16 @@
1{% load sortable %}
2
1{% if node_list|length %}3{% if node_list|length %}
2 <table class="list">4 <table class="list">
3 <thead>5 <thead>
4 <tr>6 <tr>
5 <th>MAC</th>7 {% if sortable == "true" %}
6 <th>Status</th>8 <th>{% sortable_link hostname "MAC" %}</th>
9 <th>{% sortable_link status "Status" %}</th>
10 {% else %}
11 <th>MAC</th>
12 <td>Status</th>
13 {% endif %}
7 </tr>14 </tr>
8 </thead>15 </thead>
9 {% for node in node_list %}16 {% for node in node_list %}
1017
=== modified file 'src/maasserver/templates/maasserver/tag_view.html'
--- src/maasserver/templates/maasserver/tag_view.html 2012-10-18 11:26:02 +0000
+++ src/maasserver/templates/maasserver/tag_view.html 2012-10-22 09:32:21 +0000
@@ -34,7 +34,7 @@
34 </ul>34 </ul>
35 <div id="nodes" class="block first pad-top">35 <div id="nodes" class="block first pad-top">
36 <h2 class="block first line-top pad-top">{{ paginator.count }} Node{{ paginator.count|pluralize }}</h2>36 <h2 class="block first line-top pad-top">{{ paginator.count }} Node{{ paginator.count|pluralize }}</h2>
37 {% include "maasserver/nodes_listing.html" %}37 {% include "maasserver/nodes_listing.html" with sortable="false" %}
38 {% include "maasserver/pagination.html" %}38 {% include "maasserver/pagination.html" %}
39 </div>39 </div>
40{% endblock %}40{% endblock %}
4141
=== added file 'src/maasserver/templatetags/sortable.py'
--- src/maasserver/templatetags/sortable.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/templatetags/sortable.py 2012-10-22 09:32:21 +0000
@@ -0,0 +1,168 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4# Most of this code is taken from django-sortable project:
5# https://github.com/drewyeaton/django-sortable
6# and formatted to fit our style guide
7
8"""Field type template tag."""
9
10from __future__ import (
11 absolute_import,
12 print_function,
13 unicode_literals,
14 )
15
16__metaclass__ = type
17__all__ = [
18 "sortable_link",
19 "sortable_header",
20 "sortable_url",
21 "sortable_class",
22 ]
23
24from django.conf import settings
25from django import template
26
27
28register = template.Library()
29
30# CSS classes, used by the sortable_class tag
31SORT_ASC_CLASS = getattr(settings, 'SORT_ASC_CLASS', 'sort-asc')
32SORT_DESC_CLASS = getattr(settings, 'SORT_DESC_CLASS', 'sort-desc')
33SORT_NONE_CLASS = getattr(settings, 'SORT_DESC_CLASS', 'sort-none')
34
35directions = {
36 'asc': {'class': SORT_ASC_CLASS, 'inverse': 'desc'},
37 'desc': {'class': SORT_DESC_CLASS, 'inverse': 'asc'},
38 }
39
40
41def parse_tag_token(token):
42 """Parses a tag that's supposed to be in this format:
43 {% sortable_link field title %}
44 """
45 bits = [b.strip('"\'') for b in token.split_contents()]
46 if len(bits) < 2:
47 raise template.TemplateSyntaxError(
48 "anchor tag takes at least 1 argument")
49 try:
50 title = bits[2]
51 except IndexError:
52 if bits[1].startswith(('+', '-')):
53 title = bits[1][1:].capitalize()
54 else:
55 title = bits[1].capitalize()
56
57 return (bits[1].strip(), title.strip())
58
59
60class SortableLinkNode(template.Node):
61 """Build sortable link based on query params."""
62
63 def __init__(self, field_name, title):
64 if field_name.startswith('-'):
65 self.field_name = field_name[1:]
66 self.default_direction = 'desc'
67 elif field_name.startswith('+'):
68 self.field_name = field_name[1:]
69 self.default_direction = 'asc'
70 else:
71 self.field_name = field_name
72 self.default_direction = 'asc'
73
74 self.title = title
75
76 def build_link(self, context):
77 """Prepare link for rendering based on context."""
78 get_params = context['request'].GET.copy()
79
80 field_name = get_params.get('sort', None)
81 if field_name:
82 del(get_params['sort'])
83
84 direction = get_params.get('dir', None)
85 if direction:
86 del(get_params['dir'])
87 direction = direction if direction in ('asc', 'desc') else 'asc'
88
89 # if is current field, and sort isn't defined,
90 # assume asc otherwise desc
91 direction = direction or (
92 (self.field_name == field_name) and 'asc' or 'desc')
93
94 # if current field and it's sorted, make link inverse,
95 # otherwise defalut to asc
96 if self.field_name == field_name:
97 get_params['dir'] = directions[direction]['inverse']
98 else:
99 get_params['dir'] = self.default_direction
100
101 if self.field_name == field_name:
102 css_class = directions[direction]['class']
103 else:
104 css_class = SORT_NONE_CLASS
105
106 params = (
107 "&%s" % (get_params.urlencode(),)
108 if len(get_params.keys()) > 0 else '')
109 url = '%s?sort=%s%s' % (
110 context['request'].path, self.field_name, params)
111
112 return (url, css_class)
113
114 def render(self, context):
115 url, css_class = self.build_link(context)
116 return '<a href="%s" class="%s" title="%s">%s</a>' % (
117 url, css_class, self.title, self.title)
118
119
120class SortableTableHeaderNode(SortableLinkNode):
121 """Build sortable link header based on query params."""
122
123 def render(self, context):
124 url, css_class = self.build_link(context)
125 return '<th class="%s"><a href="%s" title="%s">%s</a></th>' % (
126 css_class, url, self.title, self.title)
127
128
129class SortableURLNode(SortableLinkNode):
130 """Build sortable link header based on query params."""
131
132 def render(self, context):
133 url, css_class = self.build_link(context)
134 return url
135
136
137class SortableClassNode(SortableLinkNode):
138 """Build sortable link header based on query params."""
139
140 def render(self, context):
141 url, css_class = self.build_link(context)
142 return css_class
143
144
145def sortable_link(parser, token):
146 field, title = parse_tag_token(token)
147 return SortableLinkNode(field, title)
148
149
150def sortable_header(parser, token):
151 field, title = parse_tag_token(token)
152 return SortableTableHeaderNode(field, title)
153
154
155def sortable_url(parser, token):
156 field, title = parse_tag_token(token)
157 return SortableURLNode(field, title)
158
159
160def sortable_class(parser, token):
161 field, title = parse_tag_token(token)
162 return SortableClassNode(field, title)
163
164
165sortable_link = register.tag(sortable_link)
166sortable_header = register.tag(sortable_header)
167sortable_url = register.tag(sortable_url)
168sortable_class = register.tag(sortable_class)
0169
=== added directory 'src/maasserver/templatetags/tests'
=== added file 'src/maasserver/templatetags/tests/__init__.py'
=== added file 'src/maasserver/templatetags/tests/test_sortable.py'
--- src/maasserver/templatetags/tests/test_sortable.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/templatetags/tests/test_sortable.py 2012-10-22 09:32:21 +0000
@@ -0,0 +1,130 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4"""Sortable template tags tests."""
5
6from __future__ import (
7 absolute_import,
8 print_function,
9 unicode_literals,
10 )
11
12__metaclass__ = type
13
14from django.test import TestCase
15from django.test.client import RequestFactory
16from django.template import (
17 Template,
18 Context,
19 TemplateSyntaxError,
20 )
21
22
23class SortableTemplateTagsTests(TestCase):
24
25 def prepare_request(self, url, **kwargs):
26 return RequestFactory().get(
27 url,
28 data=kwargs,
29 )
30
31 def render_template(self, template, request):
32 return Template(
33 "{% load sortable %}" + template
34 ).render(Context({
35 'request': request,
36 }))
37
38 def test_sortable_link_tag_default_sort_with_extra_param(self):
39 request = self.prepare_request("some_url", param1="a")
40 out = self.render_template(
41 '{% sortable_link field "Title" %}',
42 request)
43 self.assertEqual(
44 out,
45 '<a href="some_url?sort=field&dir=asc&param1=a" '
46 'class="sort-none" title="Title">Title</a>')
47
48 def test_sortable_link_tag_with_sort_and_dir(self):
49 request = self.prepare_request(
50 "some_url",
51 sort="field",
52 dir="desc")
53 out = self.render_template(
54 '{% sortable_link field "Title" %}',
55 request)
56 self.assertEqual(
57 out,
58 '<a href="some_url?sort=field&dir=asc" '
59 'class="sort-desc" title="Title">Title</a>')
60
61 def test_sortable_link_tag_desc_sort(self):
62 request = self.prepare_request("some_url")
63 out = self.render_template(
64 '{% sortable_link -field "Title" %}',
65 request)
66 self.assertEqual(
67 out,
68 '<a href="some_url?sort=field&dir=desc" '
69 'class="sort-none" title="Title">Title</a>')
70
71 def test_sortable_link_tag_with_invalid_syntax_raises(self):
72 request = self.prepare_request("some_url")
73 self.assertRaises(
74 TemplateSyntaxError,
75 self.render_template,
76 "{% sortable_link %}", request)
77
78 def test_sortable_link_tag_without_title_capitalizes_field(self):
79 request = self.prepare_request("some_url")
80 out = self.render_template(
81 "{% sortable_link fieldname %}",
82 request)
83 self.assertEqual(
84 out,
85 '<a href="some_url?sort=fieldname&dir=asc" '
86 'class="sort-none" title="Fieldname">Fieldname</a>')
87
88 # Now test with explicit ascending order
89 out = self.render_template(
90 "{% sortable_link +fieldname %}",
91 request)
92 self.assertEqual(
93 out,
94 '<a href="some_url?sort=fieldname&dir=asc" '
95 'class="sort-none" title="Fieldname">Fieldname</a>')
96
97 def test_sortable_header_tag(self):
98 request = self.prepare_request(
99 "some_url",
100 sort="field",
101 dir="desc")
102 out = self.render_template(
103 '{% sortable_header other "Info" %}',
104 request)
105 self.assertEqual(
106 out,
107 '<th class="sort-none">'
108 '<a href="some_url?sort=other&dir=asc" '
109 'title="Info">Info</a></th>')
110
111 def test_sortable_url_tag(self):
112 request = self.prepare_request("some_url")
113 out = self.render_template(
114 '{% sortable_url field "Name" %}',
115 request)
116 self.assertEqual(
117 out,
118 "some_url?sort=field&dir=asc")
119
120 def test_sortable_class_tag(self):
121 request = self.prepare_request(
122 "some_url",
123 sort="field",
124 dir="desc")
125 out = self.render_template(
126 '<span class="{% sortable_class field %}">Name</span>',
127 request)
128 self.assertEqual(
129 out,
130 '<span class="sort-desc">Name</span>')
0131
=== modified file 'src/maasserver/tests/test_views_nodes.py'
--- src/maasserver/tests/test_views_nodes.py 2012-10-15 17:39:31 +0000
+++ src/maasserver/tests/test_views_nodes.py 2012-10-22 09:32:21 +0000
@@ -79,6 +79,78 @@
79 enlist_preseed_link = reverse('enlist-preseed-view')79 enlist_preseed_link = reverse('enlist-preseed-view')
80 self.assertIn(enlist_preseed_link, get_content_links(response))80 self.assertIn(enlist_preseed_link, get_content_links(response))
8181
82 def test_node_list_contains_column_sort_links(self):
83 # Just creare some nodes to have something in the list
84 for i in xrange(3):
85 factory.make_node()
86 response = self.client.get(reverse('node-list'))
87 sort_hostname = reverse('node-list') + '?sort=hostname&dir=asc'
88 sort_status = reverse('node-list') + '?sort=status&dir=asc'
89 self.assertIn(sort_hostname, get_content_links(response))
90 self.assertIn(sort_status, get_content_links(response))
91
92 def test_node_list_sorts_by_hostname(self):
93 names = ['zero', 'one', 'five']
94 nodes = [factory.make_node(hostname=n) for n in names]
95
96 # First check the ascending sort order
97 sorted_nodes = sorted(nodes, key=lambda x: x.hostname)
98 response = self.client.get(
99 reverse('node-list') + '?sort=hostname&dir=asc')
100 node_links = [
101 reverse('node-view', args=[node.system_id])
102 for node in sorted_nodes]
103 self.assertEqual(
104 node_links,
105 [link for link in get_content_links(response)
106 if link.startswith('/nodes/node')])
107
108 # Now check the reverse order
109 sorted_nodes = sorted(
110 nodes, key=lambda x: x.hostname, reverse=True)
111 response = self.client.get(
112 reverse('node-list') + '?sort=hostname&dir=desc')
113 node_links = [
114 reverse('node-view', args=[node.system_id])
115 for node in sorted_nodes]
116 self.assertEqual(
117 node_links,
118 [link for link in get_content_links(response)
119 if link.startswith('/nodes/node')])
120
121 def test_node_list_sorts_by_status(self):
122 statuses = {
123 NODE_STATUS.READY,
124 NODE_STATUS.DECLARED,
125 NODE_STATUS.FAILED_TESTS,
126 }
127 nodes = [factory.make_node(status=s) for s in statuses]
128
129 # First check the ascending sort order
130 sorted_nodes = sorted(nodes, key=lambda x: x.status)
131 response = self.client.get(
132 reverse('node-list') + '?sort=status&dir=asc')
133 node_links = [
134 reverse('node-view', args=[node.system_id])
135 for node in sorted_nodes]
136 self.assertEqual(
137 node_links,
138 [link for link in get_content_links(response)
139 if link.startswith('/nodes/node')])
140
141 # Now check the reverse order
142 sorted_nodes = sorted(
143 nodes, key=lambda x: x.status, reverse=True)
144 response = self.client.get(
145 reverse('node-list') + '?sort=status&dir=desc')
146 node_links = [
147 reverse('node-view', args=[node.system_id])
148 for node in sorted_nodes]
149 self.assertEqual(
150 node_links,
151 [link for link in get_content_links(response)
152 if link.startswith('/nodes/node')])
153
82 def test_node_list_displays_sorted_list_of_nodes(self):154 def test_node_list_displays_sorted_list_of_nodes(self):
83 # Nodes are sorted on the node list page, newest first.155 # Nodes are sorted on the node list page, newest first.
84 nodes = [factory.make_node() for i in range(3)]156 nodes = [factory.make_node() for i in range(3)]
@@ -421,7 +493,10 @@
421 {"query": "maas-tags=shiny cpu=2"})493 {"query": "maas-tags=shiny cpu=2"})
422 node2_link = reverse('node-view', args=[node2.system_id])494 node2_link = reverse('node-view', args=[node2.system_id])
423 document = fromstring(response.content)495 document = fromstring(response.content)
424 node_links = document.xpath("//div[@id='nodes']/table//a/@href")496 # Exclude header sorting links
497 node_links = filter(
498 lambda x: 'sort=' not in x,
499 document.xpath("//div[@id='nodes']/table//a/@href"))
425 self.assertEqual([node2_link], node_links)500 self.assertEqual([node2_link], node_links)
426501
427 def test_node_list_paginates(self):502 def test_node_list_paginates(self):
@@ -439,15 +514,18 @@
439 # Fetch first page, should link newest two nodes and page 2514 # Fetch first page, should link newest two nodes and page 2
440 response = self.client.get(reverse('node-list'))515 response = self.client.get(reverse('node-list'))
441 page1 = fromstring(response.content)516 page1 = fromstring(response.content)
442 self.assertEqual(node_links[:page_size], expr_node_links(page1))517 # Exclude header sorting links
518 self.assertEqual(node_links[:page_size],
519 filter(lambda x: 'sort=' not in x, expr_node_links(page1)))
443 self.assertEqual([("next", "?page=2"), ("last", "?page=3")],520 self.assertEqual([("next", "?page=2"), ("last", "?page=3")],
444 [(a.text.lower(), a.get("href"))521 [(a.text.lower(), a.get("href"))
445 for a in expr_page_anchors(page1)])522 for a in expr_page_anchors(page1)])
446 # Fetch second page, should link next nodes and adjacent pages523 # Fetch second page, should link next nodes and adjacent pages
447 response = self.client.get(reverse('node-list'), {"page": 2})524 response = self.client.get(reverse('node-list'), {"page": 2})
448 page2 = fromstring(response.content)525 page2 = fromstring(response.content)
526 # Exclude header sorting links
449 self.assertEqual(node_links[page_size:page_size * 2],527 self.assertEqual(node_links[page_size:page_size * 2],
450 expr_node_links(page2))528 filter(lambda x: 'sort=' not in x, expr_node_links(page2)))
451 self.assertEqual([("first", "."), ("previous", "."),529 self.assertEqual([("first", "."), ("previous", "."),
452 ("next", "?page=3"), ("last", "?page=3")],530 ("next", "?page=3"), ("last", "?page=3")],
453 [(a.text.lower(), a.get("href"))531 [(a.text.lower(), a.get("href"))
@@ -455,10 +533,15 @@
455 # Fetch third page, should link oldest node and node list page533 # Fetch third page, should link oldest node and node list page
456 response = self.client.get(reverse('node-list'), {"page": 3})534 response = self.client.get(reverse('node-list'), {"page": 3})
457 page3 = fromstring(response.content)535 page3 = fromstring(response.content)
458 self.assertEqual(node_links[page_size * 2:], expr_node_links(page3))536 # Exclude header sorting links
537 page3_node_links = filter(
538 lambda x: 'sort=' not in x, expr_node_links(page3))
539 page3_anchors = filter(
540 lambda x: 'sort=' not in x, expr_page_anchors(page3))
541 self.assertEqual(node_links[page_size * 2:], page3_node_links)
459 self.assertEqual([("first", "."), ("previous", "?page=2")],542 self.assertEqual([("first", "."), ("previous", "?page=2")],
460 [(a.text.lower(), a.get("href"))543 [(a.text.lower(), a.get("href"))
461 for a in expr_page_anchors(page3)])544 for a in page3_anchors])
462545
463 def test_node_list_query_paginates(self):546 def test_node_list_query_paginates(self):
464 """Node list query subset is split across multiple pages with links"""547 """Node list query subset is split across multiple pages with links"""
@@ -474,13 +557,35 @@
474 {"query": "maas-tags=odd", "page": 3})557 {"query": "maas-tags=odd", "page": 3})
475 document = fromstring(response.content)558 document = fromstring(response.content)
476 self.assertIn("5 matching nodes", document.xpath("string(//h1)"))559 self.assertIn("5 matching nodes", document.xpath("string(//h1)"))
560 # Exclude header sorting links
477 self.assertEqual([last_node_link],561 self.assertEqual([last_node_link],
478 document.xpath("//div[@id='nodes']/table//a/@href"))562 filter(
563 lambda x: 'sort=' not in x,
564 document.xpath("//div[@id='nodes']/table//a/@href")))
479 self.assertEqual([("first", "?query=maas-tags%3Dodd"),565 self.assertEqual([("first", "?query=maas-tags%3Dodd"),
480 ("previous", "?query=maas-tags%3Dodd&page=2")],566 ("previous", "?query=maas-tags%3Dodd&page=2")],
481 [(a.text.lower(), a.get("href"))567 [(a.text.lower(), a.get("href"))
482 for a in document.xpath("//div[@class='pagination']//a")])568 for a in document.xpath("//div[@class='pagination']//a")])
483569
570 def test_node_list_paginates_while_sorted(self):
571 # Set a very small page size to save creating lots of nodes
572 self.patch(nodes_views.NodeListView, 'paginate_by', 2)
573 nodes = [factory.make_node(hostname=name)
574 for name in ('zero', 'four', 'two', 'one', 'five')]
575 page1_links = [
576 reverse('node-view', args=[nodes[4].system_id]),
577 reverse('node-view', args=[nodes[1].system_id])]
578 response = self.client.get(
579 reverse('node-list') + '?sort=hostname&dir=asc')
580 document = fromstring(response.content)
581 self.assertIn("5 nodes in", document.xpath("string(//h1)"))
582 # Exclude header sorting links
583 self.assertEqual(
584 page1_links,
585 filter(
586 lambda x: 'sort=' not in x,
587 document.xpath("//div[@id='nodes']/table//a/@href")))
588
484589
485class NodePreseedViewTest(LoggedInTestCase):590class NodePreseedViewTest(LoggedInTestCase):
486591
487592
=== added directory 'src/maasserver/utils/sortable'
=== added file 'src/maasserver/utils/sortable/__init__.py'
--- src/maasserver/utils/sortable/__init__.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/utils/sortable/__init__.py 2012-10-22 09:32:21 +0000
@@ -0,0 +1,25 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4# Most of this code is taken from django-sortable project:
5# https://github.com/drewyeaton/django-sortable
6# and formatted to fit our style guide
7
8"""Sortable - helper to make sorting table columns in templates easier."""
9
10from __future__ import (
11 absolute_import,
12 print_function,
13 unicode_literals,
14 )
15
16__metaclass__ = type
17__all__ = [
18 'SortableInvalidObjectsException',
19 'Sortable',
20 ]
21
22from maasserver.utils.sortable.sortable import (
23 SortableInvalidObjectsException,
24 Sortable,
25 )
026
=== added file 'src/maasserver/utils/sortable/sortable.py'
--- src/maasserver/utils/sortable/sortable.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/utils/sortable/sortable.py 2012-10-22 09:32:21 +0000
@@ -0,0 +1,161 @@
1# Copyright 2012 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4# Most of this code is taken from django-sortable project:
5# https://github.com/drewyeaton/django-sortable
6# and formatted to fit our style guide
7
8"""Sortable - helper to make sorting table columns in templates easier."""
9
10from __future__ import (
11 absolute_import,
12 print_function,
13 unicode_literals,
14 )
15
16__metaclass__ = type
17__all__ = [
18 'SortableInvalidObjectsException',
19 'Sortable',
20 ]
21
22from operator import itemgetter, attrgetter
23
24
25class SortableInvalidObjectsException(Exception):
26 pass
27
28
29class Sortable(object):
30 """Takes a QuerySet and allows sorting by fields."""
31
32 def __init__(self, objects, fields):
33 super(Sortable, self).__init__()
34 self.objects = objects
35 self.fields = None
36 self.set_normalized_fields(fields)
37
38 def set_normalized_fields(self, fields):
39 """Takes name-to-field mapping tuple, normalizes it,
40 and sets field.
41 """
42 if fields is None:
43 return
44
45 field_list = []
46 for f in fields:
47 if isinstance(f, basestring):
48 field_list.append((f, (f,)))
49 elif isinstance(f[1], basestring):
50 field_list.append((f[0], (f[1],)))
51 else:
52 field_list.append(f)
53 self.fields = dict(field_list)
54
55 def sorted(self, field_name, direction='asc'):
56 """Returns QuerySet with order_by applied
57 or sorted list of dictionary.
58 """
59
60 if self.fields:
61 try:
62 fields = self.fields[field_name]
63 except KeyError:
64 return self.objects
65 else:
66 fields = (field_name,)
67
68 if direction not in ('asc', 'desc'):
69 return self.objects
70
71 fields = Sortable.prepare_fields(fields, direction)
72
73 if hasattr(self.objects, 'order_by'):
74 result = self.objects.order_by(*fields)
75 elif isinstance(self.objects, (list, tuple)):
76 if len(self.objects) < 2:
77 return self.objects
78
79 comparers = []
80 if isinstance(self.objects[0], dict):
81 getter = itemgetter
82 else:
83 getter = attrgetter
84
85 for f in fields:
86 field = f[1:] if f.startswith('-') else f
87 comparers.append((getter(field), 1 if field == f else -1))
88
89 def comparer(left, right):
90 for func, polarity in comparers:
91 result = cmp(func(left), func(right))
92 return 0 if not result else polarity * result
93
94 result = sorted(self.objects, cmp=comparer)
95 else:
96 raise SortableInvalidObjectsException(
97 'An object of this type can not be sorted.')
98
99 return result
100
101 def sql_predicate(self, field_name, direction='asc', default=None):
102 """Returns a predicate for use in a SQL ORDER BY clause."""
103
104 if self.fields:
105 try:
106 fields = self.fields[field_name]
107 except KeyError:
108 fields = default
109 else:
110 fields = field_name
111
112 if direction not in ('asc', 'desc'):
113 fields = default
114
115 fields = Sortable.prepare_fields(
116 fields, direction, sql_predicate=True)
117 return ', '.join(fields)
118
119 @staticmethod
120 def prepare_fields(fields, direction, sql_predicate=False):
121 """Given a list or tuple of fields and direction,
122 return a list of fields with their appropriate
123 order_by direction applied.
124
125 >>> fields = ['++one', '--two', '+three', 'four', '-five']
126 >>> Sortable.prepare_fields(fields, 'asc')
127 ['one', '-two', 'three', 'four', '-five']
128 >>> Sortable.prepare_fields(fields, 'desc')
129 ['one', '-two', '-three', '-four', 'five']
130 >>> Sortable.prepare_fields(fields, 'not_asc_or_desc')
131 ['one', '-two', 'three', 'four', '-five']
132 >>> Sortable.prepare_fields(fields, 'desc', True)
133 ['one ASC', 'two DESC', 'three DESC', 'four DESC', 'five ASC']
134 """
135
136 if direction not in ('asc', 'desc'):
137 direction = 'asc'
138
139 fields = list(fields)
140 for i, field in enumerate(fields):
141 if field.startswith('--'):
142 fields[i] = field[1:]
143 elif field.startswith('++'):
144 fields[i] = field[2:]
145 elif field.startswith('-'):
146 fields[i] = (direction == 'asc' and '-' or '') + field[1:]
147 else:
148 field = field[1:] if field.startswith('+') else field
149 fields[i] = (direction == 'desc' and '-' or '') + field
150
151 if not sql_predicate:
152 return fields
153
154 # determine sql predicates from list
155 fields = list(fields)
156 for i, field in enumerate(fields):
157 if field.startswith('-'):
158 fields[i] = '%s DESC' % (field[1:],)
159 else:
160 fields[i] = '%s ASC' % (field,)
161 return fields
0162
=== modified file 'src/maasserver/views/nodes.py'
--- src/maasserver/views/nodes.py 2012-10-19 13:55:51 +0000
+++ src/maasserver/views/nodes.py 2012-10-22 09:32:21 +0000
@@ -67,6 +67,7 @@
67 HelpfulDeleteView,67 HelpfulDeleteView,
68 PaginatedListView,68 PaginatedListView,
69 )69 )
70from maasserver.utils.sortable import Sortable
7071
7172
72def get_longpoll_context():73def get_longpoll_context():
@@ -110,6 +111,8 @@
110 def get(self, request, *args, **kwargs):111 def get(self, request, *args, **kwargs):
111 self.query = request.GET.get("query")112 self.query = request.GET.get("query")
112 self.query_error = None113 self.query_error = None
114 self.sort_field = request.GET.get('sort', '')
115 self.sort_direction = request.GET.get('dir', 'asc')
113 return super(NodeListView, self).get(request, *args, **kwargs)116 return super(NodeListView, self).get(request, *args, **kwargs)
114117
115 def get_queryset(self):118 def get_queryset(self):
@@ -117,6 +120,9 @@
117 nodes = Node.objects.get_nodes(120 nodes = Node.objects.get_nodes(
118 user=self.request.user, prefetch_mac=True,121 user=self.request.user, prefetch_mac=True,
119 perm=NODE_PERMISSION.VIEW,).order_by('-created')122 perm=NODE_PERMISSION.VIEW,).order_by('-created')
123 sortable = Sortable(nodes, ('hostname', 'status'))
124 nodes = sortable.sorted(self.sort_field, self.sort_direction)
125
120 if self.query:126 if self.query:
121 try:127 try:
122 return constrain_nodes(nodes, _parse_constraints(self.query))128 return constrain_nodes(nodes, _parse_constraints(self.query))