Index: django/db/models/fields/related.py
===================================================================
--- django/db/models/fields/related.py	(revision 7003)
+++ django/db/models/fields/related.py	(working copy)
@@ -54,6 +54,21 @@
     except klass.DoesNotExist:
         raise validators.ValidationError, _("Please enter a valid %s.") % f.verbose_name
 
+def get_reverse_rel_field(from_model, to_model, related_name): 
+    "Gets the related field which points from one model to another." 
+    for field in from_model._meta.fields: 
+        if field.__class__ == ForeignKey: 
+            if field.rel.to == to_model: 
+                return field 
+    return None 
+
+def get_model_for_db_table(db_table): 
+    "Gets a model class from a db_table string." 
+    for model in get_models(): 
+        if model._meta.db_table == db_table: 
+            return model 
+    return None 
+
 #HACK
 class RelatedField(object):
     def contribute_to_class(self, cls, name):
@@ -267,7 +282,8 @@
     and adds behavior for many-to-many related objects."""
     class ManyRelatedManager(superclass):
         def __init__(self, model=None, core_filters=None, instance=None, symmetrical=None,
-                join_table=None, source_col_name=None, target_col_name=None):
+                join_table=None, source_col_name=None, target_col_name=None, 
+                through=None):
             super(ManyRelatedManager, self).__init__()
             self.core_filters = core_filters
             self.model = model
@@ -276,6 +292,7 @@
             self.join_table = join_table
             self.source_col_name = source_col_name
             self.target_col_name = target_col_name
+            self.through = through
             self._pk_val = self.instance._get_pk_val()
             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)
@@ -284,6 +301,8 @@
             return superclass.get_query_set(self).filter(**(self.core_filters))
 
         def add(self, *objs):
+            if self.through:
+                raise NotImplementedError, "Add not possible for ManyToManyFields which specify a through model.  Try %s.objects.create(...) instead." % self.through
             self._add_items(self.source_col_name, self.target_col_name, *objs)
 
             # If this is a symmetrical m2m relation to self, add the mirror entry in the m2m table
@@ -292,8 +311,10 @@
         add.alters_data = True
 
         def remove(self, *objs):
+            if self.through:
+                raise NotImplementedError, "Remove not possible for ManyToManyFields which specify a through model."
             self._remove_items(self.source_col_name, self.target_col_name, *objs)
-
+            
             # If this is a symmetrical m2m relation to self, remove the mirror entry in the m2m table
             if self.symmetrical:
                 self._remove_items(self.target_col_name, self.source_col_name, *objs)
@@ -405,7 +426,8 @@
             symmetrical=False,
             join_table=qn(self.related.field.m2m_db_table()),
             source_col_name=qn(self.related.field.m2m_reverse_name()),
-            target_col_name=qn(self.related.field.m2m_column_name())
+            target_col_name=qn(self.related.field.m2m_column_name()),
+            through=getattr(self.related.field.rel, 'through', None)
         )
 
         return manager
@@ -414,6 +436,10 @@
         if instance is None:
             raise AttributeError, "Manager must be accessed via instance"
 
+        through = getattr(self.related.field.rel, 'through', None)
+        if through:
+            raise NotImplementedError, "Cannot set values on a ManyToManyFields which specify a through model.  Use %s's Manager instead." % through
+        
         manager = self.__get__(instance)
         manager.clear()
         manager.add(*value)
@@ -446,7 +472,8 @@
             symmetrical=(self.field.rel.symmetrical and instance.__class__ == rel_model),
             join_table=qn(self.field.m2m_db_table()),
             source_col_name=qn(self.field.m2m_column_name()),
-            target_col_name=qn(self.field.m2m_reverse_name())
+            target_col_name=qn(self.field.m2m_reverse_name()),
+            through=getattr(self.field.rel, 'through', None)
         )
 
         return manager
@@ -455,6 +482,10 @@
         if instance is None:
             raise AttributeError, "Manager must be accessed via instance"
 
+        through = getattr(self.field.rel, 'through', None)
+        if through:
+            raise NotImplementedError, "Cannot set values on a ManyToManyFields which specify a through model.  Use %s's Manager instead." % through
+
         manager = self.__get__(instance)
         manager.clear()
         manager.add(*value)
@@ -648,8 +679,11 @@
             filter_interface=kwargs.pop('filter_interface', None),
             limit_choices_to=kwargs.pop('limit_choices_to', None),
             raw_id_admin=kwargs.pop('raw_id_admin', False),
-            symmetrical=kwargs.pop('symmetrical', True))
+            symmetrical=kwargs.pop('symmetrical', True),
+            through=kwargs.pop('through', None))
         self.db_table = kwargs.pop('db_table', None)
+        if kwargs['rel'].through:
+            assert not self.db_table, "Cannot specify a db_table if an intermediary model is used." 
         if kwargs["rel"].raw_id_admin:
             kwargs.setdefault("validator_list", []).append(self.isValidIDList)
         Field.__init__(self, **kwargs)
@@ -672,7 +706,9 @@
 
     def _get_m2m_db_table(self, opts):
         "Function that can be curried to provide the m2m table name for this relation"
-        if self.db_table:
+        if self.rel.through != None: 
+            return get_model(opts.app_label, self.rel.through)._meta.db_table
+        elif self.db_table:
             return self.db_table
         else:
             return '%s_%s' % (opts.db_table, self.name)
@@ -680,7 +716,12 @@
     def _get_m2m_column_name(self, related):
         "Function that can be curried to provide the source column name for the m2m table"
         # If this is an m2m relation to self, avoid the inevitable name clash
-        if related.model == related.parent_model:
+        if self.rel.through != None:
+            through = get_model(related.opts.app_label, self.rel.through) 
+            field = get_reverse_rel_field(through, related.model, self.rel.related_name) 
+            attname, column = field.get_attname_column() 
+            return column 
+        elif related.model == related.parent_model:
             return 'from_' + related.model._meta.object_name.lower() + '_id'
         else:
             return related.model._meta.object_name.lower() + '_id'
@@ -688,7 +729,12 @@
     def _get_m2m_reverse_name(self, related):
         "Function that can be curried to provide the related column name for the m2m table"
         # If this is an m2m relation to self, avoid the inevitable name clash
-        if related.model == related.parent_model:
+        if self.rel.through != None:
+            through = get_model(related.opts.app_label, self.rel.through) 
+            field = get_reverse_rel_field(through, related.parent_model, self.rel.related_name) 
+            attname, column = field.get_attname_column() 
+            return column
+        elif related.model == related.parent_model:
             return 'to_' + related.parent_model._meta.object_name.lower() + '_id'
         else:
             return related.parent_model._meta.object_name.lower() + '_id'
@@ -809,7 +855,8 @@
 
 class ManyToManyRel(object):
     def __init__(self, to, num_in_admin=0, related_name=None,
-        filter_interface=None, limit_choices_to=None, raw_id_admin=False, symmetrical=True):
+        filter_interface=None, limit_choices_to=None, raw_id_admin=False, symmetrical=True,
+        through = None):
         self.to = to
         self.num_in_admin = num_in_admin
         self.related_name = related_name
@@ -821,5 +868,6 @@
         self.raw_id_admin = raw_id_admin
         self.symmetrical = symmetrical
         self.multiple = True
+        self.through = through
 
         assert not (self.raw_id_admin and self.filter_interface), "ManyToManyRels may not use both raw_id_admin and filter_interface"
Index: django/core/management/validation.py
===================================================================
--- django/core/management/validation.py	(revision 7003)
+++ django/core/management/validation.py	(working copy)
@@ -104,6 +104,8 @@
                         if r.get_accessor_name() == rel_query_name:
                             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))
 
+        seen_intermediary_signatures = []
+
         for i, f in enumerate(opts.many_to_many):
             # Check to see if the related m2m field will clash with any
             # existing fields, m2m fields, m2m related objects or related objects
@@ -113,6 +115,28 @@
                 # so skip the next section
                 if isinstance(f.rel.to, (str, unicode)):
                     continue
+            if hasattr(f.rel, 'through') and f.rel.through != None:
+                intermediary_model = None
+                for model in models.get_models():
+                    if model._meta.module_name == f.rel.through.lower():
+                        intermediary_model = model
+                if intermediary_model == None:
+                    e.add(opts, "%s has a manually-defined m2m relationship through a model (%s) which does not exist." % (f.name, f.rel.through))
+                else:
+                    signature = (f.rel.to, cls, intermediary_model)
+                    if signature in seen_intermediary_signatures:
+                        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))
+                    else:
+                        seen_intermediary_signatures.append(signature)
+                    seen_related_fk, seen_this_fk = False, False
+                    for field in intermediary_model._meta.fields:
+                        if field.rel:
+                            if field.rel.to == f.rel.to:
+                                seen_related_fk = True
+                            elif field.rel.to == cls:
+                                seen_this_fk = True
+                    if not seen_related_fk or not seen_this_fk:
+                        e.add(opts, "%s has a manualy-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))
 
             rel_opts = f.rel.to._meta
             rel_name = RelatedObject(f.rel.to, cls, f).get_accessor_name()
Index: django/core/management/sql.py
===================================================================
--- django/core/management/sql.py	(revision 7003)
+++ django/core/management/sql.py	(working copy)
@@ -352,7 +352,7 @@
     qn = connection.ops.quote_name
     inline_references = connection.features.inline_fk_references
     for f in opts.many_to_many:
-        if not isinstance(f.rel, generic.GenericRel):
+        if not isinstance(f.rel, generic.GenericRel) and getattr(f.rel, 'through', None) == None:
             tablespace = f.db_tablespace or opts.db_tablespace
             if tablespace and connection.features.supports_tablespaces and connection.features.autoindexes_primary_keys:
                 tablespace_sql = ' ' + connection.ops.tablespace_sql(tablespace, inline=True)
Index: tests/modeltests/invalid_models/models.py
===================================================================
--- tests/modeltests/invalid_models/models.py	(revision 7003)
+++ tests/modeltests/invalid_models/models.py	(working copy)
@@ -111,7 +111,32 @@
 class MissingRelations(models.Model):
     rel1 = models.ForeignKey("Rel1")
     rel2 = models.ManyToManyField("Rel2")
+    
+class MissingManualM2MModel(models.Model):
+    name = models.CharField(max_length=5)
+    missing_m2m = models.ManyToManyField(Model, through="MissingM2MModel")
+    
+class Person(models.Model):
+    name = models.CharField(max_length=5)
 
+class Group(models.Model):
+    name = models.CharField(max_length=5)
+    primary = models.ManyToManyField(Person, through="Membership", related_name="primary")
+    secondary = models.ManyToManyField(Person, through="Membership", related_name="secondary")
+
+class GroupTwo(models.Model):
+    name = models.CharField(max_length=5)
+    primary = models.ManyToManyField(Person, through="Membership")
+    secondary = models.ManyToManyField(Group, through="MembershipMissingFK")
+
+class Membership(models.Model):
+    person = models.ForeignKey(Person)
+    group = models.ForeignKey(Group)
+    not_default_or_null = models.CharField(max_length=5)
+
+class MembershipMissingFK(models.Model):
+    person = models.ForeignKey(Person)
+
 model_errors = """invalid_models.fielderrors: "charfield": CharFields require a "max_length" attribute.
 invalid_models.fielderrors: "decimalfield": DecimalFields require a "decimal_places" attribute.
 invalid_models.fielderrors: "decimalfield": DecimalFields require a "max_digits" attribute.
