Merge lp:~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting into lp:~maas-committers/maas/trunk
- bug-994887-nodes-listing-does-not-support-sorting
- Merge into trunk
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 | ||||
Related bugs: |
|
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:/
-------------------
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://
http://
http://
http://
http://
John A Meinel (jameinel) wrote : | # |
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://
> > http://
> > http://
> > http://
> > http://
> >
> > For more details, see:
> >
> >
> https:/
> > --
> >
> >
> https:/
> > You are subscribed to branch lp:maas.
> >
>
> --
>
> https:/
> You are subscribed to branch lp:maas.
>
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_
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 :)
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/
[...]
.......
Name Stmts Miss Cover Missing
-------
maasserver.
maasserver.
maasserver.
-------
TOTAL 91 21 77%
-------
Ran 76 tests in 23.961s
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/
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 (SortableLinkNo
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.
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'.
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.
Preview Diff
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' |
24 | Binary 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' |
26 | Binary 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¶m1=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)) |
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 people. canonical. com/~dimitern/ maas-nodes- sort-none. png people. canonical. com/~dimitern/ maas-nodes- sort-mac- asc.png people. canonical. com/~dimitern/ maas-nodes- sort-mac- desc.png people. canonical. com/~dimitern/ maas-nodes- sort-status- asc.png people. canonical. com/~dimitern/ maas-nodes- sort-status- desc.png /code.launchpad .net/~dimitern/ maas/bug- 994887- nodes-listing- does-not- support- sorting/ +merge/ 130600 /code.launchpad .net/~dimitern/ maas/bug- 994887- nodes-listing- does-not- support- sorting/ +merge/ 130600
> 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://
> http://
> http://
> http://
> http://
>
> For more details, see:
>
> https:/
> --
>
> https:/
> You are subscribed to branch lp:maas.
>