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