Merge lp:~dimitern/maas/bug-994887-alternate-nodes-list-sorting into lp:~maas-committers/maas/trunk

Proposed by Dimiter Naydenov
Status: Merged
Approved by: Dimiter Naydenov
Approved revision: no longer in the source branch.
Merged at revision: 1294
Proposed branch: lp:~dimitern/maas/bug-994887-alternate-nodes-list-sorting
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 309 lines (+178/-9)
6 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 (+7/-0)
src/maasserver/templates/maasserver/tag_view.html (+1/-1)
src/maasserver/tests/test_views_nodes.py (+115/-5)
src/maasserver/views/nodes.py (+43/-2)
To merge this branch: bzr merge lp:~dimitern/maas/bug-994887-alternate-nodes-list-sorting
Reviewer Review Type Date Requested Status
Martin Packman (community) Approve
John A Meinel (community) Approve
Review via email: mp+131177@code.launchpad.net

Commit message

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

Description of the change

Implemented sorting for /MAAS/nodes/ list - by hostname or status (asc/desc). This is an alternative, simpler solution, compared to https://code.launchpad.net/~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting/+merge/130600 - not using third party modules (django-sortable) anymore. UI changes (sorting up/down arrows) were already approved, so just the code changes need approval.

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

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

On 10/24/2012 02:57 PM, Dimiter Naydenov wrote:
> Dimiter Naydenov has proposed merging
> lp:~dimitern/maas/bug-994887-alternate-nodes-list-sorting into
> lp:maas.
>
> Commit message: Fix bug #994887: Nodes list view now supports
> sorting by hostname(mac) or status columns.
>
> Requested reviews: Launchpad code reviewers (launchpad-reviewers)
> Related bugs: Bug #994887 in MAAS: "Nodes listing does not support
> sorting" https://bugs.launchpad.net/maas/+bug/994887
>
> For more details, see:
> https://code.launchpad.net/~dimitern/maas/bug-994887-alternate-nodes-list-sorting/+merge/131177
>
> Implemented sorting for /MAAS/nodes/ list - by hostname or status
> (asc/desc). This is an alternative, simpler solution, compared to
> https://code.launchpad.net/~dimitern/maas/bug-994887-nodes-listing-does-not-support-sorting/+merge/130600
> - not using third party modules (django-sortable) anymore. UI
> changes (sorting up/down arrows) were already approved, so just the
> code changes need approval.
>

+ # Just creare some nodes to have something in the list

typo 'create'

However, why do we need the nodes to actually get the links? I would
tend to have no more than 1 'make_node()' call.

...
+ node_links = [
+ reverse('node-view', args=[node.system_id])
+
+ for node in sorted_nodes]

The spacing in this test looks wrong. You probably want to run 'make
lint' and handle anything that it brings up as a problem.

+ # Now check the reverse order
+ sorted_nodes = sorted(
+ nodes, key=lambda x: x.status, reverse=True)

I don't think you need to re-do the sort, if anything just do
"sorted_nodes = reversed(sorted_nodes)".
However, I think all you actually need to do is:
node_links = reversed(node_links) and not do anything with re-sorting
the node objects themselves.

That should simplify the test a bit.

+ # Exclude header sorting links
         self.assertEqual([last_node_link],
- - document.xpath("//div[@id='nodes']/table//a/@href"))
+ filter(
+ lambda x: 'sort=' not in x,
+ document.xpath("//div[@id='nodes']/table//a/@href")))

I'm hesitant about using 'filter' in these cases. I'm wondering if
there is a better way to write the tests. Perhaps giving a specific
class id for the node links, or one for the sorting links?

         nodes = Node.objects.get_nodes(
             user=self.request.user, prefetch_mac=True,
- - perm=NODE_PERMISSION.VIEW,).order_by('-created')
+ perm=NODE_PERMISSION.VIEW,).order_by(order_by)

