Code

Ticket #1873: multi-select-relatedfilterspec.2.diff

File multi-select-relatedfilterspec.2.diff, 17.6 KB (added by marc@…, 8 years ago)
Line 
1Index: contrib/admin/filterspecs.py
2===================================================================
3--- contrib/admin/filterspecs.py        (revision 2901)
4+++ contrib/admin/filterspecs.py        (working copy)
5@@ -34,6 +34,9 @@
6     def title(self):
7         return self.field.verbose_name
8 
9+    def modifiers(self, cl):
10+        return []
11+
12     def output(self, cl):
13         t = []
14         if self.has_output():
15@@ -52,8 +55,76 @@
16         super(RelatedFilterSpec, self).__init__(f, request, params)
17         if isinstance(f, models.ManyToManyField):
18             self.lookup_title = f.rel.to._meta.verbose_name
19+            self.is_manytomany = True
20         else:
21             self.lookup_title = f.verbose_name
22+            self.is_manytomany = False
23+        self.lookup_kwarg_and = '%s__%s__list_and' % (f.name, f.rel.to._meta.pk.name)
24+        self.lookup_kwarg_or = '%s__%s__list_or' % (f.name, f.rel.to._meta.pk.name)
25+        if self.is_manytomany and request.GET.get(self.lookup_kwarg_and, False):
26+            self.lookup_kwarg = self.lookup_kwarg_and
27+        else:
28+            self.lookup_kwarg = self.lookup_kwarg_or
29+        self.lookup_val = request.GET.get(self.lookup_kwarg, [])
30+        if self.lookup_val:
31+            self.lookup_val = [int(val) for val in self.lookup_val.split(models.query.LISTVALUE_SEPARATOR)]
32+        self.lookup_choices = f.rel.to._default_manager.all()
33+
34+    def has_output(self):
35+        return len(self.lookup_choices) > 1
36+
37+    def title(self):
38+        return self.lookup_title
39+
40+    def modifiers(self, cl):
41+        if not self.is_manytomany:
42+            return []
43+        pk_val_string = models.query.LISTVALUE_SEPARATOR.join([str(val_copy) for val_copy in self.lookup_val[:]])
44+        qs = cl.get_query_string( {self.lookup_kwarg_or: pk_val_string}, [self.lookup_kwarg_and])
45+        if not pk_val_string:
46+            qs = cl.get_query_string({}, [self.lookup_kwarg])
47+        modifier_or = {'selected': (self.lookup_kwarg is self.lookup_kwarg_or),
48+                       'query_string': qs,
49+                       'display': _('or')}
50+        qs = cl.get_query_string({self.lookup_kwarg_and: pk_val_string}, [self.lookup_kwarg_or])
51+        if not pk_val_string:
52+            qs = cl.get_query_string({}, [self.lookup_kwarg])
53+        modifier_and = {'selected': (self.lookup_kwarg is self.lookup_kwarg_and),
54+                        'query_string': qs,
55+                        'display': _('and')}
56+        return [modifier_or, modifier_and]
57+
58+    def choices(self, cl):
59+        yield {'selected': not self.lookup_val,
60+               'query_string': cl.get_query_string({}, [self.lookup_kwarg]),
61+               'display': _('All')}
62+        for val in self.lookup_choices:
63+            pk_val = getattr(val, self.field.rel.to._meta.pk.attname)
64+            lookup_val_copy = self.lookup_val[:]
65+            if pk_val in lookup_val_copy:
66+                lookup_val_copy.remove(pk_val)
67+            else:
68+                lookup_val_copy.append(pk_val)
69+            pk_val_string = models.query.LISTVALUE_SEPARATOR.join([str(val_copy) for val_copy in lookup_val_copy])
70+            if pk_val_string:
71+                qs = cl.get_query_string( {self.lookup_kwarg: pk_val_string})
72+            else:
73+                qs = cl.get_query_string({}, [self.lookup_kwarg])
74+            yield {'selected': pk_val in self.lookup_val,
75+                   'query_string': qs,
76+                   'display': val}
77+
78+FilterSpec.register(lambda f: bool(f.rel), RelatedFilterSpec)
79+
80+# ForeignFilterSpec was the original RelatedFilterSpec, but that is changed to
81+# use multi-select...
82+class ForeignFilterSpec(FilterSpec):
83+    def __init__(self, f, request, params):
84+        super(ForeignFilterSpec, self).__init__(f, request, params)
85+        if isinstance(f, models.ManyToManyField):
86+            self.lookup_title = f.rel.to._meta.verbose_name
87+        else:
88+            self.lookup_title = f.verbose_name
89         self.lookup_kwarg = '%s__%s__exact' % (f.name, f.rel.to._meta.pk.name)
90         self.lookup_val = request.GET.get(self.lookup_kwarg, None)
91         self.lookup_choices = f.rel.to._default_manager.all()
92@@ -74,7 +145,7 @@
93                    'query_string': cl.get_query_string( {self.lookup_kwarg: pk_val}),
94                    'display': val}
95 
96-FilterSpec.register(lambda f: bool(f.rel), RelatedFilterSpec)
97+FilterSpec.register(lambda f: bool(f.rel), ForeignFilterSpec)
98 
99 class ChoicesFilterSpec(FilterSpec):
100     def __init__(self, f, request, params):
101Index: contrib/admin/media/css/changelists.css
102===================================================================
103--- contrib/admin/media/css/changelists.css     (revision 2901)
104+++ contrib/admin/media/css/changelists.css     (working copy)
105@@ -27,6 +27,9 @@
106 #changelist-filter { position:absolute; top:0; right:0; z-index:1000; width:160px; border-left:1px solid #ddd; background:#efefef; margin:0; }
107 #changelist-filter h2 { font-size:11px; padding:2px 5px; border-bottom:1px solid #ddd; }
108 #changelist-filter h3 { font-size:12px; margin-bottom:0; }
109+#changelist-filter h3 span.modifier { font-size:11px; font-weight:normal; margin-left: 2px; }
110+#changelist-filter h3 span.modifier a { margin: 0 2px; }
111+#changelist-filter h3 span.modifier a.selected { color: #5b80b2; }
112 #changelist-filter ul { padding-left:0;margin-left:10px;_margin-right:-10px; }
113 #changelist-filter li { list-style-type:none; margin-left:0; padding-left:0; }
114 #changelist-filter a { color:#999; }
115Index: contrib/admin/templates/admin/filter.html
116===================================================================
117--- contrib/admin/templates/admin/filter.html   (revision 2901)
118+++ contrib/admin/templates/admin/filter.html   (working copy)
119@@ -1,5 +1,8 @@
120 {% load i18n %}
121-<h3>{% blocktrans %} By {{ title }} {% endblocktrans %}</h3>
122+<h3>
123+{% blocktrans %} By {{ title }} {% endblocktrans %}
124+{% 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 %}
125+</h3>
126 <ul>
127 {% for choice in choices %}
128     <li{% if choice.selected %} class="selected"{% endif %}>
129Index: contrib/admin/templatetags/admin_list.py
130===================================================================
131--- contrib/admin/templatetags/admin_list.py    (revision 2901)
132+++ contrib/admin/templatetags/admin_list.py    (working copy)
133@@ -252,7 +252,7 @@
134 search_form = register.inclusion_tag('admin/search_form.html')(search_form)
135 
136 def filter(cl, spec):
137-    return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
138+    return {'title': spec.title(), 'modifiers': spec.modifiers(cl), 'choices' : list(spec.choices(cl))}
139 filter = register.inclusion_tag('admin/filter.html')(filter)
140 
141 def filters(cl):
142Index: contrib/auth/models.py
143===================================================================
144--- contrib/auth/models.py      (revision 2901)
145+++ contrib/auth/models.py      (working copy)
146@@ -78,8 +78,8 @@
147             (_('Important dates'), {'fields': ('last_login', 'date_joined')}),
148             (_('Groups'), {'fields': ('groups',)}),
149         )
150-        list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
151-        list_filter = ('is_staff', 'is_superuser')
152+        list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff', 'groups')
153+        list_filter = ('groups', 'is_staff', 'is_superuser')
154         search_fields = ('username', 'first_name', 'last_name', 'email')
155 
156     def __str__(self):
157Index: core/management.py
158===================================================================
159--- core/management.py  (revision 2901)
160+++ core/management.py  (working copy)
161@@ -890,8 +890,9 @@
162                             if not hasattr(cls, fn):
163                                 e.add(opts, '"admin.list_display" refers to %r, which isn\'t an attribute, method or property.' % fn)
164                         else:
165-                            if isinstance(f, models.ManyToManyField):
166-                                e.add(opts, '"admin.list_display" doesn\'t support ManyToManyFields (%r).' % fn)
167+                            pass # for this I have added a __repr__ to the ManyRelatedManager
168+                            #if isinstance(f, models.ManyToManyField):
169+                            #    e.add(opts, '"admin.list_display" doesn\'t support ManyToManyFields (%r).' % fn)
170                 # list_filter
171                 if not isinstance(opts.admin.list_filter, (list, tuple)):
172                     e.add(opts, '"admin.list_filter", if given, must be set to a list or tuple.')
173Index: db/models/fields/__init__.py
174===================================================================
175--- db/models/fields/__init__.py        (revision 2901)
176+++ db/models/fields/__init__.py        (working copy)
177@@ -164,7 +164,7 @@
178         "Returns field's value prepared for database lookup."
179         if lookup_type in ('exact', 'gt', 'gte', 'lt', 'lte', 'ne', 'year', 'month', 'day'):
180             return [value]
181-        elif lookup_type in ('range', 'in'):
182+        elif lookup_type in ('range', 'in', 'list_or', 'list_and'):
183             return value
184         elif lookup_type in ('contains', 'icontains'):
185             return ["%%%s%%" % prep_for_like_query(value)]
186Index: db/models/fields/related.py
187===================================================================
188--- db/models/fields/related.py (revision 2901)
189+++ db/models/fields/related.py (working copy)
190@@ -243,6 +243,9 @@
191             if self._pk_val is None:
192                 raise ValueError("%r instance needs to have a primary key value before a many-to-many relationship can be used." % model)
193 
194+        def __repr__(self):
195+            return ", ".join([str(item) for item in self.all()])
196+
197         def get_query_set(self):
198             return superclass.get_query_set(self).filter(**(self.core_filters))
199 
200Index: db/models/query.py
201===================================================================
202--- db/models/query.py  (revision 2901)
203+++ db/models/query.py  (working copy)
204@@ -11,6 +11,7 @@
205     from sets import Set as set
206 
207 LOOKUP_SEPARATOR = '__'
208+LISTVALUE_SEPARATOR = ','
209 
210 # Size of each "chunk" for get_iterator calls.
211 # Larger values are slightly faster at the expense of more storage space.
212@@ -74,11 +75,13 @@
213         self._distinct = False       # Whether the query should use SELECT DISTINCT.
214         self._select = {}            # Dictionary of attname -> SQL.
215         self._where = []             # List of extra WHERE clauses to use.
216+        self._groupby = []           # Matching a list of IDs requires a GROUP BY and HAVING clause.
217         self._params = []            # List of params to use for extra WHERE clauses.
218         self._tables = []            # List of extra tables to use.
219         self._offset = None          # OFFSET clause
220         self._limit = None           # LIMIT clause
221         self._result_cache = None
222+        self.has_groupby = False
223 
224     ########################
225     # PYTHON MAGIC METHODS #
226@@ -183,7 +186,17 @@
227         select, sql, params = counter._get_sql_clause()
228         cursor = connection.cursor()
229         cursor.execute("SELECT COUNT(*)" + sql, params)
230-        return cursor.fetchone()[0]
231+        row = cursor.fetchone()
232+        if not row: return 0
233+        if not counter.has_groupby: return row[0]
234+        # Ouch! doing a SELECT COUNT(*) on a GROUP BY query to get the number of
235+        # records won't work, as you actually get more records, nicely grouped.
236+        # So, count the records instead. Perhaps I could just return -1 or something
237+        # for efficiency, but for now, return a correct rowcount
238+        count = 1
239+        while cursor.fetchone():
240+            count+= 1
241+        return count
242 
243     def get(self, *args, **kwargs):
244         "Performs the SELECT and returns a single object matching the given keyword arguments."
245@@ -350,6 +363,7 @@
246         c._distinct = self._distinct
247         c._select = self._select.copy()
248         c._where = self._where[:]
249+        c._groupby = self._groupby[:]
250         c._params = self._params[:]
251         c._tables = self._tables[:]
252         c._offset = self._offset
253@@ -386,13 +400,15 @@
254         tables = [quote_only_if_word(t) for t in self._tables]
255         joins = SortedDict()
256         where = self._where[:]
257+        groupby = self._groupby[:]
258         params = self._params[:]
259 
260         # Convert self._filters into SQL.
261-        tables2, joins2, where2, params2 = self._filters.get_sql(opts)
262+        tables2, joins2, where2, groupby2, params2 = self._filters.get_sql(opts)
263         tables.extend(tables2)
264         joins.update(joins2)
265         where.extend(where2)
266+        groupby.extend(groupby2)
267         params.extend(params2)
268 
269         # Add additional tables and WHERE clauses based on select_related.
270@@ -419,6 +435,12 @@
271         if where:
272             sql.append(where and "WHERE " + " AND ".join(where))
273 
274+        # Compose the GROUP BY clause into SQL.
275+        if groupby:
276+            # TODO: check what happens if there's more than one groupby item
277+            sql.append("GROUP BY " + ",".join(select) + (" HAVING count(" + select[0] + ")>=%d" % (groupby[0], )))
278+            self.has_groupby = True
279+
280         # ORDER BY clause
281         order_by = []
282         if self._order_by is not None:
283@@ -518,16 +540,17 @@
284         self.args = args
285 
286     def get_sql(self, opts):
287-        tables, joins, where, params = [], SortedDict(), [], []
288+        tables, joins, where, groupby, params = [], SortedDict(), [], [], []
289         for val in self.args:
290-            tables2, joins2, where2, params2 = val.get_sql(opts)
291+            tables2, joins2, where2, groupby2, params2 = val.get_sql(opts)
292             tables.extend(tables2)
293             joins.update(joins2)
294             where.extend(where2)
295+            groupby.extend(groupby2)
296             params.extend(params2)
297         if where:
298-            return tables, joins, ['(%s)' % self.operator.join(where)], params
299-        return tables, joins, [], params
300+            return tables, joins, ['(%s)' % self.operator.join(where)], groupby, params
301+        return tables, joins, [], groupby, params
302 
303 class QAnd(QOperator):
304     "Encapsulates a combined query that uses 'AND'."
305@@ -575,9 +598,9 @@
306     "Encapsulates NOT (...) queries as objects"
307 
308     def get_sql(self, opts):
309-        tables, joins, where, params = super(QNot, self).get_sql(opts)
310+        tables, joins, where, groupby, params = super(QNot, self).get_sql(opts)
311         where2 = ['(NOT (%s))' % " AND ".join(where)]
312-        return tables, joins, where2, params
313+        return tables, joins, where2, groupby, params
314 
315 def get_where_clause(lookup_type, table_prefix, field_name, value):
316     if table_prefix.endswith('.'):
317@@ -587,7 +610,7 @@
318         return '%s%s %s' % (table_prefix, field_name, (backend.OPERATOR_MAPPING[lookup_type] % '%s'))
319     except KeyError:
320         pass
321-    if lookup_type == 'in':
322+    if lookup_type in ('in', 'list_or', 'list_and'):
323         return '%s%s IN (%s)' % (table_prefix, field_name, ','.join(['%s' for v in value]))
324     elif lookup_type == 'range':
325         return '%s%s BETWEEN %%s AND %%s' % (table_prefix, field_name)
326@@ -648,7 +671,7 @@
327     # At present, this method only every returns INNER JOINs; the option is
328     # there for others to implement custom Q()s, etc that return other join
329     # types.
330-    tables, joins, where, params = [], SortedDict(), [], []
331+    tables, joins, where, groupby, params = [], SortedDict(), [], [], []
332 
333     for kwarg, value in kwarg_items:
334         if value is not None:
335@@ -674,12 +697,13 @@
336             if len(path) < 1:
337                 raise TypeError, "Cannot parse keyword query %r" % kwarg
338 
339-            tables2, joins2, where2, params2 = lookup_inner(path, clause, value, opts, opts.db_table, None)
340+            tables2, joins2, where2, groupby2, params2 = lookup_inner(path, clause, value, opts, opts.db_table, None)
341             tables.extend(tables2)
342             joins.update(joins2)
343             where.extend(where2)
344+            groupby.extend(groupby2)
345             params.extend(params2)
346-    return tables, joins, where, params
347+    return tables, joins, where, groupby, params
348 
349 class FieldFound(Exception):
350     "Exception used to short circuit field-finding operations."
351@@ -699,7 +723,7 @@
352     return matches[0]
353 
354 def lookup_inner(path, clause, value, opts, table, column):
355-    tables, joins, where, params = [], SortedDict(), [], []
356+    tables, joins, where, groupby, params = [], SortedDict(), [], [], []
357     current_opts = opts
358     current_table = table
359     current_column = column
360@@ -817,11 +841,12 @@
361             join_column = None
362 
363         # There are name queries remaining. Recurse deeper.
364-        tables2, joins2, where2, params2 = lookup_inner(path, clause, value, new_opts, new_table, join_column)
365+        tables2, joins2, where2, groupby2, params2 = lookup_inner(path, clause, value, new_opts, new_table, join_column)
366 
367         tables.extend(tables2)
368         joins.update(joins2)
369         where.extend(where2)
370+        groupby.extend(groupby2)
371         params.extend(params2)
372     else:
373         # Evaluate clause on current table.
374@@ -832,11 +857,15 @@
375             column = current_column
376         else:
377             column = field.column
378+        if clause in ('list_or', 'list_and'):
379+            value = value.split(LISTVALUE_SEPARATOR)
380+            min_matches = (clause=='list_or') and 1 or len(value)
381+            groupby.append(min_matches)
382 
383         where.append(get_where_clause(clause, current_table + '.', column, value))
384         params.extend(field.get_db_prep_lookup(clause, value))
385 
386-    return tables, joins, where, params
387+    return tables, joins, where, groupby, params
388 
389 def delete_objects(seen_objs):
390     "Iterate through a list of seen classes, and remove any instances that are referred to"