Code

Ticket #6095: 6095-nfadmin.diff

File 6095-nfadmin.diff, 26.8 KB (added by floguy, 6 years ago)

Updated patch for newforms-admin, with the added bonus of fixing the integration problem with the admin. Now when a ManyToManyField specifies an intermediary model, it does not display the field in the admin.

Line 
1diff --git a/AUTHORS b/AUTHORS
2index ea898de..6278a73 100644
3--- a/AUTHORS
4+++ b/AUTHORS
5@@ -134,6 +134,7 @@ answer newbie questions, and generally made Django that much better:
6     Afonso Fernández Nogueira <fonzzo.django@gmail.com>
7     Matthew Flanagan <http://wadofstuff.blogspot.com>
8     Eric Floehr <eric@intellovations.com>
9+    Eric Florenzano <floguy@gmail.com>
10     Vincent Foley <vfoleybourgon@yahoo.ca>
11     Rudolph Froger <rfroger@estrate.nl>
12     Jorge Gajon <gajon@gajon.org>
13diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
14index ec32c89..6f010e7 100644
15--- a/django/contrib/admin/options.py
16+++ b/django/contrib/admin/options.py
17@@ -172,7 +172,10 @@ class BaseModelAdmin(object):
18                 kwargs['widget'] = widgets.ForeignKeyRawIdWidget(db_field.rel)
19             else:
20                 if isinstance(db_field, models.ManyToManyField):
21-                    if db_field.name in self.raw_id_fields:
22+                    # If it uses an intermediary model, don't show field in admin.
23+                    if db_field.rel.through != None:
24+                        return None
25+                    elif db_field.name in self.raw_id_fields:
26                         kwargs['widget'] = widgets.ManyToManyRawIdWidget(db_field.rel)
27                         kwargs['help_text'] = ''
28                     elif db_field.name in (self.filter_vertical + self.filter_horizontal):
29diff --git a/django/core/management/sql.py b/django/core/management/sql.py
30index 15bffce..5430dea 100644
31--- a/django/core/management/sql.py
32+++ b/django/core/management/sql.py
33@@ -352,7 +352,7 @@ def many_to_many_sql_for_model(model, style):
34     qn = connection.ops.quote_name
35     inline_references = connection.features.inline_fk_references
36     for f in opts.many_to_many:
37-        if not isinstance(f.rel, generic.GenericRel):
38+        if not isinstance(f.rel, generic.GenericRel) and f.creates_table:
39             tablespace = f.db_tablespace or opts.db_tablespace
40             if tablespace and connection.features.supports_tablespaces and connection.features.autoindexes_primary_keys:
41                 tablespace_sql = ' ' + connection.ops.tablespace_sql(tablespace, inline=True)
42diff --git a/django/core/management/validation.py b/django/core/management/validation.py
43index 6e8e6d0..241563e 100644
44--- a/django/core/management/validation.py
45+++ b/django/core/management/validation.py
46@@ -102,6 +102,8 @@ def get_validation_errors(outfile, app=None):
47                         if r.get_accessor_name() == rel_query_name:
48                             e.add(opts, "Reverse query name for field '%s' clashes with related field '%s.%s'. Add a related_name argument to the definition for '%s'." % (f.name, rel_opts.object_name, r.get_accessor_name(), f.name))
49 
50+        seen_intermediary_signatures = []
51+
52         for i, f in enumerate(opts.many_to_many):
53             # Check to see if the related m2m field will clash with any
54             # existing fields, m2m fields, m2m related objects or related objects
55@@ -111,6 +113,28 @@ def get_validation_errors(outfile, app=None):
56                 # so skip the next section
57                 if isinstance(f.rel.to, (str, unicode)):
58                     continue
59+            if hasattr(f.rel, 'through') and f.rel.through != None:
60+                intermediary_model = None
61+                for model in models.get_models():
62+                    if model._meta.module_name == f.rel.through.lower():
63+                        intermediary_model = model
64+                if intermediary_model == None:
65+                    e.add(opts, "%s has a manually-defined m2m relationship through a model (%s) which does not exist." % (f.name, f.rel.through))
66+                else:
67+                    signature = (f.rel.to, cls, intermediary_model)
68+                    if signature in seen_intermediary_signatures:
69+                        e.add(opts, "%s has two manually-defined m2m relationships through the same model (%s), which is not possible.  Please use a field on your intermediary model instead." % (cls._meta.object_name, intermediary_model._meta.object_name))
70+                    else:
71+                        seen_intermediary_signatures.append(signature)
72+                    seen_related_fk, seen_this_fk = False, False
73+                    for field in intermediary_model._meta.fields:
74+                        if field.rel:
75+                            if field.rel.to == f.rel.to:
76+                                seen_related_fk = True
77+                            elif field.rel.to == cls:
78+                                seen_this_fk = True
79+                    if not seen_related_fk or not seen_this_fk:
80+                        e.add(opts, "%s has a manually-defined m2m relationship through a model (%s) which does not have foreign keys to %s and %s" % (f.name, f.rel.through, f.rel.to._meta.object_name, cls._meta.object_name))
81 
82             rel_opts = f.rel.to._meta
83             rel_name = RelatedObject(f.rel.to, cls, f).get_accessor_name()
84diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py
85index 2bd31c9..86215e4 100644
86--- a/django/db/models/fields/related.py
87+++ b/django/db/models/fields/related.py
88@@ -10,6 +10,7 @@ from django.core import validators
89 from django import oldforms
90 from django import newforms as forms
91 from django.dispatch import dispatcher
92+from new import instancemethod
93 
94 try:
95     set
96@@ -262,7 +263,7 @@ class ForeignRelatedObjectsDescriptor(object):
97             manager.clear()
98         manager.add(*value)
99 
100-def create_many_related_manager(superclass):
101+def create_many_related_manager(superclass, through=False):
102     """Creates a manager that subclasses 'superclass' (which is a Manager)
103     and adds behavior for many-to-many related objects."""
104     class ManyRelatedManager(superclass):
105@@ -276,6 +277,7 @@ def create_many_related_manager(superclass):
106             self.join_table = join_table
107             self.source_col_name = source_col_name
108             self.target_col_name = target_col_name
109+            self.through = through
110             self._pk_val = self.instance._get_pk_val()
111             if self._pk_val is None:
112                 raise ValueError("%r instance needs to have a primary key value before a many-to-many relationship can be used." % model)
113@@ -283,22 +285,6 @@ def create_many_related_manager(superclass):
114         def get_query_set(self):
115             return superclass.get_query_set(self).filter(**(self.core_filters))
116 
117-        def add(self, *objs):
118-            self._add_items(self.source_col_name, self.target_col_name, *objs)
119-
120-            # If this is a symmetrical m2m relation to self, add the mirror entry in the m2m table
121-            if self.symmetrical:
122-                self._add_items(self.target_col_name, self.source_col_name, *objs)
123-        add.alters_data = True
124-
125-        def remove(self, *objs):
126-            self._remove_items(self.source_col_name, self.target_col_name, *objs)
127-
128-            # If this is a symmetrical m2m relation to self, remove the mirror entry in the m2m table
129-            if self.symmetrical:
130-                self._remove_items(self.target_col_name, self.source_col_name, *objs)
131-        remove.alters_data = True
132-
133         def clear(self):
134             self._clear_items(self.source_col_name)
135 
136@@ -375,6 +361,24 @@ def create_many_related_manager(superclass):
137                 [self._pk_val])
138             transaction.commit_unless_managed()
139 
140+    def add(self, *objs):
141+        self._add_items(self.source_col_name, self.target_col_name, *objs)
142+        # If this is a symmetrical m2m relation to self, add the mirror entry in the m2m table
143+        if self.symmetrical:
144+            self._add_items(self.target_col_name, self.source_col_name, *objs)
145+    add.alters_data = True
146+
147+    def remove(self, *objs):
148+        self._remove_items(self.source_col_name, self.target_col_name, *objs)
149+        # If this is a symmetrical m2m relation to self, remove the mirror entry in the m2m table
150+        if self.symmetrical:
151+            self._remove_items(self.target_col_name, self.source_col_name, *objs)
152+    remove.alters_data = True
153+
154+    if not through:
155+        ManyRelatedManager.add = instancemethod(add, None, ManyRelatedManager)
156+        ManyRelatedManager.remove = instancemethod(remove, None, ManyRelatedManager)
157+
158     return ManyRelatedManager
159 
160 class ManyRelatedObjectsDescriptor(object):
161@@ -395,7 +399,7 @@ class ManyRelatedObjectsDescriptor(object):
162         # model's default manager.
163         rel_model = self.related.model
164         superclass = rel_model._default_manager.__class__
165-        RelatedManager = create_many_related_manager(superclass)
166+        RelatedManager = create_many_related_manager(superclass, self.related.field.rel.through)
167 
168         qn = connection.ops.quote_name
169         manager = RelatedManager(
170@@ -414,6 +418,10 @@ class ManyRelatedObjectsDescriptor(object):
171         if instance is None:
172             raise AttributeError, "Manager must be accessed via instance"
173 
174+        through = getattr(self.related.field.rel, 'through', None)
175+        if through:
176+            raise AttributeError, "Cannot set values on a ManyToManyField which specifies a through model.  Use %s's Manager instead." % through
177+       
178         manager = self.__get__(instance)
179         manager.clear()
180         manager.add(*value)
181@@ -436,7 +444,7 @@ class ReverseManyRelatedObjectsDescriptor(object):
182         # model's default manager.
183         rel_model=self.field.rel.to
184         superclass = rel_model._default_manager.__class__
185-        RelatedManager = create_many_related_manager(superclass)
186+        RelatedManager = create_many_related_manager(superclass, self.field.rel.through)
187 
188         qn = connection.ops.quote_name
189         manager = RelatedManager(
190@@ -455,6 +463,10 @@ class ReverseManyRelatedObjectsDescriptor(object):
191         if instance is None:
192             raise AttributeError, "Manager must be accessed via instance"
193 
194+        through = getattr(self.field.rel, 'through', None)
195+        if through:
196+            raise AttributeError, "Cannot set values on a ManyToManyField which specifies a through model.  Use %s's Manager instead." % through
197+
198         manager = self.__get__(instance)
199         manager.clear()
200         manager.add(*value)
201@@ -633,8 +645,14 @@ class ManyToManyField(RelatedField, Field):
202             num_in_admin=kwargs.pop('num_in_admin', 0),
203             related_name=kwargs.pop('related_name', None),
204             limit_choices_to=kwargs.pop('limit_choices_to', None),
205-            symmetrical=kwargs.pop('symmetrical', True))
206+            symmetrical=kwargs.pop('symmetrical', True),
207+            through=kwargs.pop('through', None))
208         self.db_table = kwargs.pop('db_table', None)
209+        if kwargs['rel'].through:
210+            self.creates_table = False
211+            assert not self.db_table, "Cannot specify a db_table if an intermediary model is used."
212+        else:
213+            self.creates_table = True
214         Field.__init__(self, **kwargs)
215 
216         msg = ugettext_lazy('Hold down "Control", or "Command" on a Mac, to select more than one.')
217@@ -649,7 +667,9 @@ class ManyToManyField(RelatedField, Field):
218 
219     def _get_m2m_db_table(self, opts):
220         "Function that can be curried to provide the m2m table name for this relation"
221-        if self.db_table:
222+        if self.rel.through != None:
223+            return get_model(opts.app_label, self.rel.through)._meta.db_table
224+        elif self.db_table:
225             return self.db_table
226         else:
227             return '%s_%s' % (opts.db_table, self.name)
228@@ -657,7 +677,11 @@ class ManyToManyField(RelatedField, Field):
229     def _get_m2m_column_name(self, related):
230         "Function that can be curried to provide the source column name for the m2m table"
231         # If this is an m2m relation to self, avoid the inevitable name clash
232-        if related.model == related.parent_model:
233+        if self.rel.through != None:
234+            field = related.model._meta.get_related_object(self.rel.through).field
235+            attname, column = field.get_attname_column()
236+            return column
237+        elif related.model == related.parent_model:
238             return 'from_' + related.model._meta.object_name.lower() + '_id'
239         else:
240             return related.model._meta.object_name.lower() + '_id'
241@@ -665,7 +689,11 @@ class ManyToManyField(RelatedField, Field):
242     def _get_m2m_reverse_name(self, related):
243         "Function that can be curried to provide the related column name for the m2m table"
244         # If this is an m2m relation to self, avoid the inevitable name clash
245-        if related.model == related.parent_model:
246+        if self.rel.through != None:
247+            field = related.parent_model._meta.get_related_object(self.rel.through).field
248+            attname, column = field.get_attname_column()
249+            return column
250+        elif related.model == related.parent_model:
251             return 'to_' + related.parent_model._meta.object_name.lower() + '_id'
252         else:
253             return related.parent_model._meta.object_name.lower() + '_id'
254@@ -780,7 +808,7 @@ class OneToOneRel(ManyToOneRel):
255 
256 class ManyToManyRel(object):
257     def __init__(self, to, num_in_admin=0, related_name=None,
258-        limit_choices_to=None, symmetrical=True):
259+        limit_choices_to=None, symmetrical=True, through=None):
260         self.to = to
261         self.num_in_admin = num_in_admin
262         self.related_name = related_name
263@@ -790,3 +818,4 @@ class ManyToManyRel(object):
264         self.edit_inline = False
265         self.symmetrical = symmetrical
266         self.multiple = True
267+        self.through = through
268diff --git a/django/db/models/options.py b/django/db/models/options.py
269index c0f36d9..00ce771 100644
270--- a/django/db/models/options.py
271+++ b/django/db/models/options.py
272@@ -2,7 +2,7 @@ from django.conf import settings
273 from django.db.models.related import RelatedObject
274 from django.db.models.fields.related import ManyToManyRel
275 from django.db.models.fields import AutoField, FieldDoesNotExist
276-from django.db.models.loading import get_models, app_cache_ready
277+from django.db.models.loading import get_models, get_model, app_cache_ready
278 from django.db.models.query import orderlist2sql
279 from django.utils.translation import activate, deactivate_all, get_language, string_concat
280 from django.utils.encoding import force_unicode, smart_str
281@@ -161,6 +161,15 @@ class Options(object):
282             follow = self.get_follow()
283         return [f for f in self.get_all_related_objects() if follow.get(f.name, None)]
284 
285+    def get_related_object(self, from_model):
286+        "Gets the RelatedObject which links from from_model to this model."
287+        if isinstance(from_model, str):
288+            from_model = get_model(self.app_label, from_model)
289+        for related_object in self.get_all_related_objects():
290+            if related_object.model == from_model:
291+                return related_object
292+        return None
293+
294     def get_data_holders(self, follow=None):
295         if follow == None:
296             follow = self.get_follow()
297diff --git a/tests/modeltests/invalid_models/models.py b/tests/modeltests/invalid_models/models.py
298index 48e574a..ba74c4a 100644
299--- a/tests/modeltests/invalid_models/models.py
300+++ b/tests/modeltests/invalid_models/models.py
301@@ -110,6 +110,31 @@ class Car(models.Model):
302 class MissingRelations(models.Model):
303     rel1 = models.ForeignKey("Rel1")
304     rel2 = models.ManyToManyField("Rel2")
305+   
306+class MissingManualM2MModel(models.Model):
307+    name = models.CharField(max_length=5)
308+    missing_m2m = models.ManyToManyField(Model, through="MissingM2MModel")
309+   
310+class Person(models.Model):
311+    name = models.CharField(max_length=5)
312+
313+class Group(models.Model):
314+    name = models.CharField(max_length=5)
315+    primary = models.ManyToManyField(Person, through="Membership", related_name="primary")
316+    secondary = models.ManyToManyField(Person, through="Membership", related_name="secondary")
317+
318+class GroupTwo(models.Model):
319+    name = models.CharField(max_length=5)
320+    primary = models.ManyToManyField(Person, through="Membership")
321+    secondary = models.ManyToManyField(Group, through="MembershipMissingFK")
322+
323+class Membership(models.Model):
324+    person = models.ForeignKey(Person)
325+    group = models.ForeignKey(Group)
326+    not_default_or_null = models.CharField(max_length=5)
327+
328+class MembershipMissingFK(models.Model):
329+    person = models.ForeignKey(Person)
330 
331 model_errors = """invalid_models.fielderrors: "charfield": CharFields require a "max_length" attribute.
332 invalid_models.fielderrors: "decimalfield": DecimalFields require a "decimal_places" attribute.
333@@ -195,4 +220,8 @@ invalid_models.selfclashm2m: Reverse query name for m2m field 'm2m_3' clashes wi
334 invalid_models.selfclashm2m: Reverse query name for m2m field 'm2m_4' clashes with field 'SelfClashM2M.selfclashm2m'. Add a related_name argument to the definition for 'm2m_4'.
335 invalid_models.missingrelations: 'rel2' has m2m relation with model Rel2, which has not been installed
336 invalid_models.missingrelations: 'rel1' has relation with model Rel1, which has not been installed
337+invalid_models.group: Group has two manually-defined m2m relationships through the same model (Membership), which is not possible.  Please use a field on your intermediary model instead.
338+invalid_models.missingmanualm2mmodel: missing_m2m has a manually-defined m2m relationship through a model (MissingM2MModel) which does not exist.
339+invalid_models.grouptwo: primary has a manually-defined m2m relationship through a model (Membership) which does not have foreign keys to Person and GroupTwo
340+invalid_models.grouptwo: secondary has a manually-defined m2m relationship through a model (MembershipMissingFK) which does not have foreign keys to Group and GroupTwo
341 """
342diff --git a/tests/modeltests/m2m_manual/__init__.py b/tests/modeltests/m2m_manual/__init__.py
343new file mode 100644
344index 0000000..e69de29
345diff --git a/tests/modeltests/m2m_manual/models.py b/tests/modeltests/m2m_manual/models.py
346new file mode 100644
347index 0000000..c79d0c4
348--- /dev/null
349+++ b/tests/modeltests/m2m_manual/models.py
350@@ -0,0 +1,266 @@
351+from django.db import models
352+from datetime import datetime
353+
354+# M2M described on one of the models
355+class Person(models.Model):
356+    name = models.CharField(max_length=128)
357+
358+    def __unicode__(self):
359+        return self.name
360+
361+class Group(models.Model):
362+    name = models.CharField(max_length=128)
363+    members = models.ManyToManyField(Person, through='Membership')
364+    custom_members = models.ManyToManyField(Person, through='CustomMembership', related_name="custom")
365+    nodefaultsnonulls = models.ManyToManyField(Person, through='TestNoDefaultsOrNulls', related_name="testnodefaultsnonulls")
366+   
367+    def __unicode__(self):
368+        return self.name
369+
370+class Membership(models.Model):
371+    person = models.ForeignKey(Person)
372+    group = models.ForeignKey(Group)
373+    date_joined = models.DateTimeField(default=datetime.now)
374+    invite_reason = models.CharField(max_length=64, null=True)
375+   
376+    def __unicode__(self):
377+        return "%s is a member of %s" % (self.person.name, self.group.name)
378+
379+class CustomMembership(models.Model):
380+    person = models.ForeignKey(Person, db_column="custom_person_column", related_name="custom_person_related_name")
381+    group = models.ForeignKey(Group)
382+    weird_fk = models.ForeignKey(Membership, null=True)
383+    date_joined = models.DateTimeField(default=datetime.now)
384+   
385+    def __unicode__(self):
386+        return "%s is a member of %s" % (self.person.name, self.group.name)
387+   
388+    class Meta:
389+        db_table = "test_table"
390+
391+class TestNoDefaultsOrNulls(models.Model):
392+    person = models.ForeignKey(Person)
393+    group = models.ForeignKey(Group)
394+    nodefaultnonull = models.CharField(max_length=5)
395+
396+__test__ = {'API_TESTS':"""
397+>>> from datetime import datetime
398+
399+### Creation and Saving Tests ###
400+
401+>>> bob = Person.objects.create(name = 'Bob')
402+>>> jim = Person.objects.create(name = 'Jim')
403+>>> jane = Person.objects.create(name = 'Jane')
404+>>> rock = Group.objects.create(name = 'Rock')
405+>>> roll = Group.objects.create(name = 'Roll')
406+
407+# We start out by making sure that the Group 'rock' has no members.
408+>>> rock.members.all()
409+[]
410+
411+# To make Jim a member of Group Rock, simply create a Membership object.
412+>>> m1 = Membership.objects.create(person = jim, group = rock)
413+
414+# We can do the same for Jane and Rock.
415+>>> m2 = Membership.objects.create(person = jane, group = rock)
416+
417+# Let's check to make sure that it worked.  Jane and Jim should be members of Rock.
418+>>> rock.members.all()
419+[<Person: Jim>, <Person: Jane>]
420+
421+# Now we can add a bunch more Membership objects to test with.
422+>>> m3 = Membership.objects.create(person = bob, group = roll)
423+>>> m4 = Membership.objects.create(person = jim, group = roll)
424+>>> m5 = Membership.objects.create(person = jane, group = roll)
425+
426+# We can get Jim's Group membership as with any ForeignKey.
427+>>> jim.group_set.all()
428+[<Group: Rock>, <Group: Roll>]
429+
430+# Querying the intermediary model works like normal. 
431+# In this case we get Jane's membership to Rock.
432+>>> m = Membership.objects.get(person = jane, group = rock)
433+>>> m
434+<Membership: Jane is a member of Rock>
435+
436+# Now we set some date_joined dates for further testing.
437+>>> m2.invite_reason = "She was just awesome."
438+>>> m2.date_joined = datetime(2006, 1, 1)
439+>>> m2.save()
440+
441+>>> m5.date_joined = datetime(2004, 1, 1)
442+>>> m5.save()
443+
444+>>> m3.date_joined = datetime(2004, 1, 1)
445+>>> m3.save()
446+
447+# It's not only get that works.  Filter works like normal as well.
448+>>> Membership.objects.filter(person = jim)
449+[<Membership: Jim is a member of Rock>, <Membership: Jim is a member of Roll>]
450+
451+
452+### Forward Descriptors Tests ###
453+
454+# Due to complications with adding via an intermediary model, the add method is
455+# not provided.
456+>>> rock.members.add(bob)
457+Traceback (most recent call last):
458+...
459+AttributeError: 'ManyRelatedManager' object has no attribute 'add'
460+
461+# Remove has similar complications, and is not provided either.
462+>>> rock.members.remove(jim)
463+Traceback (most recent call last):
464+...
465+AttributeError: 'ManyRelatedManager' object has no attribute 'remove'
466+
467+# Here we back up the list of all members of Rock.
468+>>> backup = list(rock.members.all())
469+
470+# ...and we verify that it has worked.
471+>>> backup
472+[<Person: Jim>, <Person: Jane>]
473+
474+# The clear function should still work.
475+>>> rock.members.clear()
476+
477+# Now there will be no members of Rock.
478+>>> rock.members.all()
479+[]
480+
481+# Assignment should not work with models specifying a through model for many of
482+# the same reasons as adding.
483+>>> rock.members = backup
484+Traceback (most recent call last):
485+...
486+AttributeError: Cannot set values on a ManyToManyField which specifies a through model.  Use Membership's Manager instead.
487+
488+# Let's re-save those instances that we've cleared.
489+>>> m1.save()
490+>>> m2.save()
491+
492+# Verifying that those instances were re-saved successfully.
493+>>> rock.members.all()
494+[<Person: Jim>, <Person: Jane>]
495+
496+
497+### Reverse Descriptors Tests ###
498+
499+# Due to complications with adding via an intermediary model, the add method is
500+# not provided.
501+>>> bob.group_set.add(rock)
502+Traceback (most recent call last):
503+...
504+AttributeError: 'ManyRelatedManager' object has no attribute 'add'
505+
506+# Remove has similar complications, and is not provided either.
507+>>> jim.group_set.remove(rock)
508+Traceback (most recent call last):
509+...
510+AttributeError: 'ManyRelatedManager' object has no attribute 'remove'
511+
512+# Here we back up the list of all of Jim's groups.
513+>>> backup = list(jim.group_set.all())
514+>>> backup
515+[<Group: Rock>, <Group: Roll>]
516+
517+# The clear function should still work.
518+>>> jim.group_set.clear()
519+
520+# Now Jim will be in no groups.
521+>>> jim.group_set.all()
522+[]
523+
524+# Assignment should not work with models specifying a through model for many of
525+# the same reasons as adding.
526+>>> jim.group_set = backup
527+Traceback (most recent call last):
528+...
529+AttributeError: Cannot set values on a ManyToManyField which specifies a through model.  Use Membership's Manager instead.
530+
531+# Let's re-save those instances that we've cleared.
532+>>> m1.save()
533+>>> m4.save()
534+
535+# Verifying that those instances were re-saved successfully.
536+>>> jim.group_set.all()
537+[<Group: Rock>, <Group: Roll>]
538+
539+### Custom Tests ###
540+
541+# Let's see if we can query through our second relationship.
542+>>> rock.custom_members.all()
543+[]
544+
545+# We can query in the opposite direction as well.
546+>>> bob.custom.all()
547+[]
548+
549+# Let's create some membership objects in this custom relationship.
550+>>> cm1 = CustomMembership.objects.create(person = bob, group = rock)
551+>>> cm2 = CustomMembership.objects.create(person = jim, group = rock)
552+
553+# If we get the number of people in Rock, it should be both Bob and Jim.
554+>>> rock.custom_members.all()
555+[<Person: Bob>, <Person: Jim>]
556+
557+# Bob should only be in one custom group.
558+>>> bob.custom.all()
559+[<Group: Rock>]
560+
561+# Let's make sure our new descriptors don't conflict with the FK related_name.
562+>>> bob.custom_person_related_name.all()
563+[<CustomMembership: Bob is a member of Rock>]
564+
565+### QUERY TESTS ###
566+
567+# We can query for the related model by using its attribute name (members, in
568+# this case).
569+>>> Group.objects.filter(members__name='Bob')
570+[<Group: Roll>]
571+
572+# To query through the intermediary model, we specify its model name.
573+# In this case, membership.
574+>>> Group.objects.filter(membership__invite_reason = "She was just awesome.")
575+[<Group: Rock>]
576+
577+# If we want to query in the reverse direction by the related model, use its
578+# model name (group, in this case).
579+>>> Person.objects.filter(group__name="Rock")
580+[<Person: Jim>, <Person: Jane>]
581+
582+# If the m2m field has specified a related_name, using that will work.
583+>>> Person.objects.filter(custom__name="Rock")
584+[<Person: Bob>, <Person: Jim>]
585+
586+# To query through the intermediary model in the reverse direction, we again
587+# specify its model name (membership, in this case).
588+>>> Person.objects.filter(membership__invite_reason = "She was just awesome.")
589+[<Person: Jane>]
590+
591+# Let's see all of the groups that Jane joined after 1 Jan 2005:
592+>>> Group.objects.filter(membership__date_joined__gt = datetime(2005, 1, 1),
593+... membership__person = jane)
594+[<Group: Rock>]
595+
596+# Queries also work in the reverse direction: Now let's see all of the people
597+# that have joined Rock since 1 Jan 2005:
598+>>> Person.objects.filter(membership__date_joined__gt = datetime(2005, 1, 1),
599+... membership__group = rock)
600+[<Person: Jim>, <Person: Jane>]
601+
602+# Conceivably, queries through membership could return correct, but non-unique
603+# querysets.  To demonstrate this, we query for all people who have joined a
604+# group after 2004:
605+>>> Person.objects.filter(membership__date_joined__gt = datetime(2004, 1, 1))
606+[<Person: Jim>, <Person: Jim>, <Person: Jane>]
607+
608+# Jim showed up twice, because he joined two groups ('Rock', and 'Roll'):
609+>>> [(m.person.name, m.group.name) for m in
610+... Membership.objects.filter(date_joined__gt = datetime(2004, 1, 1))]
611+[(u'Jim', u'Rock'), (u'Jane', u'Rock'), (u'Jim', u'Roll')]
612+
613+# QuerySet's distinct() method can correct this problem.
614+>>> Person.objects.filter(membership__date_joined__gt = datetime(2004, 1, 1)).distinct()
615+[<Person: Jim>, <Person: Jane>]
616+"""}
617\ No newline at end of file