@@ -197,4 +222,8 @@
 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'.
 invalid_models.missingrelations: 'rel2' has m2m relation with model Rel2, which has not been installed
 invalid_models.missingrelations: 'rel1' has relation with model Rel1, which has not been installed
+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.
+invalid_models.missingmanualm2mmodel: missing_m2m has a manually-defined m2m relationship through a model (MissingM2MModel) which does not exist.
+invalid_models.grouptwo: primary has a manualy-defined m2m relationship through a model (Membership) which does not have foreign keys to Person and GroupTwo
+invalid_models.grouptwo: secondary has a manualy-defined m2m relationship through a model (MembershipMissingFK) which does not have foreign keys to Group and GroupTwo
 """
Index: tests/modeltests/m2m_manual/__init__.py
===================================================================
Index: tests/modeltests/m2m_manual/models.py
===================================================================
--- tests/modeltests/m2m_manual/models.py	(revision 0)
+++ tests/modeltests/m2m_manual/models.py	(revision 0)
@@ -0,0 +1,227 @@
+from django.db import models
+from datetime import datetime
+
+# M2M described on one of the models
+class Person(models.Model):
+    name = models.CharField(max_length=128)
+
+    def __unicode__(self):
+        return self.name
+
+class Group(models.Model):
+    name = models.CharField(max_length=128)
+    members = models.ManyToManyField(Person, through='Membership')
+    custom_members = models.ManyToManyField(Person, through='CustomMembership', related_name="custom")
+    nodefaultsnonulls = models.ManyToManyField(Person, through='TestNoDefaultsOrNulls', related_name="testnodefaultsnonulls")
+    
+    def __unicode__(self):
+        return self.name
+
+class Membership(models.Model):
+    person = models.ForeignKey(Person)
+    group = models.ForeignKey(Group)
+    date_joined = models.DateTimeField(default=datetime.now)
+    invite_reason = models.CharField(max_length=64, null=True)
+    
+    def __unicode__(self):
+        return "%s is a member of %s" % (self.person.name, self.group.name)
+
+class CustomMembership(models.Model):
+    person = models.ForeignKey(Person, db_column="custom_person_column", related_name="custom_person_related_name")
+    group = models.ForeignKey(Group)
+    weird_fk = models.ForeignKey(Membership, null=True)
+    date_joined = models.DateTimeField(default=datetime.now)
+    
+    def __unicode__(self):
+        return "%s is a member of %s" % (self.person.name, self.group.name)
+    
+    class Meta:
+        db_table = "test_table"
+
+class TestNoDefaultsOrNulls(models.Model):
+    person = models.ForeignKey(Person)
+    group = models.ForeignKey(Group)
+    nodefaultnonull = models.CharField(max_length=5)
+
+__test__ = {'API_TESTS':"""
+>>> from datetime import datetime
+
+### Creation and Saving Tests ###
+>>> bob = Person.objects.create(name = 'Bob')
+>>> jim = Person.objects.create(name = 'Jim')
+>>> jane = Person.objects.create(name = 'Jane')
+>>> rock = Group.objects.create(name = 'Rock')
+>>> roll = Group.objects.create(name = 'Roll')
+
+>>> rock.members.all()
+[]
+
+>>> m1 = Membership.objects.create(person = jim, group = rock)
+>>> m2 = Membership.objects.create(person = jane, group = rock)
+
+>>> rock.members.all()
+[<Person: Jim>, <Person: Jane>]
+
+>>> m3 = Membership.objects.create(person = bob, group = roll)
+>>> m4 = Membership.objects.create(person = jim, group = roll)
+>>> m5 = Membership.objects.create(person = jane, group = roll)
+
+>>> jim.group_set.all()
+[<Group: Rock>, <Group: Roll>]
+
+# Check to make sure that querying via intermediary model works as normal
+>>> m = Membership.objects.get(person = jane, group = rock)
+>>> m
+<Membership: Jane is a member of Rock>
+
+# Setting some date_joined dates
+>>> m2.invite_reason = "She was just awesome."
+>>> m2.date_joined = datetime(2006, 1, 1)
+>>> m2.save()
+
+>>> m5.date_joined = datetime(2004, 1, 1)
+>>> m5.save()
+
+>>> m3.date_joined = datetime(2004, 1, 1)
+>>> m3.save()
+
+>>> Membership.objects.filter(person = jim)
+[<Membership: Jim is a member of Rock>, <Membership: Jim is a member of Roll>]
+
+
+### Forward Descriptors Tests ###
+# Ensure that using the add or remove function errors, due to using a through model.
+>>> rock.members.add(bob)
+Traceback (most recent call last):
+...
+NotImplementedError: Add not possible for ManyToManyFields which specify a through model.  Try Membership.objects.create(...) instead.
+
+>>> rock.members.remove(jim)
+Traceback (most recent call last):
+...
+NotImplementedError: Remove not possible for ManyToManyFields which specify a through model.
+
+>>> backup = list(rock.members.all())
+>>> backup
+[<Person: Jim>, <Person: Jane>]
+
+# The clear function should still work.
+>>> rock.members.clear()
+>>> rock.members.all()
+[]
+
+# Assignment should not work with models specifying a through model.
+>>> rock.members = backup
+Traceback (most recent call last):
+...
+NotImplementedError: Cannot set values on a ManyToManyFields which specify a through model.  Use Membership's Manager instead.
+
+# Let's re-add those instances that we've cleared.
+>>> m1.save()
+>>> m2.save()
+
+>>> rock.members.all()
+[<Person: Jim>, <Person: Jane>]
+
+
+### Reverse Descriptors Tests ###
+# Ensure that using the add or remove function errors, due to using a through model.
+>>> bob.group_set.add(rock)
+Traceback (most recent call last):
+...
+NotImplementedError: Add not possible for ManyToManyFields which specify a through model.  Try Membership.objects.create(...) instead.
+
+>>> jim.group_set.remove(rock)
+Traceback (most recent call last):
+...
+NotImplementedError: Remove not possible for ManyToManyFields which specify a through model.
+
+>>> backup = list(jim.group_set.all())
+>>> backup
+[<Group: Rock>, <Group: Roll>]
+
+# The clear function should still work.
+>>> jim.group_set.clear()
+>>> jim.group_set.all()
+[]
+
+# Assignment should not work with models specifying a through model.
+>>> jim.group_set = backup
+Traceback (most recent call last):
+...
+NotImplementedError: Cannot set values on a ManyToManyFields which specify a through model.  Use Membership's Manager instead.
+
+# Let's re-add those instances that we've cleared.
+>>> m1.save()
+>>> m4.save()
+
+>>> jim.group_set.all()
+[<Group: Rock>, <Group: Roll>]
+
+### Custom Tests ###
+
+>>> rock.custom_members.all()
+[]
+>>> bob.custom.all()
+[]
+>>> cm1 = CustomMembership.objects.create(person = bob, group = rock)
+>>> cm2 = CustomMembership.objects.create(person = jim, group = rock)
+
+>>> rock.custom_members.all()
+[<Person: Bob>, <Person: Jim>]
+>>> bob.custom.all()
+[<Group: Rock>]
+
+# Let's make sure our new descriptors don't conflict with the FK related_name.
+>>> bob.custom_person_related_name.all()
+[<CustomMembership: Bob is a member of Rock>]
+
+###QUERY TESTS###
+# Queries involving the related model (Person, in the case of Group) use its 
+# attname
+>>> Group.objects.filter(members__name='Bob')
+[<Group: Roll>]
+
+# Queries involving the relationship model (Membership, in the case of Group) 
+# use its model name
+>>> Group.objects.filter(membership__invite_reason = "She was just awesome.")
+[<Group: Rock>]
+
+# Queries involving the reverse related model (Group, in the case of Person) 
+# use its model name
+>>> Person.objects.filter(group__name="Rock")
+[<Person: Jim>, <Person: Jane>]
+
+# If the m2m field has specified a related_name, using that will work.
+>>> Person.objects.filter(custom__name="Rock")
+[<Person: Bob>, <Person: Jim>]
+
+# Queries involving the relationship model (Membership, in the case of Group) 
+# use its model name
+>>> Person.objects.filter(membership__invite_reason = "She was just awesome.")
+[<Person: Jane>]
+
+# Let's see all of the groups that Jane joined after 1 Jan 2005:
+>>> Group.objects.filter(membership__date_joined__gt = datetime(2005, 1, 1), 
+... membership__person = jane)
+[<Group: Rock>]
+
+# Now let's see all of the people that have joined Rock since 1 Jan 2005:
+>>> Person.objects.filter(membership__date_joined__gt = datetime(2005, 1, 1), 
+... membership__group = rock)
+[<Person: Jim>, <Person: Jane>]
+
+# Conceivably, queries through membership could return non-unique querysets.
+# To demonstrate this, query for all people who have joined a group after 2004:
+>>> Person.objects.filter(membership__date_joined__gt = datetime(2004, 1, 1))
+[<Person: Jim>, <Person: Jim>, <Person: Jane>]
+
+# Jim showed up twice, because he joined two groups ('Rock', and 'Roll'):
+>>> [(m.person.name, m.group.name) for m in 
+... Membership.objects.filter(date_joined__gt = datetime(2004, 1, 1))]
+[(u'Jim', u'Rock'), (u'Jane', u'Rock'), (u'Jim', u'Roll')]
+
+# QuerySet's distinct() method can correct this problem.
+>>> Person.objects.filter(membership__date_joined__gt = datetime(2004, 1, 1)).distinct()
+[<Person: Jim>, <Person: Jane>]
+"""}
\ No newline at end of file
Index: AUTHORS
===================================================================
--- AUTHORS	(revision 7003)
+++ AUTHORS	(working copy)
@@ -129,6 +129,7 @@
     Afonso Fernández Nogueira <fonzzo.django@gmail.com>
     Matthew Flanagan <http://wadofstuff.blogspot.com>
     Eric Floehr <eric@intellovations.com>
+    Eric Florenzano <floguy@gmail.com>
     Vincent Foley <vfoleybourgon@yahoo.ca>
     Rudolph Froger <rfroger@estrate.nl>
     Jorge Gajon <gajon@gajon.org>