I kind of feel like we want to always have a sort field that is
unique. What I mean, is that if you sort by 'status' then all nodes
with the same status can occur in arbitrary order. (It is probably
reasonably static because the db isn't changing a lot in the
background, but it isn't guaranteed). Certainly an unrelated action
that would otherwise not affect the nodes being shown could cause a
node that was on page 1 to suddenly swap places with a node on page 2.

I'm not sure about the specific order of operations, but I think we
want something li...

Read more...

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

        # Just creare some nodes to have something in the list

Tyop, 'create'. Hm, we should really make a helper for multi-node creation on these views, but that can happen later.

+ sorted_nodes = sorted(nodes, key=lambda x: x.hostname)

Always nice to use operator.attrgetter('hostname') for this.

+ header_links = document.xpath("//div[@id='nodes']/table//th/a/@href")
+ for link in header_links:
+ self.assertIn('page=1', link)
+ self.assertIn('query=maas-tags%3Dshiny', link)

Rather than having multiple string contains assertions like this, I'd use urlparse.parse_qsl and testtools matchers against all the key/values you expect, so a failure has the full context. Something like:

    self.assertThat(urlparse.parse_qsl(link), ContainsAll([('page', '1'), ...]))

- node_links = document.xpath("//div[@id='nodes']/table//a/@href")
+ # Exclude header sorting links
+ node_links = filter(
+ lambda x: 'sort=' not in x,
+ document.xpath("//div[@id='nodes']/table//a/@href"))

For these, I'd adjust the xpath instead, ie, .../table//td//a/...

+ links = {field: self.request.path + '?' for field in fields}

Give relative links rather than including the path in them, the link should just start with the '?', note tests will need changing to expect this too.

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

Thanks for the review! I made the requested modifications and also simplified the tests (fixing a minor issue I found with the initial sort order).

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

+ perm=NODE_PERMISSION.VIEW,).order_by(order_by, '-created')

Having the default order_by here being ('-created', '-created') but all the other changes look good.

