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 %}
-
{% blocktrans %} By {{ title }} {% endblocktrans %}
+
+{% blocktrans %} By {{ title }} {% endblocktrans %}
+{% if modifiers %}({% for modifier in modifiers %}{% if not forloop.first %}|{% endif %}{{ modifier.display }}{% endfor %}){% endif %}
+
{% for choice in choices %}
-
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"