Index: contrib/admin/filterspecs.py
===================================================================
--- contrib/admin/filterspecs.py	(revision 2901)
+++ contrib/admin/filterspecs.py	(working copy)
@@ -34,6 +34,9 @@
     def title(self):
         return self.field.verbose_name
 
+    def modifiers(self, cl):
+        return []
+
     def output(self, cl):
         t = []
         if self.has_output():
@@ -52,8 +55,76 @@
         super(RelatedFilterSpec, self).__init__(f, request, params)
         if isinstance(f, models.ManyToManyField):
             self.lookup_title = f.rel.to._meta.verbose_name
+            self.is_manytomany = True
         else:
             self.lookup_title = f.verbose_name
+            self.is_manytomany = False
+        self.lookup_kwarg_and = '%s__%s__list_and' % (f.name, f.rel.to._meta.pk.name)
+        self.lookup_kwarg_or = '%s__%s__list_or' % (f.name, f.rel.to._meta.pk.name)
+        if self.is_manytomany and request.GET.get(self.lookup_kwarg_and, False):
+            self.lookup_kwarg = self.lookup_kwarg_and
+        else:
+            self.lookup_kwarg = self.lookup_kwarg_or
+        self.lookup_val = request.GET.get(self.lookup_kwarg, [])
+        if self.lookup_val:
+            self.lookup_val = [int(val) for val in self.lookup_val.split(models.query.LISTVALUE_SEPARATOR)]
+        self.lookup_choices = f.rel.to._default_manager.all()
+
+    def has_output(self):
+        return len(self.lookup_choices) > 1
+
+    def title(self):
+        return self.lookup_title
+
+    def modifiers(self, cl):
+        if not self.is_manytomany:
+            return []
+        pk_val_string = models.query.LISTVALUE_SEPARATOR.join([str(val_copy) for val_copy in self.lookup_val[:]])
+        qs = cl.get_query_string( {self.lookup_kwarg_or: pk_val_string}, [self.lookup_kwarg_and])
+        if not pk_val_string:
+            qs = cl.get_query_string({}, [self.lookup_kwarg])
+        modifier_or = {'selected': (self.lookup_kwarg is self.lookup_kwarg_or),
+                       'query_string': qs,
+                       'display': _('or')}
+        qs = cl.get_query_string({self.lookup_kwarg_and: pk_val_string}, [self.lookup_kwarg_or])
+        if not pk_val_string:
+            qs = cl.get_query_string({}, [self.lookup_kwarg])
+        modifier_and = {'selected': (self.lookup_kwarg is self.lookup_kwarg_and),
+                        'query_string': qs,
+                        'display': _('and')}
+        return [modifier_or, modifier_and]
+
+    def choices(self, cl):
+        yield {'selected': not self.lookup_val,
+               'query_string': cl.get_query_string({}, [self.lookup_kwarg]),
+               'display': _('All')}
+        for val in self.lookup_choices:
+            pk_val = getattr(val, self.field.rel.to._meta.pk.attname)
+            lookup_val_copy = self.lookup_val[:]
+            if pk_val in lookup_val_copy:
+                lookup_val_copy.remove(pk_val)
+            else:
+                lookup_val_copy.append(pk_val)
+            pk_val_string = models.query.LISTVALUE_SEPARATOR.join([str(val_copy) for val_copy in lookup_val_copy])
+            if pk_val_string:
+                qs = cl.get_query_string( {self.lookup_kwarg: pk_val_string})
+            else: 
+                qs = cl.get_query_string({}, [self.lookup_kwarg])
+            yield {'selected': pk_val in self.lookup_val,
+                   'query_string': qs,
+                   'display': val}
+
+FilterSpec.register(lambda f: bool(f.rel), RelatedFilterSpec)
+
+# ForeignFilterSpec was the original RelatedFilterSpec, but that is changed to 
+# use multi-select...
+class ForeignFilterSpec(FilterSpec):
+    def __init__(self, f, request, params):
+        super(ForeignFilterSpec, self).__init__(f, request, params)
+        if isinstance(f, models.ManyToManyField):
+            self.lookup_title = f.rel.to._meta.verbose_name
+        else:
+            self.lookup_title = f.verbose_name
         self.lookup_kwarg = '%s__%s__exact' % (f.name, f.rel.to._meta.pk.name)
         self.lookup_val = request.GET.get(self.lookup_kwarg, None)
         self.lookup_choices = f.rel.to._default_manager.all()
