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
1=== modified file 'src/maasserver/static/css/components/table_list.css'
2--- src/maasserver/static/css/components/table_list.css 2012-04-05 06:23:59 +0000
3+++ src/maasserver/static/css/components/table_list.css 2012-10-22 09:32:21 +0000
4@@ -30,6 +30,17 @@
5 border-color: #dd4814;
6 cursor: pointer;
7 }
8+.sort-none, .sort-asc, .sort-desc {
9+ background-repeat: no-repeat;
10+ background-position: right 0.4em;
11+ padding-right: 1.2em;
12+ }
13+.sort-asc {
14+ background-image: url('/static/img/up_arrow_dark.png');
15+ }
16+.sort-desc {
17+ background-image: url('/static/img/down_arrow_dark.png');
18+ }
19 /* ul list */
20 ul.list {
21 list-style: none;
22
23=== added file 'src/maasserver/static/img/down_arrow_dark.png'
24Binary 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
25=== added file 'src/maasserver/static/img/up_arrow_dark.png'
26Binary 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
27=== modified file 'src/maasserver/templates/maasserver/node_list.html'
28--- src/maasserver/templates/maasserver/node_list.html 2012-10-12 13:33:09 +0000
29+++ src/maasserver/templates/maasserver/node_list.html 2012-10-22 09:32:21 +0000
30@@ -38,7 +38,7 @@
31 {% if input_query_error %}
32 <p class="form-errors">{{input_query_error}}</p>
33 {% endif %}
34- {% include "maasserver/nodes_listing.html" %}
35+ {% include "maasserver/nodes_listing.html" with sortable="true" %}
36 {% include "maasserver/pagination.html" %}
37 <a id="addnode" href="#" class="button right space-top">+ Add node</a>
38 <div class="clear"></div>
39
40=== modified file 'src/maasserver/templates/maasserver/nodes_listing.html'
41--- src/maasserver/templates/maasserver/nodes_listing.html 2012-10-10 08:13:01 +0000
42+++ src/maasserver/templates/maasserver/nodes_listing.html 2012-10-22 09:32:21 +0000
43@@ -1,9 +1,16 @@
44+{% load sortable %}
45+
46 {% if node_list|length %}
47 <table class="list">
48 <thead>
49 <tr>
50- <th>MAC</th>
51- <th>Status</th>
52+ {% if sortable == "true" %}
53+ <th>{% sortable_link hostname "MAC" %}</th>
54+ <th>{% sortable_link status "Status" %}</th>
55+ {% else %}
56+ <th>MAC</th>
57+ <td>Status</th>
58+ {% endif %}
59 </tr>
60 </thead>
61 {% for node in node_list %}
62
63=== modified file 'src/maasserver/templates/maasserver/tag_view.html'
64--- src/maasserver/templates/maasserver/tag_view.html 2012-10-18 11:26:02 +0000
65+++ src/maasserver/templates/maasserver/tag_view.html 2012-10-22 09:32:21 +0000
66@@ -34,7 +34,7 @@
67 </ul>
68 <div id="nodes" class="block first pad-top">
69 <h2 class="block first line-top pad-top">{{ paginator.count }} Node{{ paginator.count|pluralize }}</h2>
70- {% include "maasserver/nodes_listing.html" %}
71+ {% include "maasserver/nodes_listing.html" with sortable="false" %}
72 {% include "maasserver/pagination.html" %}
73 </div>
74 {% endblock %}
75
76=== added file 'src/maasserver/templatetags/sortable.py'
77--- src/maasserver/templatetags/sortable.py 1970-01-01 00:00:00 +0000
78+++ src/maasserver/templatetags/sortable.py 2012-10-22 09:32:21 +0000
79@@ -0,0 +1,168 @@
80+# Copyright 2012 Canonical Ltd. This software is licensed under the
81+# GNU Affero General Public License version 3 (see the file LICENSE).
82+
83+# Most of this code is taken from django-sortable project:
84+# https://github.com/drewyeaton/django-sortable
85+# and formatted to fit our style guide
86+
87+"""Field type template tag."""
88+
89+from __future__ import (
90+ absolute_import,
91+ print_function,
92+ unicode_literals,
93+ )
94+
95+__metaclass__ = type
96+__all__ = [
97+ "sortable_link",
98+ "sortable_header",
99+ "sortable_url",
100+ "sortable_class",
101+ ]
102+
103+from django.conf import settings
104+from django import template
105+
106+
107+register = template.Library()
108+
109+# CSS classes, used by the sortable_class tag
110+SORT_ASC_CLASS = getattr(settings, 'SORT_ASC_CLASS', 'sort-asc')
111+SORT_DESC_CLASS = getattr(settings, 'SORT_DESC_CLASS', 'sort-desc')
112+SORT_NONE_CLASS = getattr(settings, 'SORT_DESC_CLASS', 'sort-none')
113+
114+directions = {
115+ 'asc': {'class': SORT_ASC_CLASS, 'inverse': 'desc'},
116+ 'desc': {'class': SORT_DESC_CLASS, 'inverse': 'asc'},
117+ }
118+
119+
120+def parse_tag_token(token):
121+ """Parses a tag that's supposed to be in this format:
122+ {% sortable_link field title %}
123+ """
124+ bits = [b.strip('"\'') for b in token.split_contents()]
125+ if len(bits) < 2:
126+ raise template.TemplateSyntaxError(
127+ "anchor tag takes at least 1 argument")
128+ try:
129+ title = bits[2]
130+ except IndexError:
131+ if bits[1].startswith(('+', '-')):
132+ title = bits[1][1:].capitalize()
133+ else:
134+ title = bits[1].capitalize()
135+
136+ return (bits[1].strip(), title.strip())
137+
138+
139+class SortableLinkNode(template.Node):
140+ """Build sortable link based on query params."""
141+
142+ def __init__(self, field_name, title):
143+ if field_name.startswith('-'):
144+ self.field_name = field_name[1:]
145+ self.default_direction = 'desc'
146+ elif field_name.startswith('+'):
147+ self.field_name = field_name[1:]
148+ self.default_direction = 'asc'
149+ else:
150+ self.field_name = field_name
151+ self.default_direction = 'asc'
152+
153+ self.title = title
154+
155+ def build_link(self, context):
156+ """Prepare link for rendering based on context."""
157+ get_params = context['request'].GET.copy()
158+
159+ field_name = get_params.get('sort', None)
160+ if field_name:
161+ del(get_params['sort'])
162+
163+ direction = get_params.get('dir', None)
164+ if direction:
165+ del(get_params['dir'])
166+ direction = direction if direction in ('asc', 'desc') else 'asc'
167+
168+ # if is current field, and sort isn't defined,
169+ # assume asc otherwise desc
170+ direction = direction or (
171+ (self.field_name == field_name) and 'asc' or 'desc')
172+
173+ # if current field and it's sorted, make link inverse,
174+ # otherwise defalut to asc
175+ if self.field_name == field_name:
176+ get_params['dir'] = directions[direction]['inverse']
177+ else:
178+ get_params['dir'] = self.default_direction
179+
180+ if self.field_name == field_name:
181+ css_class = directions[direction]['class']
182+ else:
183+ css_class = SORT_NONE_CLASS
184+
185+ params = (
186+ "&%s" % (get_params.urlencode(),)
187+ if len(get_params.keys()) > 0 else '')
188+ url = '%s?sort=%s%s' % (
189+ context['request'].path, self.field_name, params)
190+
191+ return (url, css_class)
192+
193+ def render(self, context):
194+ url, css_class = self.build_link(context)
195+ return '<a href="%s" class="%s" title="%s">%s</a>' % (
196+ url, css_class, self.title, self.title)
197+
198+
199+class SortableTableHeaderNode(SortableLinkNode):
200+ """Build sortable link header based on query params."""
201+
202+ def render(self, context):
203+ url, css_class = self.build_link(context)
204+ return '<th class="%s"><a href="%s" title="%s">%s</a></th>' % (
205+ css_class, url, self.title, self.title)
206+
207+
208+class SortableURLNode(SortableLinkNode):
209+ """Build sortable link header based on query params."""
210+
211+ def render(self, context):
212+ url, css_class = self.build_link(context)
213+ return url
214+
215+
216+class SortableClassNode(SortableLinkNode):
217+ """Build sortable link header based on query params."""
218+
219+ def render(self, context):
220+ url, css_class = self.build_link(context)
221+ return css_class
222+
223+
224+def sortable_link(parser, token):
225+ field, title = parse_tag_token(token)
226+ return SortableLinkNode(field, title)
227+
228+
229+def sortable_header(parser, token):
230+ field, title = parse_tag_token(token)
231+ return SortableTableHeaderNode(field, title)
232+
233+
234+def sortable_url(parser, token):
235+ field, title = parse_tag_token(token)
236+ return SortableURLNode(field, title)
237+
238+
239+def sortable_class(parser, token):
240+ field, title = parse_tag_token(token)
241+ return SortableClassNode(field, title)
242+
243+
244+sortable_link = register.tag(sortable_link)
245+sortable_header = register.tag(sortable_header)
246+sortable_url = register.tag(sortable_url)
247+sortable_class = register.tag(sortable_class)
248
249=== added directory 'src/maasserver/templatetags/tests'
250=== added file 'src/maasserver/templatetags/tests/__init__.py'
251=== added file 'src/maasserver/templatetags/tests/test_sortable.py'
252--- src/maasserver/templatetags/tests/test_sortable.py 1970-01-01 00:00:00 +0000
253+++ src/maasserver/templatetags/tests/test_sortable.py 2012-10-22 09:32:21 +0000
254@@ -0,0 +1,130 @@
255+# Copyright 2012 Canonical Ltd. This software is licensed under the
256+# GNU Affero General Public License version 3 (see the file LICENSE).
257+
258+"""Sortable template tags tests."""
259+
260+from __future__ import (
261+ absolute_import,
262+ print_function,
263+ unicode_literals,
264+ )
265+
266+__metaclass__ = type
267+
268+from django.test import TestCase
269+from django.test.client import RequestFactory
270+from django.template import (
271+ Template,
272+ Context,
273+ TemplateSyntaxError,
274+ )
275+
276+
277+class SortableTemplateTagsTests(TestCase):
278+
279+ def prepare_request(self, url, **kwargs):
280+ return RequestFactory().get(
281+ url,
282+ data=kwargs,
283+ )
284+
285+ def render_template(self, template, request):
286+ return Template(
287+ "{% load sortable %}" + template
288+ ).render(Context({
289+ 'request': request,
290+ }))
291+
292+ def test_sortable_link_tag_default_sort_with_extra_param(self):
293+ request = self.prepare_request("some_url", param1="a")
294+ out = self.render_template(
295+ '{% sortable_link field "Title" %}',
296+ request)
297+ self.assertEqual(
298+ out,
299+ '<a href="some_url?sort=field&dir=asc&param1=a" '
300+ 'class="sort-none" title="Title">Title</a>')
301+
302+ def test_sortable_link_tag_with_sort_and_dir(self):
303+ request = self.prepare_request(
304+ "some_url",
305+ sort="field",
306+ dir="desc")
307+ out = self.render_template(
308+ '{% sortable_link field "Title" %}',
309+ request)
310+ self.assertEqual(
311+ out,
312+ '<a href="some_url?sort=field&dir=asc" '
313+ 'class="sort-desc" title="Title">Title</a>')
314+
315+ def test_sortable_link_tag_desc_sort(self):
316+ request = self.prepare_request("some_url")
317+ out = self.render_template(
318+ '{% sortable_link -field "Title" %}',
319+ request)
320+ self.assertEqual(
321+ out,
322+ '<a href="some_url?sort=field&dir=desc" '
323+ 'class="sort-none" title="Title">Title</a>')
324+
325+ def test_sortable_link_tag_with_invalid_syntax_raises(self):
326+ request = self.prepare_request("some_url")
327+ self.assertRaises(
328+ TemplateSyntaxError,
329+ self.render_template,
330+ "{% sortable_link %}", request)
331+
332+ def test_sortable_link_tag_without_title_capitalizes_field(self):
333+ request = self.prepare_request("some_url")
334+ out = self.render_template(
335+ "{% sortable_link fieldname %}",
336+ request)
337+ self.assertEqual(
338+ out,
339+ '<a href="some_url?sort=fieldname&dir=asc" '
340+ 'class="sort-none" title="Fieldname">Fieldname</a>')
341+
342+ # Now test with explicit ascending order
343+ out = self.render_template(
344+ "{% sortable_link +fieldname %}",
345+ request)
346+ self.assertEqual(
347+ out,
348+ '<a href="some_url?sort=fieldname&dir=asc" '
349+ 'class="sort-none" title="Fieldname">Fieldname</a>')
350+
351+ def test_sortable_header_tag(self):
352+ request = self.prepare_request(
353+ "some_url",
354+ sort="field",
355+ dir="desc")
356+ out = self.render_template(
357+ '{% sortable_header other "Info" %}',
358+ request)
359+ self.assertEqual(
360+ out,
361+ '<th class="sort-none">'
362+ '<a href="some_url?sort=other&dir=asc" '
363+ 'title="Info">Info</a></th>')
364+
365+ def test_sortable_url_tag(self):
366+ request = self.prepare_request("some_url")
367+ out = self.render_template(
368+ '{% sortable_url field "Name" %}',
369+ request)
370+ self.assertEqual(
371+ out,
372+ "some_url?sort=field&dir=asc")
373+
374+ def test_sortable_class_tag(self):
375+ request = self.prepare_request(
376+ "some_url",
377+ sort="field",
378+ dir="desc")
379+ out = self.render_template(
380+ '<span class="{% sortable_class field %}">Name</span>',
381+ request)
382+ self.assertEqual(
383+ out,
384+ '<span class="sort-desc">Name</span>')
385
386=== modified file 'src/maasserver/tests/test_views_nodes.py'
387--- src/maasserver/tests/test_views_nodes.py 2012-10-15 17:39:31 +0000
388+++ src/maasserver/tests/test_views_nodes.py 2012-10-22 09:32:21 +0000
389@@ -79,6 +79,78 @@
390 enlist_preseed_link = reverse('enlist-preseed-view')
391 self.assertIn(enlist_preseed_link, get_content_links(response))
392
393+ def test_node_list_contains_column_sort_links(self):
394+ # Just creare some nodes to have something in the list
395+ for i in xrange(3):
396+ factory.make_node()
397+ response = self.client.get(reverse('node-list'))
398+ sort_hostname = reverse('node-list') + '?sort=hostname&dir=asc'
399+ sort_status = reverse('node-list') + '?sort=status&dir=asc'
400+ self.assertIn(sort_hostname, get_content_links(response))
401+ self.assertIn(sort_status, get_content_links(response))
402+
403+ def test_node_list_sorts_by_hostname(self):
404+ names = ['zero', 'one', 'five']
405+ nodes = [factory.make_node(hostname=n) for n in names]
406+
407+ # First check the ascending sort order
408+ sorted_nodes = sorted(nodes, key=lambda x: x.hostname)
409+ response = self.client.get(
410+ reverse('node-list') + '?sort=hostname&dir=asc')
411+ node_links = [
412+ reverse('node-view', args=[node.system_id])
413+ for node in sorted_nodes]
414+ self.assertEqual(
415+ node_links,
416+ [link for link in get_content_links(response)
417+ if link.startswith('/nodes/node')])
418+
419+ # Now check the reverse order
420+ sorted_nodes = sorted(
421+ nodes, key=lambda x: x.hostname, reverse=True)
422+ response = self.client.get(
423+ reverse('node-list') + '?sort=hostname&dir=desc')
424+ node_links = [
425+ reverse('node-view', args=[node.system_id])
426+ for node in sorted_nodes]
427+ self.assertEqual(
428+ node_links,
429+ [link for link in get_content_links(response)
430+ if link.startswith('/nodes/node')])
431+
432+ def test_node_list_sorts_by_status(self):
433+ statuses = {
434+ NODE_STATUS.READY,
435+ NODE_STATUS.DECLARED,
436+ NODE_STATUS.FAILED_TESTS,
437+ }
438+ nodes = [factory.make_node(status=s) for s in statuses]
439+
440+ # First check the ascending sort order
441+ sorted_nodes = sorted(nodes, key=lambda x: x.status)
442+ response = self.client.get(
443+ reverse('node-list') + '?sort=status&dir=asc')
444+ node_links = [
445+ reverse('node-view', args=[node.system_id])
446+ for node in sorted_nodes]
447+ self.assertEqual(
448+ node_links,
449+ [link for link in get_content_links(response)
450+ if link.startswith('/nodes/node')])
451+
452+ # Now check the reverse order
453+ sorted_nodes = sorted(
454+ nodes, key=lambda x: x.status, reverse=True)
455+ response = self.client.get(
456+ reverse('node-list') + '?sort=status&dir=desc')
457+ node_links = [
458+ reverse('node-view', args=[node.system_id])
459+ for node in sorted_nodes]
460+ self.assertEqual(
461+ node_links,
462+ [link for link in get_content_links(response)
463+ if link.startswith('/nodes/node')])
464+
465 def test_node_list_displays_sorted_list_of_nodes(self):
466 # Nodes are sorted on the node list page, newest first.
467 nodes = [factory.make_node() for i in range(3)]
468@@ -421,7 +493,10 @@
469 {"query": "maas-tags=shiny cpu=2"})
470 node2_link = reverse('node-view', args=[node2.system_id])
471 document = fromstring(response.content)
472- node_links = document.xpath("//div[@id='nodes']/table//a/@href")
473+ # Exclude header sorting links
474+ node_links = filter(
475+ lambda x: 'sort=' not in x,
476+ document.xpath("//div[@id='nodes']/table//a/@href"))
477 self.assertEqual([node2_link], node_links)
478
479 def test_node_list_paginates(self):
480@@ -439,15 +514,18 @@
481 # Fetch first page, should link newest two nodes and page 2
482 response = self.client.get(reverse('node-list'))
483 page1 = fromstring(response.content)
484- self.assertEqual(node_links[:page_size], expr_node_links(page1))
485+ # Exclude header sorting links
486+ self.assertEqual(node_links[:page_size],
487+ filter(lambda x: 'sort=' not in x, expr_node_links(page1)))
488 self.assertEqual([("next", "?page=2"), ("last", "?page=3")],
489 [(a.text.lower(), a.get("href"))
490 for a in expr_page_anchors(page1)])
491 # Fetch second page, should link next nodes and adjacent pages
492 response = self.client.get(reverse('node-list'), {"page": 2})
493 page2 = fromstring(response.content)
494+ # Exclude header sorting links
495 self.assertEqual(node_links[page_size:page_size * 2],
496- expr_node_links(page2))
497+ filter(lambda x: 'sort=' not in x, expr_node_links(page2)))
498 self.assertEqual([("first", "."), ("previous", "."),
499 ("next", "?page=3"), ("last", "?page=3")],
500 [(a.text.lower(), a.get("href"))
501@@ -455,10 +533,15 @@
502 # Fetch third page, should link oldest node and node list page
503 response = self.client.get(reverse('node-list'), {"page": 3})
504 page3 = fromstring(response.content)
505- self.assertEqual(node_links[page_size * 2:], expr_node_links(page3))
506+ # Exclude header sorting links
507+ page3_node_links = filter(
508+ lambda x: 'sort=' not in x, expr_node_links(page3))
509+ page3_anchors = filter(
510+ lambda x: 'sort=' not in x, expr_page_anchors(page3))
511+ self.assertEqual(node_links[page_size * 2:], page3_node_links)
512 self.assertEqual([("first", "."), ("previous", "?page=2")],
513 [(a.text.lower(), a.get("href"))
514- for a in expr_page_anchors(page3)])
515+ for a in page3_anchors])
516
517 def test_node_list_query_paginates(self):
518 """Node list query subset is split across multiple pages with links"""
519@@ -474,13 +557,35 @@
520 {"query": "maas-tags=odd", "page": 3})
521 document = fromstring(response.content)
522 self.assertIn("5 matching nodes", document.xpath("string(//h1)"))
523+ # Exclude header sorting links
524 self.assertEqual([last_node_link],
525- document.xpath("//div[@id='nodes']/table//a/@href"))
526+ filter(
527+ lambda x: 'sort=' not in x,
528+ document.xpath("//div[@id='nodes']/table//a/@href")))
529 self.assertEqual([("first", "?query=maas-tags%3Dodd"),
530 ("previous", "?query=maas-tags%3Dodd&page=2")],
531 [(a.text.lower(), a.get("href"))
532 for a in document.xpath("//div[@class='pagination']//a")])
533
534+ def test_node_list_paginates_while_sorted(self):
535+ # Set a very small page size to save creating lots of nodes
536+ self.patch(nodes_views.NodeListView, 'paginate_by', 2)
537+ nodes = [factory.make_node(hostname=name)
538+ for name in ('zero', 'four', 'two', 'one', 'five')]
539+ page1_links = [
540+ reverse('node-view', args=[nodes[4].system_id]),
541+ reverse('node-view', args=[nodes[1].system_id])]
542+ response = self.client.get(
543+ reverse('node-list') + '?sort=hostname&dir=asc')
544+ document = fromstring(response.content)
545+ self.assertIn("5 nodes in", document.xpath("string(//h1)"))
546+ # Exclude header sorting links
547+ self.assertEqual(
548+ page1_links,
549+ filter(
550+ lambda x: 'sort=' not in x,
551+ document.xpath("//div[@id='nodes']/table//a/@href")))
552+
553
554 class NodePreseedViewTest(LoggedInTestCase):
555
556
557=== added directory 'src/maasserver/utils/sortable'
558=== added file 'src/maasserver/utils/sortable/__init__.py'
559--- src/maasserver/utils/sortable/__init__.py 1970-01-01 00:00:00 +0000
560+++ src/maasserver/utils/sortable/__init__.py 2012-10-22 09:32:21 +0000
561@@ -0,0 +1,25 @@
562+# Copyright 2012 Canonical Ltd. This software is licensed under the
563+# GNU Affero General Public License version 3 (see the file LICENSE).
564+
565+# Most of this code is taken from django-sortable project:
566+# https://github.com/drewyeaton/django-sortable
567+# and formatted to fit our style guide
568+
569+"""Sortable - helper to make sorting table columns in templates easier."""
570+
571+from __future__ import (
572+ absolute_import,
573+ print_function,
574+ unicode_literals,
575+ )
576+
577+__metaclass__ = type
578+__all__ = [
579+ 'SortableInvalidObjectsException',
580+ 'Sortable',
581+ ]
582+
583+from maasserver.utils.sortable.sortable import (
584+ SortableInvalidObjectsException,
585+ Sortable,
586+ )
587
588=== added file 'src/maasserver/utils/sortable/sortable.py'
589--- src/maasserver/utils/sortable/sortable.py 1970-01-01 00:00:00 +0000
590+++ src/maasserver/utils/sortable/sortable.py 2012-10-22 09:32:21 +0000
591@@ -0,0 +1,161 @@
592+# Copyright 2012 Canonical Ltd. This software is licensed under the
593+# GNU Affero General Public License version 3 (see the file LICENSE).
594+
595+# Most of this code is taken from django-sortable project:
596+# https://github.com/drewyeaton/django-sortable
597+# and formatted to fit our style guide
598+
599+"""Sortable - helper to make sorting table columns in templates easier."""
600+
601+from __future__ import (
602+ absolute_import,
603+ print_function,
604+ unicode_literals,
605+ )
606+
607+__metaclass__ = type
608+__all__ = [
609+ 'SortableInvalidObjectsException',
610+ 'Sortable',
611+ ]
612+
613+from operator import itemgetter, attrgetter
614+
615+
616+class SortableInvalidObjectsException(Exception):
617+ pass
618+
619+
620+class Sortable(object):
621+ """Takes a QuerySet and allows sorting by fields."""
622+
623+ def __init__(self, objects, fields):
624+ super(Sortable, self).__init__()
625+ self.objects = objects
626+ self.fields = None
627+ self.set_normalized_fields(fields)
628+
629+ def set_normalized_fields(self, fields):
630+ """Takes name-to-field mapping tuple, normalizes it,
631+ and sets field.
632+ """
633+ if fields is None:
634+ return
635+
636+ field_list = []
637+ for f in fields:
638+ if isinstance(f, basestring):
639+ field_list.append((f, (f,)))
640+ elif isinstance(f[1], basestring):
641+ field_list.append((f[0], (f[1],)))
642+ else:
643+ field_list.append(f)
644+ self.fields = dict(field_list)
645+
646+ def sorted(self, field_name, direction='asc'):
647+ """Returns QuerySet with order_by applied
648+ or sorted list of dictionary.
649+ """
650+
651+ if self.fields:
652+ try:
653+ fields = self.fields[field_name]
654+ except KeyError:
655+ return self.objects
656+ else:
657+ fields = (field_name,)
658+
659+ if direction not in ('asc', 'desc'):
660+ return self.objects
661+
662+ fields = Sortable.prepare_fields(fields, direction)
663+
664+ if hasattr(self.objects, 'order_by'):
665+ result = self.objects.order_by(*fields)
666+ elif isinstance(self.objects, (list, tuple)):
667+ if len(self.objects) < 2:
668+ return self.objects
669+
670+ comparers = []
671+ if isinstance(self.objects[0], dict):
672+ getter = itemgetter
673+ else:
674+ getter = attrgetter
675+
676+ for f in fields:
677+ field = f[1:] if f.startswith('-') else f
678+ comparers.append((getter(field), 1 if field == f else -1))
679+
680+ def comparer(left, right):
681+ for func, polarity in comparers:
682+ result = cmp(func(left), func(right))
683+ return 0 if not result else polarity * result
684+
685+ result = sorted(self.objects, cmp=comparer)
686+ else:
687+ raise SortableInvalidObjectsException(
688+ 'An object of this type can not be sorted.')
689+
690+ return result
691+
692+ def sql_predicate(self, field_name, direction='asc', default=None):
693+ """Returns a predicate for use in a SQL ORDER BY clause."""
694+
695+ if self.fields:
696+ try:
697+ fields = self.fields[field_name]
698+ except KeyError:
699+ fields = default
700+ else:
701+ fields = field_name
702+
703+ if direction not in ('asc', 'desc'):
704+ fields = default
705+
706+ fields = Sortable.prepare_fields(
707+ fields, direction, sql_predicate=True)
708+ return ', '.join(fields)
709+
710+ @staticmethod
711+ def prepare_fields(fields, direction, sql_predicate=False):
712+ """Given a list or tuple of fields and direction,
713+ return a list of fields with their appropriate
714+ order_by direction applied.
715+
716+ >>> fields = ['++one', '--two', '+three', 'four', '-five']
717+ >>> Sortable.prepare_fields(fields, 'asc')
718+ ['one', '-two', 'three', 'four', '-five']
719+ >>> Sortable.prepare_fields(fields, 'desc')
720+ ['one', '-two', '-three', '-four', 'five']
721+ >>> Sortable.prepare_fields(fields, 'not_asc_or_desc')
722+ ['one', '-two', 'three', 'four', '-five']
723+ >>> Sortable.prepare_fields(fields, 'desc', True)
724+ ['one ASC', 'two DESC', 'three DESC', 'four DESC', 'five ASC']
725+ """
726+
727+ if direction not in ('asc', 'desc'):
728+ direction = 'asc'
729+
730+ fields = list(fields)
731+ for i, field in enumerate(fields):
732+ if field.startswith('--'):
733+ fields[i] = field[1:]
734+ elif field.startswith('++'):
735+ fields[i] = field[2:]
736+ elif field.startswith('-'):
737+ fields[i] = (direction == 'asc' and '-' or '') + field[1:]
738+ else:
739+ field = field[1:] if field.startswith('+') else field
740+ fields[i] = (direction == 'desc' and '-' or '') + field
741+
742+ if not sql_predicate:
743+ return fields
744+
745+ # determine sql predicates from list
746+ fields = list(fields)
747+ for i, field in enumerate(fields):
748+ if field.startswith('-'):
749+ fields[i] = '%s DESC' % (field[1:],)
750+ else:
751+ fields[i] = '%s ASC' % (field,)
752+ return fields
753
754=== modified file 'src/maasserver/views/nodes.py'
755--- src/maasserver/views/nodes.py 2012-10-19 13:55:51 +0000
756+++ src/maasserver/views/nodes.py 2012-10-22 09:32:21 +0000
757@@ -67,6 +67,7 @@
758 HelpfulDeleteView,
759 PaginatedListView,
760 )
761+from maasserver.utils.sortable import Sortable
762
763
764 def get_longpoll_context():
765@@ -110,6 +111,8 @@
766 def get(self, request, *args, **kwargs):
767 self.query = request.GET.get("query")
768 self.query_error = None
769+ self.sort_field = request.GET.get('sort', '')
770+ self.sort_direction = request.GET.get('dir', 'asc')
771 return super(NodeListView, self).get(request, *args, **kwargs)
772
773 def get_queryset(self):
774@@ -117,6 +120,9 @@
775 nodes = Node.objects.get_nodes(
776 user=self.request.user, prefetch_mac=True,
777 perm=NODE_PERMISSION.VIEW,).order_by('-created')
778+ sortable = Sortable(nodes, ('hostname', 'status'))
779+ nodes = sortable.sorted(self.sort_field, self.sort_direction)
780+
781 if self.query:
782 try:
783 return constrain_nodes(nodes, _parse_constraints(self.query))