diff -r 2297d9993a41 django/contrib/admin/media/css/base.css
--- a/django/contrib/admin/media/css/base.css	Thu Jun 02 00:24:37 2011 +0000
+++ b/django/contrib/admin/media/css/base.css	Thu Jun 02 01:39:38 2011 +0100
@@ -326,6 +326,36 @@
     background: url(../img/admin/arrow-down.gif) right .4em no-repeat;
 }
 
+table thead th.sorted a span.text {
+   display: block;
+   float: left;
+}
+
+table thead th.sorted a span.sortpos {
+   display: block;
+   float: right;
+   font-size: .6em;
+}
+
+table thead th.sorted a img {
+   vertical-align: bottom;
+   /* Make it look like a link */
+   border-bottom: 1px solid #5b80b2;
+}
+
+table thead th.sorted a span.clear {
+   display: block;
+   clear: both;
+}
+
+#sorting-popup-div {
+    position: absolute;
+    background-color: white;
+    border: 1px solid #ddd;
+    z-index: 2000; /* more than filters on right */
+    padding-right: 10px;
+}
+
 /* ORDERABLE TABLES */
 
 table.orderable tbody tr td:hover {
diff -r 2297d9993a41 django/contrib/admin/templates/admin/change_list_results.html
--- a/django/contrib/admin/templates/admin/change_list_results.html	Thu Jun 02 00:24:37 2011 +0000
+++ b/django/contrib/admin/templates/admin/change_list_results.html	Thu Jun 02 01:39:38 2011 +0100
@@ -1,3 +1,5 @@
+{% load adminmedia %}
+{% load i18n %}
 {% if result_hidden_fields %}
 <div class="hiddenfields">{# DIV for HTML validation #}
 {% for item in result_hidden_fields %}{{ item }}{% endfor %}
@@ -8,10 +10,18 @@
 <table id="result_list">
 <thead>
 <tr>
-{% for header in result_headers %}<th scope="col"{{ header.class_attrib }}>
-{% if header.sortable %}<a href="{{ header.url }}">{% endif %}
-{{ header.text|capfirst }}
-{% if header.sortable %}</a>{% endif %}</th>{% endfor %}
+{% for header in result_headers %}
+<th scope="col" {{ header.class_attrib }}>
+  {% if header.sortable %}<a href="{{ header.url }}">{% endif %}
+  <span class="text">{{ header.text|capfirst }}</span>
+  {% if header.sortable %}
+    {% if header.sort_pos > 0 %}<span class="sortpos">
+      {% if header.sort_pos == 1 %}<img id="primary-sort-icon" src="{% admin_media_prefix %}img/admin/icon_primary_sort.png" alt="Primary sort field" />{% endif %}
+      {{ header.sort_pos }}</span>
+    {% endif %}
+    <span class="clear"></span></a>
+  {% endif %}
+</th>{% endfor %}
 </tr>
 </thead>
 <tbody>
@@ -24,4 +34,49 @@
 </tbody>
 </table>
 </div>
+
+{# Sorting popup: #}
+<div style="display: none;" id="sorting-popup-div">
+<p>{% trans "Sorting by:" %}</p>
+<ol>
+{% for header in result_headers|dictsort:"sort_pos" %}
+  {% if header.sort_pos > 0 %}
+    <li>{{ header.text|capfirst }}</li>
+  {% endif %}
+{% endfor %}
+</ol>
+<p><a href="{{ reset_order_url }}">{% trans "Reset sorting" %}</a></p>
+</div>
+<script type="text/javascript">
+<!--
+(function($) {
+    $(document).ready(function() {
+        var popup = $('#sorting-popup-div');
+        /* These next lines seems necessary to prime the popup: */
+        popup.offset({left:-1000, top:0});
+        popup.show();
+        var popupWidth = popup.width()
+        popup.hide();
+
+        $('#primary-sort-icon').toggle(function(ev) {
+                                          ev.preventDefault();
+                                          var img = $(this);
+                                          var pos = img.offset();
+                                          pos.top += img.height();
+                                          if (pos.left + popupWidth >
+                                              $(window).width()) {
+                                              pos.left -= popupWidth;
+                                          }
+                                          popup.show();
+                                          popup.offset(pos);
+                                      },
+                                      function(ev) {
+                                          ev.preventDefault();
+                                          popup.hide();
+                                      });
+    });
+})(django.jQuery);
+//-->
+</script>
+
 {% endif %}
diff -r 2297d9993a41 django/contrib/admin/templatetags/admin_list.py
--- a/django/contrib/admin/templatetags/admin_list.py	Thu Jun 02 00:24:37 2011 +0000
+++ b/django/contrib/admin/templatetags/admin_list.py	Thu Jun 02 01:39:38 2011 +0100
@@ -93,6 +93,7 @@
             if field_name == 'action_checkbox':
                 yield {
                     "text": header,
+                    "sort_pos": 0,
                     "class_attrib": mark_safe(' class="action-checkbox-column"')
                 }
                 continue
@@ -100,7 +101,7 @@
             # It is a non-field, but perhaps one that is sortable
             admin_order_field = getattr(attr, "admin_order_field", None)
             if not admin_order_field:
-                yield {"text": header}
+                yield {"text": header, "sort_pos": 0 }
                 continue
 
             # So this _is_ a sortable non-field.  Go to the yield
@@ -110,14 +111,44 @@
 
         th_classes = []
         new_order_type = 'asc'
-        if field_name == cl.order_field or admin_order_field == cl.order_field:
-            th_classes.append('sorted %sending' % cl.order_type.lower())
-            new_order_type = {'asc': 'desc', 'desc': 'asc'}[cl.order_type.lower()]
+        ordering_fields = cl.get_ordering_fields()
+        sort_pos = 0
+        if field_name in ordering_fields or admin_order_field in ordering_fields:
+            if not field_name in ordering_fields:
+                field_name = admin_order_field
+            order_type = ordering_fields.get(field_name).lower()
+            sort_pos = ordering_fields.keys().index(field_name) + 1
+            th_classes.append('sorted %sending' % order_type)
+            new_order_type = {'asc': 'desc', 'desc': 'asc'}[order_type]
+
+        # build new ordering param
+        o_list = []
+        make_qs_param = lambda t, n: ('-' if t == 'desc' else '') + str(n)
+
+        for f in ordering_fields.keys():
+            try:
+                n = cl.list_display.index(f)
+            except ValueError:
+                continue
+
+            if f == field_name:
+                # We want clicking on this header to bring the ordering to the
+                # front
+                o_list.insert(0, make_qs_param(new_order_type, n))
+            else:
+                o_list.append(make_qs_param(ordering_fields.get(f), n))
+
+        if field_name not in ordering_fields:
+            n = cl.list_display.index(field_name)
+            o_list.insert(0, make_qs_param(new_order_type, n))
+
+        o_list = '.'.join(o_list)
 
         yield {
             "text": header,
             "sortable": True,
-            "url": cl.get_query_string({ORDER_VAR: i, ORDER_TYPE_VAR: new_order_type}),
+            "sort_pos": sort_pos,
+            "url": cl.get_query_string({ORDER_VAR: o_list}),
             "class_attrib": mark_safe(th_classes and ' class="%s"' % ' '.join(th_classes) or '')
         }
 
@@ -231,6 +262,7 @@
     return {'cl': cl,
             'result_hidden_fields': list(result_hidden_fields(cl)),
             'result_headers': list(result_headers(cl)),
+            'reset_order_url': cl.get_query_string(remove=[ORDER_VAR]),
             'results': list(results(cl))}
 
 @register.inclusion_tag('admin/date_hierarchy.html')
diff -r 2297d9993a41 django/contrib/admin/views/main.py
--- a/django/contrib/admin/views/main.py	Thu Jun 02 00:24:37 2011 +0000
+++ b/django/contrib/admin/views/main.py	Thu Jun 02 01:39:38 2011 +0100
@@ -3,6 +3,7 @@
 from django.core.exceptions import SuspiciousOperation
 from django.core.paginator import InvalidPage
 from django.db import models
+from django.utils.datastructures import SortedDict
 from django.utils.encoding import force_unicode, smart_str
 from django.utils.translation import ugettext, ugettext_lazy
 from django.utils.http import urlencode
@@ -75,7 +76,7 @@
             self.list_editable = ()
         else:
             self.list_editable = list_editable
-        self.order_field, self.order_type = self.get_ordering()
+        self.ordering = self.get_ordering()
         self.query = request.GET.get(SEARCH_VAR, '')
         self.query_set = self.get_query_set(request)
         self.get_results(request)
@@ -171,35 +172,46 @@
         # manually-specified ordering from the query string.
         ordering = self.model_admin.ordering or lookup_opts.ordering or ['-' + lookup_opts.pk.name]
 
-        if ordering[0].startswith('-'):
-            order_field, order_type = ordering[0][1:], 'desc'
-        else:
-            order_field, order_type = ordering[0], 'asc'
         if ORDER_VAR in params:
-            try:
-                field_name = self.list_display[int(params[ORDER_VAR])]
+            # Clear ordering and used params
+            ordering = []
+            order_params = params[ORDER_VAR].split('.')
+            for p in order_params:
                 try:
-                    f = lookup_opts.get_field(field_name)
-                except models.FieldDoesNotExist:
-                    # See whether field_name is a name of a non-field
-                    # that allows sorting.
+                    none, pfx, idx = p.rpartition('-')
+                    field_name = self.list_display[int(idx)]
                     try:
-                        if callable(field_name):
-                            attr = field_name
-                        elif hasattr(self.model_admin, field_name):
-                            attr = getattr(self.model_admin, field_name)
-                        else:
-                            attr = getattr(self.model, field_name)
-                        order_field = attr.admin_order_field
-                    except AttributeError:
-                        pass
-                else:
-                    order_field = f.name
-            except (IndexError, ValueError):
-                pass # Invalid ordering specified. Just use the default.
-        if ORDER_TYPE_VAR in params and params[ORDER_TYPE_VAR] in ('asc', 'desc'):
-            order_type = params[ORDER_TYPE_VAR]
-        return order_field, order_type
+                        f = lookup_opts.get_field(field_name)
+                    except models.FieldDoesNotExist:
+                        # See whether field_name is a name of a non-field
+                        # that allows sorting.
+                        try:
+                            if callable(field_name):
+                                attr = field_name
+                            elif hasattr(self.model_admin, field_name):
+                                attr = getattr(self.model_admin, field_name)
+                            else:
+                                attr = getattr(self.model, field_name)
+                            field_name = attr.admin_order_field
+                        except AttributeError:
+                            continue # No 'admin_order_field', skip it
+                    else:
+                        field_name = f.name
+
+                    ordering.append(pfx + field_name)
+
+                except (IndexError, ValueError):
+                    continue # Invalid ordering specified, skip it.
+
+        return ordering
+
+    def get_ordering_fields(self):
+        # Returns a SortedDict of ordering fields and asc/desc
+        ordering_fields = SortedDict()
+        for o in self.ordering:
+            none, t, f = o.rpartition('-')
+            ordering_fields[f] = 'desc' if t == '-' else 'asc'
+        return ordering_fields
 
     def get_lookup_params(self, use_distinct=False):
         lookup_params = self.params.copy() # a dictionary of the query string
@@ -290,8 +302,8 @@
                             break
 
         # Set ordering.
-        if self.order_field:
-            qs = qs.order_by('%s%s' % ((self.order_type == 'desc' and '-' or ''), self.order_field))
+        if self.ordering:
+            qs = qs.order_by(*self.ordering)
 
         # Apply keyword searches.
         def construct_search(field_name):
diff -r 2297d9993a41 docs/ref/contrib/admin/index.txt
--- a/docs/ref/contrib/admin/index.txt	Thu Jun 02 00:24:37 2011 +0000
+++ b/docs/ref/contrib/admin/index.txt	Thu Jun 02 01:39:38 2011 +0100
@@ -696,10 +696,10 @@
     If this isn't provided, the Django admin will use the model's default
     ordering.
 
-    .. admonition:: Note
-
-        Django will only honor the first element in the list/tuple; any others
-        will be ignored.
+    .. versionchanged:: 1.4
+
+    Django honors all elements in the list/tuple; before 1.4, any others
+    were ignored.
 
 .. attribute:: ModelAdmin.paginator
 
diff -r 2297d9993a41 docs/releases/1.4.txt
--- a/docs/releases/1.4.txt	Thu Jun 02 00:24:37 2011 +0000
+++ b/docs/releases/1.4.txt	Thu Jun 02 01:39:38 2011 +0100
@@ -46,6 +46,14 @@
 known as "FilterSpec" which was used internally. For more details, see the
 documentation for :attr:`~django.contrib.admin.ModelAdmin.list_filter`.
 
+Multiple sort in admin interface
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+The admin change list now supports sorting on multiple columns. It respects all
+elements of the :attr:`~django.contrib.admin.ModelAdmin.ordering` attribute, and
+sorting on multiple columns by clicking on headers is designed to work similarly
+to how desktop GUIs do it.
+
 Tools for cryptographic signing
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
diff -r 2297d9993a41 tests/regressiontests/admin_views/tests.py
--- a/tests/regressiontests/admin_views/tests.py	Thu Jun 02 00:24:37 2011 +0000
+++ b/tests/regressiontests/admin_views/tests.py	Thu Jun 02 01:39:38 2011 +0100
@@ -204,7 +204,7 @@
         Ensure we can sort on a list_display field that is a callable
         (column 2 is callable_year in ArticleAdmin)
         """
-        response = self.client.get('/test_admin/%s/admin_views/article/' % self.urlbit, {'ot': 'asc', 'o': 2})
+        response = self.client.get('/test_admin/%s/admin_views/article/' % self.urlbit, {'o': 2})
         self.assertEqual(response.status_code, 200)
         self.assertTrue(
             response.content.index('Oldest content') < response.content.index('Middle content') and
@@ -217,7 +217,7 @@
         Ensure we can sort on a list_display field that is a Model method
         (colunn 3 is 'model_year' in ArticleAdmin)
         """
-        response = self.client.get('/test_admin/%s/admin_views/article/' % self.urlbit, {'ot': 'dsc', 'o': 3})
+        response = self.client.get('/test_admin/%s/admin_views/article/' % self.urlbit, {'o': '-3'})
         self.assertEqual(response.status_code, 200)
         self.assertTrue(
             response.content.index('Newest content') < response.content.index('Middle content') and
@@ -230,7 +230,7 @@
         Ensure we can sort on a list_display field that is a ModelAdmin method
         (colunn 4 is 'modeladmin_year' in ArticleAdmin)
         """
-        response = self.client.get('/test_admin/%s/admin_views/article/' % self.urlbit, {'ot': 'asc', 'o': 4})
+        response = self.client.get('/test_admin/%s/admin_views/article/' % self.urlbit, {'o': '4'})
         self.assertEqual(response.status_code, 200)
         self.assertTrue(
             response.content.index('Oldest content') < response.content.index('Middle content') and
@@ -1956,7 +1956,7 @@
             'action' : 'external_mail',
             'index': 0,
         }
-        url = '/test_admin/admin/admin_views/externalsubscriber/?ot=asc&o=1'
+        url = '/test_admin/admin/admin_views/externalsubscriber/?o=1'
         response = self.client.post(url, action_data)
         self.assertRedirects(response, url)
 