@@ -74,7 +145,7 @@
                    'query_string': cl.get_query_string( {self.lookup_kwarg: pk_val}),
                    'display': val}
 
-FilterSpec.register(lambda f: bool(f.rel), RelatedFilterSpec)
+FilterSpec.register(lambda f: bool(f.rel), ForeignFilterSpec)
 
 class ChoicesFilterSpec(FilterSpec):
     def __init__(self, f, request, params):
Index: contrib/admin/media/css/changelists.css
===================================================================
--- contrib/admin/media/css/changelists.css	(revision 2901)
+++ contrib/admin/media/css/changelists.css	(working copy)
@@ -27,6 +27,9 @@
 #changelist-filter { position:absolute; top:0; right:0; z-index:1000; width:160px; border-left:1px solid #ddd; background:#efefef; margin:0; }
 #changelist-filter h2 { font-size:11px; padding:2px 5px; border-bottom:1px solid #ddd; }
 #changelist-filter h3 { font-size:12px; margin-bottom:0; }
+#changelist-filter h3 span.modifier { font-size:11px; font-weight:normal; margin-left: 2px; }
+#changelist-filter h3 span.modifier a { margin: 0 2px; }
+#changelist-filter h3 span.modifier a.selected { color: #5b80b2; }
 #changelist-filter ul { padding-left:0;margin-left:10px;_margin-right:-10px; }
 #changelist-filter li { list-style-type:none; margin-left:0; padding-left:0; }
 #changelist-filter a { color:#999; }
Index: contrib/admin/templates/admin/filter.html
===================================================================
--- contrib/admin/templates/admin/filter.html	(revision 2901)
+++ contrib/admin/templates/admin/filter.html	(working copy)
@@ -1,5 +1,8 @@
 {% load i18n %}
-<h3>{% blocktrans %} By {{ title }} {% endblocktrans %}</h3>
+<h3>
+{% blocktrans %} By {{ title }} {% endblocktrans %}
+{% if modifiers %}<span class="modifier">({% for modifier in modifiers %}{% if not forloop.first %}|{% endif %}<a{% if modifier.selected %} class="selected"{% endif %} href="{{ modifier.query_string }}">{{ modifier.display }}</a>{% endfor %})</span>{% endif %}
+</h3>
 <ul>
 {% for choice in choices %}
     <li{% if choice.selected %} class="selected"{% endif %}>
Index: contrib/admin/templatetags/admin_list.py
===================================================================
--- contrib/admin/templatetags/admin_list.py	(revision 2901)
+++ contrib/admin/templatetags/admin_list.py	(working copy)
@@ -252,7 +252,7 @@
 search_form = register.inclusion_tag('admin/search_form.html')(search_form)
 
 def filter(cl, spec):
-    return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
+    return {'title': spec.title(), 'modifiers': spec.modifiers(cl), 'choices' : list(spec.choices(cl))}
 filter = register.inclusion_tag('admin/filter.html')(filter)
 
 def filters(cl):
Index: contrib/auth/models.py
===================================================================
--- contrib/auth/models.py	(revision 2901)
+++ contrib/auth/models.py	(working copy)
@@ -78,8 +78,8 @@
             (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
             (_('Groups'), {'fields': ('groups',)}),
         )
-        list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
-        list_filter = ('is_staff', 'is_superuser')
+        list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'groups')
+        list_filter = ('groups', 'is_staff', 'is_superuser')
         search_fields = ('username', 'first_name', 'last_name', 'email')
 
     def __str__(self):
Index: core/management.py
===================================================================
--- core/management.py	(revision 2901)
+++ core/management.py	(working copy)
@@ -890,8 +890,9 @@
                             if not hasattr(cls, fn):
                                 e.add(opts, '"admin.list_display" refers to %r, which isn\'t an attribute, method or property.' % fn)
                         else:
-                            if isinstance(f, models.ManyToManyField):
-                                e.add(opts, '"admin.list_display" doesn\'t support ManyToManyFields (%r).' % fn)
+                            pass # for this I have added a __repr__ to the ManyRelatedManager
+                            #if isinstance(f, models.ManyToManyField):
+                            #    e.add(opts, '"admin.list_display" doesn\'t support ManyToManyFields (%r).' % fn)
                 # list_filter
                 if not isinstance(opts.admin.list_filter, (list, tuple)):
                     e.add(opts, '"admin.list_filter", if given, must be set to a list or tuple.')
