Sorting Dance Lists

Sometimes there are clearly-useful features in the database that just seem to be overlooked. But it's fun to add them in when it turns out people have a need for them, just to help these people out.

Earlier today I received an e-mail from Zoltan Graff in Budapest which read

I have a new informational dance list.
Is it possible to sort it by dance name?

Now Zoltan obviously knows that you can sort dance lists by various columns using the “sorting triangle” icons in the table heading on the list's detail page. But it turns out that's not what he's after: This is about sorting the actual dance list in the database, with the effects permanent and visible to everyone, as opposed to sorting the list output on its detail page and repeating that whenever (and wherever) that page is displayed.

SCDDB dance list sort modal

This is such a manifestly helpful feature that I'm retroactively wondering why I haven't added it years ago. I sometimes sort dance lists by hand, which is fine if you're dealing with a dozen dances but increasingly inconvenient as dance lists grow longer – Zoltan's list, for example, has almost 50 entries and having to sort these manually would be a hassle.

Adding this to the database isn't at all complicated. First of all, we add a modal dialog to the “Manage” pop-up menu on the dance list editing page that will let users pick the criterion according to which the list should be sorted (allowing sorting just by dance name would be boring and restrictive). And just because we can, why not allow “secondary” and “tertiary” criteria, too? That way you could sort a dance list by dance type (jig, reel, strathspey) and within each dance type, sort the dances alphabetically by name. Oh, and why not allow people to sort the list in ascending or descending order for each key?

The form is set up such that it returns the sort keys as key1, key2, and key3, and the corresponding directions as dir1, dir2, and dir3. We'll be using the Python built-in function sorted() to sort a list of dance list items, and this function takes a key parameter which can be used to extract a key for the sort from each item to be sorted. All we need to do is supply a function which – given a dance list item – will return whatever we actually want to use to sort the items. Here's how we do that:

if key == "name":
    fn = lambda item: item.dance.name if item.is_dance() else "?"
elif key == "type":
    fn = dancetype_key
elif key == "shape":
    fn = lambda item: item.dance.setshape() if item.is_dance() else "?"
elif key == "author":
    fn = lambda item: item.dance.devisor.name if item.is_dance() else "?"
elif key == "notes":
    fn = lambda item: item.notes

(Note how we check whether the item is actually a dance item before trying to get at the dance-specific info.) The type key is a little more complicated because some dances in the database don't have a single type – they are marked “jig or reel” or “jig, reel, or strathspey”, and we want them to sort according to their “effective” dance type in the dance list. Figuring that out is too complicated for a lambda function, so the actual definition of dancetype_key() is

def dancetype_key(item):
    if item.is_dance():
        return (item.eff_dtype + str(item.dance.barsperrepeat)
                if item.dance.is_multitype() else item.dance.dancetype())
    return "?"

How do we sort the list according to several criteria? Instead of trying to manufacture a complex key (which would be difficult given that we let people select the sort direction), we take advantage of the fact that the sorting algorithm used by the sorted() function is “stable” – in other words, if you sort a list according to some key, then list items which share a value of that key will keep whatever order they had before the sort. So to sort our list of items according to three different criteria, we simply need to start with the third (least important) criterion and work our way forward in order to end up with a list that is sorted according to the first criterion, with the “equals” ordered according to the second criterion, and any items with the same values in the first and second criteria sorted according to the third. Easy! Here's how that looks like in the code:

d = form.cleaned_data
items = self.object.nonscratch_items()
for k in range(3, 0, -1):
    key, direction = d[f"key{k}"], d[f"dir{k}"]
    if key == "name":
        # etc. etc. (see above)
    items = sorted(items, key=fn, reverse=direction == "desc")

where form.cleaned_data refers to the entries of the filled-in sort-order form on the modal dialogue. Note that we're deliberately only sorting the actual “list” part of the dance list, not the content of the scratch area. (This may change in the future.)

All that remains is actually establishing the new order in the database:

number = 1
for item in items:
    item.number = number
    item.save()
    number += 1

And updating the list object, the item times (if using) and the display (both table-oriented and graphical). We don't need to return anything to the client (the SSE will take care of updating the list on the client) except a toast telling the user that the list has been sorted.

self.object.save()                   # Update hash code
self.object.calculate_times()
self.object.calculate_svg_coordinates(force=True)
sse_send(f"dl_{self.object.pk}", "reread-list", "")

response = http.HttpResponse("")
toasts.add(response, 'List sorted', tag=messages.SUCCESS)
return response

There you have it! All of this code goes into the form_valid() method of the ListSortView class defined in src/ace4/db/views/lists.py, which is a subclass of ListHandleView.

So, thank you Zoltan for inspiring this useful feature, which should have been introduced ages ago!