review: Approve

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-24 16:02:22 +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-24 16:02:22 +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-24 16:02:22 +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-24 16:02:22 +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 sorting="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-24 16:02:22 +0000
43@@ -2,8 +2,15 @@
44 <table class="list">
45 <thead>
46 <tr>
47+ {% if sorting == "true" %}
48+ <th><a href="{{ sort_links.hostname }}"
49+ class="{{ sort_classes.hostname }}">MAC</a></th>
50+ <th><a href="{{ sort_links.status }}"
51+ class="{{ sort_classes.status }}">Status</a></th>
52+ {% else %}
53 <th>MAC</th>
54 <th>Status</th>
55+ {% endif %}
56 </tr>
57 </thead>
58 {% for node in node_list %}
59
60=== modified file 'src/maasserver/templates/maasserver/tag_view.html'
61--- src/maasserver/templates/maasserver/tag_view.html 2012-10-18 11:26:02 +0000
62+++ src/maasserver/templates/maasserver/tag_view.html 2012-10-24 16:02:22 +0000
63@@ -34,7 +34,7 @@
64 </ul>
65 <div id="nodes" class="block first pad-top">
66 <h2 class="block first line-top pad-top">{{ paginator.count }} Node{{ paginator.count|pluralize }}</h2>
67- {% include "maasserver/nodes_listing.html" %}
68+ {% include "maasserver/nodes_listing.html" with sorting="false" %}
69 {% include "maasserver/pagination.html" %}
70 </div>
71 {% endblock %}
72
73=== modified file 'src/maasserver/tests/test_views_nodes.py'
74--- src/maasserver/tests/test_views_nodes.py 2012-10-15 17:39:31 +0000
75+++ src/maasserver/tests/test_views_nodes.py 2012-10-24 16:02:22 +0000
76@@ -13,7 +13,12 @@
77 __all__ = []
78
79 import httplib
80+from operator import attrgetter
81 from unittest import skip
82+from urlparse import (
83+ parse_qsl,
84+ urlparse,
85+ )
86
87 from django.conf import settings
88 from django.core.urlresolvers import reverse
89@@ -79,6 +84,108 @@
90 enlist_preseed_link = reverse('enlist-preseed-view')
91 self.assertIn(enlist_preseed_link, get_content_links(response))
92
93+ def test_node_list_contains_column_sort_links(self):
94+ # Just create a node to have something in the list
95+ factory.make_node()
96+ response = self.client.get(reverse('node-list'))
97+ sort_hostname = '?sort=hostname&dir=asc'
98+ sort_status = '?sort=status&dir=asc'
99+ self.assertIn(sort_hostname, get_content_links(response))
100+ self.assertIn(sort_status, get_content_links(response))
101+
102+ def test_node_list_sorts_by_hostname(self):
103+ names = ['zero', 'one', 'five']
104+ nodes = [factory.make_node(hostname=n) for n in names]
105+
106+ # First check the ascending sort order
107+ sorted_nodes = sorted(nodes, key=attrgetter('hostname'))
108+ response = self.client.get(
109+ reverse('node-list'), {
110+ 'sort': 'hostname',
111+ 'dir': 'asc'})
112+ node_links = [
113+ reverse('node-view', args=[node.system_id])
114+ for node in sorted_nodes]
115+ self.assertEqual(
116+ node_links,
117+ [link for link in get_content_links(response)
118+ if link.startswith('/nodes/node')])
119+
120+ # Now check the reverse order
121+ node_links = list(reversed(node_links))
122+ response = self.client.get(
123+ reverse('node-list'), {
124+ 'sort': 'hostname',
125+ 'dir': 'desc'})
126+ self.assertEqual(
127+ node_links,
128+ [link for link in get_content_links(response)
129+ if link.startswith('/nodes/node')])
130+
131+ def test_node_list_sorts_by_status(self):
132+ statuses = {
133+ NODE_STATUS.READY,
134+ NODE_STATUS.DECLARED,
135+ NODE_STATUS.FAILED_TESTS,
136+ }
137+ nodes = [factory.make_node(status=s) for s in statuses]
138+
139+ # First check the ascending sort order
140+ sorted_nodes = sorted(nodes, key=attrgetter('status'))
141+ response = self.client.get(
142+ reverse('node-list'), {
143+ 'sort': 'status',
144+ 'dir': 'asc'})
145+ node_links = [
146+ reverse('node-view', args=[node.system_id])
147+ for node in sorted_nodes]
148+ self.assertEqual(
149+ node_links,
150+ [link for link in get_content_links(response)
151+ if link.startswith('/nodes/node')])
152+
153+ # Now check the reverse order
154+ node_links = list(reversed(node_links))
155+ response = self.client.get(
156+ reverse('node-list'), {
157+ 'sort': 'status',
158+ 'dir': 'desc'})
159+ self.assertEqual(
160+ node_links,
161+ [link for link in get_content_links(response)
162+ if link.startswith('/nodes/node')])
163+
164+ def test_node_list_sort_preserves_other_params(self):
165+ # Set a very small page size to save creating lots of nodes
166+ page_size = 2
167+ self.patch(nodes_views.NodeListView, 'paginate_by', page_size)
168+
169+ nodes = []
170+ tag = factory.make_tag('shiny')
171+ for name in ('bbb', 'ccc', 'ddd', 'aaa'):
172+ node = factory.make_node(hostname=name)
173+ node.tags = [tag]
174+ nodes.append(node)
175+
176+ params = {
177+ 'sort': 'hostname',
178+ 'dir': 'asc',
179+ 'page': '1',
180+ 'query': 'maas-tags=shiny'}
181+ response = self.client.get(reverse('node-list'), params)
182+ document = fromstring(response.content)
183+ header_links = document.xpath("//div[@id='nodes']/table//th/a/@href")
184+ fields = iter(('hostname', 'status'))
185+ field_dirs = iter(('desc', 'asc'))
186+ for link in header_links:
187+ self.assertThat(
188+ parse_qsl(urlparse(link).query),
189+ ContainsAll([
190+ ('page', '1'),
191+ ('query', 'maas-tags=shiny'),
192+ ('sort', next(fields)),
193+ ('dir', next(field_dirs))]))
194+
195 def test_node_list_displays_sorted_list_of_nodes(self):
196 # Nodes are sorted on the node list page, newest first.
197 nodes = [factory.make_node() for i in range(3)]
198@@ -421,7 +528,8 @@
199 {"query": "maas-tags=shiny cpu=2"})
200 node2_link = reverse('node-view', args=[node2.system_id])
201 document = fromstring(response.content)
202- node_links = document.xpath("//div[@id='nodes']/table//a/@href")
203+ node_links = document.xpath(
204+ "//div[@id='nodes']/table//td/a/@href")
205 self.assertEqual([node2_link], node_links)
206
207 def test_node_list_paginates(self):
208@@ -434,7 +542,7 @@
209 # Order node links with newest first as the view is expected to
210 node_links = [reverse('node-view', args=[node.system_id])
211 for node in reversed(nodes)]
212- expr_node_links = XPath("//div[@id='nodes']/table//a/@href")
213+ expr_node_links = XPath("//div[@id='nodes']/table//td/a/@href")
214 expr_page_anchors = XPath("//div[@class='pagination']//a")
215 # Fetch first page, should link newest two nodes and page 2
216 response = self.client.get(reverse('node-list'))
217@@ -446,7 +554,8 @@
218 # Fetch second page, should link next nodes and adjacent pages
219 response = self.client.get(reverse('node-list'), {"page": 2})
220 page2 = fromstring(response.content)
221- self.assertEqual(node_links[page_size:page_size * 2],
222+ self.assertEqual(
223+ node_links[page_size:page_size * 2],
224 expr_node_links(page2))
225 self.assertEqual([("first", "."), ("previous", "."),
226 ("next", "?page=3"), ("last", "?page=3")],
227@@ -474,8 +583,9 @@
228 {"query": "maas-tags=odd", "page": 3})
229 document = fromstring(response.content)
230 self.assertIn("5 matching nodes", document.xpath("string(//h1)"))
231- self.assertEqual([last_node_link],
232- document.xpath("//div[@id='nodes']/table//a/@href"))
233+ self.assertEqual(
234+ [last_node_link],
235+ document.xpath("//div[@id='nodes']/table//td/a/@href"))
236 self.assertEqual([("first", "?query=maas-tags%3Dodd"),
237 ("previous", "?query=maas-tags%3Dodd&page=2")],
238 [(a.text.lower(), a.get("href"))
239
240=== modified file 'src/maasserver/views/nodes.py'
241--- src/maasserver/views/nodes.py 2012-10-19 13:55:51 +0000
242+++ src/maasserver/views/nodes.py 2012-10-24 16:02:22 +0000
243@@ -110,13 +110,26 @@
244 def get(self, request, *args, **kwargs):
245 self.query = request.GET.get("query")
246 self.query_error = None
247+ self.sort_by = request.GET.get("sort")
248+ self.sort_dir = request.GET.get("dir")
249+
250 return super(NodeListView, self).get(request, *args, **kwargs)
251
252 def get_queryset(self):
253- # Return node list sorted, newest first.
254+ # Default sort - newest first, unless sorting params are
255+ # present. In addition, to ensure order consistency, when
256+ # sorting by non-unique fields (like status), we always
257+ # sort by the unique creation date as well
258+ if self.sort_by is not None:
259+ prefix = '-' if self.sort_dir == 'desc' else ''
260+ order_by = (prefix + self.sort_by, '-created')
261+ else:
262+ order_by = ('-created', )
263+
264+ # Return the sorted node list
265 nodes = Node.objects.get_nodes(
266 user=self.request.user, prefetch_mac=True,
267- perm=NODE_PERMISSION.VIEW,).order_by('-created')
268+ perm=NODE_PERMISSION.VIEW,).order_by(*order_by)
269 if self.query:
270 try:
271 return constrain_nodes(nodes, _parse_constraints(self.query))
272@@ -125,11 +138,39 @@
273 return Node.objects.none()
274 return nodes
275
276+ def _prepare_sort_links(self):
277+ """Returns 2 dicts, with sort fields as keys and
278+ links and CSS classes for the that field.
279+ """
280+
281+ fields = ('hostname', 'status')
282+ # Build relative URLs for the links, just with the params
283+ links = {field: '?' for field in fields}
284+ classes = {field: 'sort-none' for field in fields}
285+
286+ params = self.request.GET.copy()
287+ reverse_dir = 'asc' if self.sort_dir == 'desc' else 'desc'
288+
289+ for field in fields:
290+ params['sort'] = field
291+ if field == self.sort_by:
292+ params['dir'] = reverse_dir
293+ classes[field] = 'sort-%s' % self.sort_dir
294+ else:
295+ params['dir'] = 'asc'
296+
297+ links[field] += params.urlencode()
298+
299+ return links, classes
300+
301 def get_context_data(self, **kwargs):
302 context = super(NodeListView, self).get_context_data(**kwargs)
303 context.update(get_longpoll_context())
304 context["input_query"] = self.query
305 context["input_query_error"] = self.query_error
306+ links, classes = self._prepare_sort_links()
307+ context["sort_links"] = links
308+ context["sort_classes"] = classes
309 return context
310
311