Index: db/models/fields/__init__.py
===================================================================
--- db/models/fields/__init__.py	(revision 2901)
+++ db/models/fields/__init__.py	(working copy)
@@ -164,7 +164,7 @@
         "Returns field's value prepared for database lookup."
         if lookup_type in ('exact', 'gt', 'gte', 'lt', 'lte', 'ne', 'year', 'month', 'day'):
             return [value]
-        elif lookup_type in ('range', 'in'):
+        elif lookup_type in ('range', 'in', 'list_or', 'list_and'):
             return value
         elif lookup_type in ('contains', 'icontains'):
             return ["%%%s%%" % prep_for_like_query(value)]
Index: db/models/fields/related.py
===================================================================
--- db/models/fields/related.py	(revision 2901)
+++ db/models/fields/related.py	(working copy)
@@ -243,6 +243,9 @@
             if self._pk_val is None:
                 raise ValueError("%r instance needs to have a primary key value before a many-to-many relationship can be used." % model)
 
+        def __repr__(self):
+            return ", ".join([str(item) for item in self.all()])
+
         def get_query_set(self):
             return superclass.get_query_set(self).filter(**(self.core_filters))
 
Index: db/models/query.py
===================================================================
--- db/models/query.py	(revision 2901)
+++ db/models/query.py	(working copy)
@@ -11,6 +11,7 @@
     from sets import Set as set
 
 LOOKUP_SEPARATOR = '__'
+LISTVALUE_SEPARATOR = ','
 
 # Size of each "chunk" for get_iterator calls.
 # Larger values are slightly faster at the expense of more storage space.
@@ -74,11 +75,13 @@
         self._distinct = False       # Whether the query should use SELECT DISTINCT.
         self._select = {}            # Dictionary of attname -> SQL.
         self._where = []             # List of extra WHERE clauses to use.
+        self._groupby = []           # Matching a list of IDs requires a GROUP BY and HAVING clause.
         self._params = []            # List of params to use for extra WHERE clauses.
         self._tables = []            # List of extra tables to use.
         self._offset = None          # OFFSET clause
         self._limit = None           # LIMIT clause
         self._result_cache = None
+        self.has_groupby = False
 
     ########################
     # PYTHON MAGIC METHODS #
@@ -183,7 +186,17 @@
         select, sql, params = counter._get_sql_clause()
         cursor = connection.cursor()
         cursor.execute("SELECT COUNT(*)" + sql, params)
-        return cursor.fetchone()[0]
+        row = cursor.fetchone()
+        if not row: return 0
+        if not counter.has_groupby: return row[0]
+        # Ouch! doing a SELECT COUNT(*) on a GROUP BY query to get the number of
+        # records won't work, as you actually get more records, nicely grouped.
+        # So, count the records instead. Perhaps I could just return -1 or something
+        # for efficiency, but for now, return a correct rowcount
+        count = 1
+        while cursor.fetchone():
+            count+= 1
+        return count
 
     def get(self, *args, **kwargs):
         "Performs the SELECT and returns a single object matching the given keyword arguments."
@@ -350,6 +363,7 @@
         c._distinct = self._distinct
         c._select = self._select.copy()
         c._where = self._where[:]
+        c._groupby = self._groupby[:]
         c._params = self._params[:]
         c._tables = self._tables[:]
         c._offset = self._offset
@@ -386,13 +400,15 @@
         tables = [quote_only_if_word(t) for t in self._tables]
         joins = SortedDict()
         where = self._where[:]
+        groupby = self._groupby[:]
         params = self._params[:]
 
         # Convert self._filters into SQL.
-        tables2, joins2, where2, params2 = self._filters.get_sql(opts)
+        tables2, joins2, where2, groupby2, params2 = self._filters.get_sql(opts)
         tables.extend(tables2)
         joins.update(joins2)
         where.extend(where2)
+        groupby.extend(groupby2)
         params.extend(params2)
 
         # Add additional tables and WHERE clauses based on select_related.
@@ -419,6 +435,12 @@
         if where:
             sql.append(where and "WHERE " + " AND ".join(where))
 
+        # Compose the GROUP BY clause into SQL.
+        if groupby:
+            # TODO: check what happens if there's more than one groupby item
+            sql.append("GROUP BY " + ",".join(select) + (" HAVING count(" + select[0] + ")>=%d" % (groupby[0], )))
+            self.has_groupby = True
+
         # ORDER BY clause
         order_by = []
         if self._order_by is not None:
@@ -518,16 +540,17 @@
         self.args = args
 
     def get_sql(self, opts):
-        tables, joins, where, params = [], SortedDict(), [], []
+        tables, joins, where, groupby, params = [], SortedDict(), [], [], []
         for val in self.args:
-            tables2, joins2, where2, params2 = val.get_sql(opts)
+            tables2, joins2, where2, groupby2, params2 = val.get_sql(opts)
             tables.extend(tables2)
             joins.update(joins2)
             where.extend(where2)
+            groupby.extend(groupby2)
             params.extend(params2)
         if where:
-            return tables, joins, ['(%s)' % self.operator.join(where)], params
-        return tables, joins, [], params
+            return tables, joins, ['(%s)' % self.operator.join(where)], groupby, params
+        return tables, joins, [], groupby, params
 
 class QAnd(QOperator):
     "Encapsulates a combined query that uses 'AND'."
@@ -575,9 +598,9 @@
     "Encapsulates NOT (...) queries as objects"
 
     def get_sql(self, opts):
-        tables, joins, where, params = super(QNot, self).get_sql(opts)
+        tables, joins, where, groupby, params = super(QNot, self).get_sql(opts)
         where2 = ['(NOT (%s))' % " AND ".join(where)]
-        return tables, joins, where2, params
+        return tables, joins, where2, groupby, params
 
 def get_where_clause(lookup_type, table_prefix, field_name, value):
     if table_prefix.endswith('.'):
@@ -587,7 +610,7 @@
         return '%s%s %s' % (table_prefix, field_name, (backend.OPERATOR_MAPPING[lookup_type] % '%s'))
     except KeyError:
         pass
-    if lookup_type == 'in':
+    if lookup_type in ('in', 'list_or', 'list_and'):
         return '%s%s IN (%s)' % (table_prefix, field_name, ','.join(['%s' for v in value]))
     elif lookup_type == 'range':
         return '%s%s BETWEEN %%s AND %%s' % (table_prefix, field_name)
@@ -648,7 +671,7 @@
     # At present, this method only every returns INNER JOINs; the option is
     # there for others to implement custom Q()s, etc that return other join
     # types.
-    tables, joins, where, params = [], SortedDict(), [], []
+    tables, joins, where, groupby, params = [], SortedDict(), [], [], []
 
     for kwarg, value in kwarg_items:
         if value is not None:
@@ -674,12 +697,13 @@
             if len(path) < 1:
                 raise TypeError, "Cannot parse keyword query %r" % kwarg
 
-            tables2, joins2, where2, params2 = lookup_inner(path, clause, value, opts, opts.db_table, None)
+            tables2, joins2, where2, groupby2, params2 = lookup_inner(path, clause, value, opts, opts.db_table, None)
             tables.extend(tables2)
             joins.update(joins2)
             where.extend(where2)
+            groupby.extend(groupby2)
             params.extend(params2)
-    return tables, joins, where, params
+    return tables, joins, where, groupby, params
 
 class FieldFound(Exception):
     "Exception used to short circuit field-finding operations."
@@ -699,7 +723,7 @@
     return matches[0]
 
 def lookup_inner(path, clause, value, opts, table, column):
-    tables, joins, where, params = [], SortedDict(), [], []
+    tables, joins, where, groupby, params = [], SortedDict(), [], [], []
     current_opts = opts
     current_table = table
     current_column = column
@@ -817,11 +841,12 @@
             join_column = None
 
         # There are name queries remaining. Recurse deeper.
-        tables2, joins2, where2, params2 = lookup_inner(path, clause, value, new_opts, new_table, join_column)
+        tables2, joins2, where2, groupby2, params2 = lookup_inner(path, clause, value, new_opts, new_table, join_column)
 
         tables.extend(tables2)
         joins.update(joins2)
         where.extend(where2)
+        groupby.extend(groupby2)
         params.extend(params2)
     else:
         # Evaluate clause on current table.
@@ -832,11 +857,15 @@
             column = current_column
         else:
             column = field.column
+        if clause in ('list_or', 'list_and'):
+            value = value.split(LISTVALUE_SEPARATOR)
+            min_matches = (clause=='list_or') and 1 or len(value)
+            groupby.append(min_matches)
 
         where.append(get_where_clause(clause, current_table + '.', column, value))
         params.extend(field.get_db_prep_lookup(clause, value))
 
-    return tables, joins, where, params
+    return tables, joins, where, groupby, params
 
 def delete_objects(seen_objs):
     "Iterate through a list of seen classes, and remove any instances that are referred to"
