diff --git a/AUTHORS b/AUTHORS index cf03e891ec..a9f55836e6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -154,6 +154,7 @@ answer newbie questions, and generally made Django that much better: Maciej Fijalkowski Matthew Flanagan Eric Floehr + Eric Florenzano Vincent Foley Rudolph Froger Jorge Gajon diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 1df399bd5a..c74a0e361e 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -161,7 +161,10 @@ class BaseModelAdmin(object): kwargs['empty_label'] = db_field.blank and _('None') or None else: if isinstance(db_field, models.ManyToManyField): - if db_field.name in self.raw_id_fields: + # If it uses an intermediary model, don't show field in admin. + if db_field.rel.through is not None: + return None + elif db_field.name in self.raw_id_fields: kwargs['widget'] = widgets.ManyToManyRawIdWidget(db_field.rel) kwargs['help_text'] = '' elif db_field.name in (list(self.filter_vertical) + list(self.filter_horizontal)): diff --git a/django/contrib/contenttypes/generic.py b/django/contrib/contenttypes/generic.py index e91be70d1b..2ff58580d0 100644 --- a/django/contrib/contenttypes/generic.py +++ b/django/contrib/contenttypes/generic.py @@ -104,6 +104,9 @@ class GenericRelation(RelatedField, Field): limit_choices_to=kwargs.pop('limit_choices_to', None), symmetrical=kwargs.pop('symmetrical', True)) + # By its very nature, a GenericRelation doesn't create a table. + self.creates_table = False + # Override content-type/object-id field names on the related class self.object_id_field_name = kwargs.pop("object_id_field", "object_id") self.content_type_field_name = kwargs.pop("content_type_field", "content_type") diff --git a/django/core/management/sql.py b/django/core/management/sql.py index 86e9ed325f..bd6a804b32 100644 --- a/django/core/management/sql.py +++ b/django/core/management/sql.py @@ -353,7 +353,7 @@ def many_to_many_sql_for_model(model, style): qn = connection.ops.quote_name inline_references = connection.features.inline_fk_references for f in opts.local_many_to_many: - if not isinstance(f.rel, generic.GenericRel): + if f.creates_table: tablespace = f.db_tablespace or opts.db_tablespace if tablespace and connection.features.supports_tablespaces: tablespace_sql = ' ' + connection.ops.tablespace_sql(tablespace, inline=True) diff --git a/django/core/management/validation.py b/django/core/management/validation.py index e17409ae5d..e9d7b53027 100644 --- a/django/core/management/validation.py +++ b/django/core/management/validation.py @@ -102,6 +102,7 @@ def get_validation_errors(outfile, app=None): 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.local_many_to_many): # Check to see if the related m2m field will clash with any # existing fields, m2m fields, m2m related objects or related @@ -112,7 +113,49 @@ def get_validation_errors(outfile, app=None): # so skip the next section if isinstance(f.rel.to, (str, unicode)): continue - + if getattr(f.rel, 'through', None) is not None: + if hasattr(f.rel, 'through_model'): + from_model, to_model = cls, f.rel.to + if from_model == to_model and f.rel.symmetrical: + e.add(opts, "Many-to-many fields with intermediate tables cannot be symmetrical.") + seen_from, seen_to, seen_self = False, False, 0 + for inter_field in f.rel.through_model._meta.fields: + rel_to = getattr(inter_field.rel, 'to', None) + if from_model == to_model: # relation to self + if rel_to == from_model: + seen_self += 1 + if seen_self > 2: + e.add(opts, "Intermediary model %s has more than two foreign keys to %s, which is ambiguous and is not permitted." % (f.rel.through_model._meta.object_name, from_model._meta.object_name)) + else: + if rel_to == from_model: + if seen_from: + e.add(opts, "Intermediary model %s has more than one foreign key to %s, which is ambiguous and is not permitted." % (f.rel.through_model._meta.object_name, rel_from._meta.object_name)) + else: + seen_from = True + elif rel_to == to_model: + if seen_to: + e.add(opts, "Intermediary model %s has more than one foreign key to %s, which is ambiguous and is not permitted." % (f.rel.through_model._meta.object_name, rel_to._meta.object_name)) + else: + seen_to = True + if f.rel.through_model not in models.get_models(): + e.add(opts, "'%s' specifies an m2m relation through model %s, which has not been installed." % (f.name, f.rel.through)) + signature = (f.rel.to, cls, f.rel.through_model) + if signature in seen_intermediary_signatures: + e.add(opts, "The model %s has two manually-defined m2m relations through the model %s, which is not permitted. Please consider using an extra field on your intermediary model instead." % (cls._meta.object_name, f.rel.through_model._meta.object_name)) + else: + seen_intermediary_signatures.append(signature) + seen_related_fk, seen_this_fk = False, False + for field in f.rel.through_model._meta.fields: + if field.rel: + if not seen_related_fk and 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 manually-defined m2m relation through 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)) + else: + e.add(opts, "'%s' specifies an m2m relation through model %s, which has not been installed" % (f.name, f.rel.through)) + rel_opts = f.rel.to._meta rel_name = RelatedObject(f.rel.to, cls, f).get_accessor_name() rel_query_name = f.related_query_name() diff --git a/django/db/models/fields/related.py b/django/db/models/fields/related.py index cb86f00c86..7f6b1c67c8 100644 --- a/django/db/models/fields/related.py +++ b/django/db/models/fields/related.py @@ -23,7 +23,7 @@ RECURSIVE_RELATIONSHIP_CONSTANT = 'self' pending_lookups = {} -def add_lazy_relation(cls, field, relation): +def add_lazy_relation(cls, field, relation, operation): """ Adds a lookup on ``cls`` when a related field is defined using a string, i.e.:: @@ -45,6 +45,8 @@ def add_lazy_relation(cls, field, relation): If the other model hasn't yet been loaded -- almost a given if you're using lazy relationships -- then the relation won't be set up until the class_prepared signal fires at the end of model initialization. + + operation is the work that must be performed once the relation can be resolved. """ # Check for recursive relations if relation == RECURSIVE_RELATIONSHIP_CONSTANT: @@ -66,11 +68,10 @@ def add_lazy_relation(cls, field, relation): # is prepared. model = get_model(app_label, model_name, False) if model: - field.rel.to = model - field.do_related_class(model, cls) + operation(field, model, cls) else: key = (app_label, model_name) - value = (cls, field) + value = (cls, field, operation) pending_lookups.setdefault(key, []).append(value) def do_pending_lookups(sender): @@ -78,9 +79,8 @@ def do_pending_lookups(sender): Handle any pending relations to the sending model. Sent from class_prepared. """ key = (sender._meta.app_label, sender.__name__) - for cls, field in pending_lookups.pop(key, []): - field.rel.to = sender - field.do_related_class(sender, cls) + for cls, field, operation in pending_lookups.pop(key, []): + operation(field, sender, cls) dispatcher.connect(do_pending_lookups, signal=signals.class_prepared) @@ -108,7 +108,10 @@ class RelatedField(object): other = self.rel.to if isinstance(other, basestring): - add_lazy_relation(cls, self, other) + def resolve_related_class(field, model, cls): + field.rel.to = model + field.do_related_class(model, cls) + add_lazy_relation(cls, self, other, resolve_related_class) else: self.do_related_class(other, cls) @@ -340,7 +343,7 @@ class ForeignRelatedObjectsDescriptor(object): manager.clear() manager.add(*value) -def create_many_related_manager(superclass): +def create_many_related_manager(superclass, through=False): """Creates a manager that subclasses 'superclass' (which is a Manager) and adds behavior for many-to-many related objects.""" class ManyRelatedManager(superclass): @@ -354,6 +357,7 @@ def create_many_related_manager(superclass): 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." % instance.__class__.__name__) @@ -361,21 +365,24 @@ def create_many_related_manager(superclass): def get_query_set(self): return superclass.get_query_set(self).filter(**(self.core_filters)) - def add(self, *objs): - self._add_items(self.source_col_name, self.target_col_name, *objs) + # If the ManyToMany relation has an intermediary model, + # the add and remove methods do not exist. + if through is None: + def add(self, *objs): + 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 - if self.symmetrical: - self._add_items(self.target_col_name, self.source_col_name, *objs) - add.alters_data = True + # If this is a symmetrical m2m relation to self, add the mirror entry in the m2m table + if self.symmetrical: + self._add_items(self.target_col_name, self.source_col_name, *objs) + add.alters_data = True - def remove(self, *objs): - self._remove_items(self.source_col_name, self.target_col_name, *objs) + def remove(self, *objs): + 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) - remove.alters_data = True + # 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) + remove.alters_data = True def clear(self): self._clear_items(self.source_col_name) @@ -386,6 +393,10 @@ def create_many_related_manager(superclass): clear.alters_data = True def create(self, **kwargs): + # This check needs to be done here, since we can't later remove this + # from the method lookup table, as we do with add and remove. + if through is not None: + raise AttributeError, "Cannot use create() on a ManyToManyField which specifies an intermediary model. Use %s's Manager instead." % through new_obj = self.model(**kwargs) new_obj.save() self.add(new_obj) @@ -473,7 +484,7 @@ class ManyRelatedObjectsDescriptor(object): # model's default manager. rel_model = self.related.model superclass = rel_model._default_manager.__class__ - RelatedManager = create_many_related_manager(superclass) + RelatedManager = create_many_related_manager(superclass, self.related.field.rel.through) qn = connection.ops.quote_name manager = RelatedManager( @@ -492,6 +503,10 @@ class ManyRelatedObjectsDescriptor(object): if instance is None: raise AttributeError, "Manager must be accessed via instance" + through = getattr(self.related.field.rel, 'through', None) + if through is not None: + raise AttributeError, "Cannot set values on a ManyToManyField which specifies an intermediary model. Use %s's Manager instead." % through + manager = self.__get__(instance) manager.clear() manager.add(*value) @@ -514,7 +529,7 @@ class ReverseManyRelatedObjectsDescriptor(object): # model's default manager. rel_model=self.field.rel.to superclass = rel_model._default_manager.__class__ - RelatedManager = create_many_related_manager(superclass) + RelatedManager = create_many_related_manager(superclass, self.field.rel.through) qn = connection.ops.quote_name manager = RelatedManager( @@ -533,6 +548,10 @@ class ReverseManyRelatedObjectsDescriptor(object): if instance is None: raise AttributeError, "Manager must be accessed via instance" + through = getattr(self.field.rel, 'through', None) + if through is not None: + raise AttributeError, "Cannot set values on a ManyToManyField which specifies an intermediary model. Use %s's Manager instead." % through + manager = self.__get__(instance) manager.clear() manager.add(*value) @@ -584,7 +603,7 @@ class OneToOneRel(ManyToOneRel): class ManyToManyRel(object): def __init__(self, to, num_in_admin=0, related_name=None, - limit_choices_to=None, symmetrical=True): + limit_choices_to=None, symmetrical=True, through=None): self.to = to self.num_in_admin = num_in_admin self.related_name = related_name @@ -594,6 +613,7 @@ class ManyToManyRel(object): self.edit_inline = False self.symmetrical = symmetrical self.multiple = True + self.through = through class ForeignKey(RelatedField, Field): empty_strings_allowed = False @@ -723,8 +743,16 @@ class ManyToManyField(RelatedField, Field): num_in_admin=kwargs.pop('num_in_admin', 0), related_name=kwargs.pop('related_name', None), limit_choices_to=kwargs.pop('limit_choices_to', None), - 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 is not None: + self.creates_table = False + assert self.db_table is None, "Cannot specify a db_table if an intermediary model is used." + else: + self.creates_table = True + Field.__init__(self, **kwargs) msg = ugettext_lazy('Hold down "Control", or "Command" on a Mac, to select more than one.') @@ -739,26 +767,62 @@ class ManyToManyField(RelatedField, Field): 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 is not None: + return self.rel.through_model._meta.db_table + elif self.db_table: return self.db_table else: return '%s_%s' % (opts.db_table, self.name) 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: - return 'from_' + related.model._meta.object_name.lower() + '_id' - else: - return related.model._meta.object_name.lower() + '_id' + try: + return self._m2m_column_name_cache + except: + if self.rel.through is not None: + for f in self.rel.through_model._meta.fields: + if hasattr(f,'rel') and f.rel and f.rel.to == related.model: + self._m2m_column_name_cache = f.column + break + # If this is an m2m relation to self, avoid the inevitable name clash + elif related.model == related.parent_model: + self._m2m_column_name_cache = 'from_' + related.model._meta.object_name.lower() + '_id' + else: + self._m2m_column_name_cache = related.model._meta.object_name.lower() + '_id' + + # Return the newly cached value + return self._m2m_column_name_cache 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: - return 'to_' + related.parent_model._meta.object_name.lower() + '_id' - else: - return related.parent_model._meta.object_name.lower() + '_id' + try: + return self._m2m_reverse_name_cache + except: + if self.rel.through is not None: + found = False + for f in self.rel.through_model._meta.fields: + if hasattr(f,'rel') and f.rel and f.rel.to == related.parent_model: + if related.model == related.parent_model: + # If this is an m2m-intermediate to self, + # the first foreign key you find will be + # the source column. Keep searching for + # the second foreign key. + if found: + self._m2m_reverse_name_cache = f.column + break + else: + found = True + else: + self._m2m_reverse_name_cache = f.column + break + # If this is an m2m relation to self, avoid the inevitable name clash + elif related.model == related.parent_model: + self._m2m_reverse_name_cache = 'to_' + related.parent_model._meta.object_name.lower() + '_id' + else: + self._m2m_reverse_name_cache = related.parent_model._meta.object_name.lower() + '_id' + + # Return the newly cached value + return self._m2m_reverse_name_cache def isValidIDList(self, field_data, all_data): "Validates that the value is a valid list of foreign keys" @@ -792,13 +856,23 @@ class ManyToManyField(RelatedField, Field): return new_data def contribute_to_class(self, cls, name): - super(ManyToManyField, self).contribute_to_class(cls, name) + super(ManyToManyField, self).contribute_to_class(cls, name) # Add the descriptor for the m2m relation setattr(cls, self.name, ReverseManyRelatedObjectsDescriptor(self)) # Set up the accessor for the m2m table name for the relation self.m2m_db_table = curry(self._get_m2m_db_table, cls._meta) - + + # Populate some necessary rel arguments so that cross-app relations + # work correctly. + if isinstance(self.rel.through, basestring): + def resolve_through_model(field, model, cls): + field.rel.through_model = model + add_lazy_relation(cls, self, self.rel.through, resolve_through_model) + elif self.rel.through: + self.rel.through_model = self.rel.through + self.rel.through = self.rel.through._meta.object_name + if isinstance(self.rel.to, basestring): target = self.rel.to else: diff --git a/docs/admin.txt b/docs/admin.txt index 76f61a1d42..8c9ef71898 100644 --- a/docs/admin.txt +++ b/docs/admin.txt @@ -617,6 +617,61 @@ automatically:: FriendshipInline, ] +Working with Many-to-Many Intermediary Models +---------------------------------------------- + +By default, admin widgets for many-to-many relations will be displayed inline +on whichever model contains the actual reference to the `ManyToManyField`. +However, when you specify an intermediary model using the ``through`` +argument to a ``ManyToManyField``, the admin will not display a widget by +default. This is because each instance of that intermediary model requires +more information than could be displayed in a single widget, and the layout +required for multiple widgets will vary depending on the intermediate model. + +However, we still want to be able to edit that information inline. Fortunately, +this is easy to do with inline admin models. Suppose we have the following +models:: + + class Person(models.Model): + name = models.CharField(max_length=128) + + class Group(models.Model): + name = models.CharField(max_length=128) + members = models.ManyToManyField(Person, through='Membership') + + class Membership(models.Model): + person = models.ForeignKey(Person) + group = models.ForeignKey(Group) + date_joined = models.DateField() + invite_reason = models.CharField(max_length=64) + +The first step in displaying this intermediate model in the admin is to +define an inline model for the Membership table:: + + class MembershipInline(admin.TabularInline): + model = Membership + extra = 1 + +This simple example uses the defaults inline form for the Membership model, +and shows 1 extra line. This could be customized using any of the options +available to inline models. + +Now create admin views for the ``Person`` and ``Group`` models:: + + class PersonAdmin(admin.ModelAdmin): + inlines = (MembershipInline,) + + class GroupAdmin(admin.ModelAdmin): + inlines = (MembershipInline,) + +Finally, register your ``Person`` and ``Group`` models with the admin site:: + + admin.site.register(Person, PersonAdmin) + admin.site.register(Group, GroupAdmin) + +Now your admin site is set up to edit ``Membership`` objects inline from either +the ``Person`` or the ``Group`` detail pages. + ``AdminSite`` objects ===================== diff --git a/docs/model-api.txt b/docs/model-api.txt index e20319ae22..616cd1d242 100644 --- a/docs/model-api.txt +++ b/docs/model-api.txt @@ -655,7 +655,7 @@ Note that this value is *not* HTML-escaped when it's displayed in the admin interface. This lets you include HTML in ``help_text`` if you so desire. For example:: - help_text="Please use the following format: YYYY-MM-DD." + help_text="Please use the following format: YYYY-MM-DD." Alternatively you can use plain text and ``django.utils.html.escape()`` to escape any HTML special characters. @@ -944,6 +944,131 @@ the relationship should work. All are optional: ======================= ============================================================ +Extra fields on many-to-many relationships +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +**New in Django development version** + +When you're only dealing with simple many-to-many relationships such as +mixing and matching pizzas and toppings, a standard ``ManyToManyField`` +is all you need. However, sometimes you may need to associate data with the +relationship between two models. + +For example, consider the case of an application tracking the musical groups +which musicians belong to. There is a many-to-many relationship between a person +and the groups of which they are a member, so you could use a ManyToManyField +to represent this relationship. However, there is a lot of detail about the +membership that you might want to collect, such as the date at which the person +joined the group. + +For these situations, Django allows you to specify the model that will be used +to govern the many-to-many relationship. You can then put extra fields on the +intermediate model. The intermediate model is associated with the +``ManyToManyField`` using the ``through`` argument to point to the model +that will act as an intermediary. For our musician example, the code would look +something like this:: + + 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') + + def __unicode__(self): + return self.name + + class Membership(models.Model): + person = models.ForeignKey(Person) + group = models.ForeignKey(Group) + date_joined = models.DateField() + invite_reason = models.CharField(max_length=64) + +When you set up the intermediary model, you explicitly specify foreign +keys to the models that are involved in the ManyToMany relation. This +explicit declaration defines how the two models are related. + +There are a few restrictions on the intermediate model: + + * Your intermediate model must contain one - and *only* one - foreign key + on the target model (this would be ``Person`` in our example). If you + have more than one foreign key, a validation error will be raised. + + * Your intermediate model must contain one - and *only* one - foreign key + on the source model (this would be ``Group`` in our example). If you + have more than one foreign key, a validation error will be raised. + + * If the many-to-many relation is a relation on itself, the relationship + must be non-symmetric. + +Now that you have set up your ``ManyToManyField`` to use your intermediary +model (Membership, in this case), you're ready to start creating some +many-to-many relationships. You do this by creating instances of the +intermediate model:: + + >>> ringo = Person.objects.create(name="Ringo Starr") + >>> paul = Person.objects.create(name="Paul McCartney") + >>> beatles = Group.objects.create(name="The Beatles") + >>> m1 = Membership(person=ringo, group=beatles, + ... date_joined=date(1962, 8, 16), + ... invite_reason= "Needed a new drummer.") + >>> m1.save() + >>> beatles.members.all() + [] + >>> ringo.group_set.all() + [] + >>> m2 = Membership.objects.create(person=paul, group=beatles, + ... date_joined=date(1960, 8, 1), + ... invite_reason= "Wanted to form a band.") + >>> beatles.members.all() + [, ] + +Unlike normal many-to-many fields, you *can't* use ``add``, ``create``, +or assignment (i.e., ``beatles.members = [...]``) to create relationships:: + + # THIS WILL NOT WORK + >>> beatles.members.add(john) + # NEITHER WILL THIS + >>> beatles.members.create(name="George Harrison") + # AND NEITHER WILL THIS + >>> beatles.members = [john, paul, ringo, george] + +Why? You can't just create a relationship between a Person and a Group - you +need to specify all the detail for the relationship required by the +Membership table. The simple ``add``, ``create`` and assignment calls +don't provide a way to specify this extra detail. As a result, they are +disabled for many-to-many relationships that use an intermediate model. +The only way to create a many-to-many relationship with an intermediate table +is to create instances of the intermediate model. + +The ``remove`` method is disabled for similar reasons. However, the +``clear()`` method can be used to remove all many-to-many relationships +for an instance:: + + # Beatles have broken up + >>> beatles.members.clear() + +Once you have established the many-to-many relationships by creating instances +of your intermediate model, you can issue queries. Just as with normal +many-to-many relationships, you can query using the attributes of the +many-to-many-related model:: + + # Find all the groups with a member whose name starts with 'Paul' + >>> Groups.objects.filter(person__name__startswith='Paul') + [] + +As you are using an intermediate table, you can also query on the attributes +of the intermediate model:: + + # Find all the members of the Beatles that joined after 1 Jan 1961 + >>> Person.objects.filter( + ... group__name='The Beatles', + ... membership__date_joined__gt=date(1961,1,1)) + [>> 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') + +# We start out by making sure that the Group 'rock' has no members. +>>> rock.members.all() +[] + +# To make Jim a member of Group Rock, simply create a Membership object. +>>> m1 = Membership.objects.create(person=jim, group=rock) + +# We can do the same for Jane and Rock. +>>> m2 = Membership.objects.create(person=jane, group=rock) + +# Let's check to make sure that it worked. Jane and Jim should be members of Rock. +>>> rock.members.all() +[, ] + +# Now we can add a bunch more Membership objects to test with. +>>> m3 = Membership.objects.create(person=bob, group=roll) +>>> m4 = Membership.objects.create(person=jim, group=roll) +>>> m5 = Membership.objects.create(person=jane, group=roll) + +# We can get Jim's Group membership as with any ForeignKey. +>>> jim.group_set.all() +[, ] + +# Querying the intermediary model works like normal. +# In this case we get Jane's membership to Rock. +>>> m = Membership.objects.get(person=jane, group=rock) +>>> m + + +# Now we set some date_joined dates for further testing. +>>> 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() + +# It's not only get that works. Filter works like normal as well. +>>> Membership.objects.filter(person=jim) +[, ] + + +### Forward Descriptors Tests ### + +# Due to complications with adding via an intermediary model, +# the add method is not provided. +>>> rock.members.add(bob) +Traceback (most recent call last): +... +AttributeError: 'ManyRelatedManager' object has no attribute 'add' + +# Create is also disabled as it suffers from the same problems as add. +>>> rock.members.create(name='Anne') +Traceback (most recent call last): +... +AttributeError: Cannot use create() on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +# Remove has similar complications, and is not provided either. +>>> rock.members.remove(jim) +Traceback (most recent call last): +... +AttributeError: 'ManyRelatedManager' object has no attribute 'remove' + +# Here we back up the list of all members of Rock. +>>> backup = list(rock.members.all()) + +# ...and we verify that it has worked. +>>> backup +[, ] + +# The clear function should still work. +>>> rock.members.clear() + +# Now there will be no members of Rock. +>>> rock.members.all() +[] + +# Assignment should not work with models specifying a through model for many of +# the same reasons as adding. +>>> rock.members = backup +Traceback (most recent call last): +... +AttributeError: Cannot set values on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +# Let's re-save those instances that we've cleared. +>>> m1.save() +>>> m2.save() + +# Verifying that those instances were re-saved successfully. +>>> rock.members.all() +[, ] + + +### Reverse Descriptors Tests ### + +# Due to complications with adding via an intermediary model, +# the add method is not provided. +>>> bob.group_set.add(rock) +Traceback (most recent call last): +... +AttributeError: 'ManyRelatedManager' object has no attribute 'add' + +# Create is also disabled as it suffers from the same problems as add. +>>> bob.group_set.create(name='Funk') +Traceback (most recent call last): +... +AttributeError: Cannot use create() on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +# Remove has similar complications, and is not provided either. +>>> jim.group_set.remove(rock) +Traceback (most recent call last): +... +AttributeError: 'ManyRelatedManager' object has no attribute 'remove' + +# Here we back up the list of all of Jim's groups. +>>> backup = list(jim.group_set.all()) +>>> backup +[, ] + +# The clear function should still work. +>>> jim.group_set.clear() + +# Now Jim will be in no groups. +>>> jim.group_set.all() +[] + +# Assignment should not work with models specifying a through model for many of +# the same reasons as adding. +>>> jim.group_set = backup +Traceback (most recent call last): +... +AttributeError: Cannot set values on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +# Let's re-save those instances that we've cleared. +>>> m1.save() +>>> m4.save() + +# Verifying that those instances were re-saved successfully. +>>> jim.group_set.all() +[, ] + +### Custom Tests ### + +# Let's see if we can query through our second relationship. +>>> rock.custom_members.all() +[] + +# We can query in the opposite direction as well. +>>> bob.custom.all() +[] + +# Let's create some membership objects in this custom relationship. +>>> cm1 = CustomMembership.objects.create(person=bob, group=rock) +>>> cm2 = CustomMembership.objects.create(person=jim, group=rock) + +# If we get the number of people in Rock, it should be both Bob and Jim. +>>> rock.custom_members.all() +[, ] + +# Bob should only be in one custom group. +>>> bob.custom.all() +[] + +# Let's make sure our new descriptors don't conflict with the FK related_name. +>>> bob.custom_person_related_name.all() +[] + +### SELF-REFERENTIAL TESTS ### + +# Let's first create a person who has no friends. +>>> tony = PersonSelfRefM2M.objects.create(name="Tony") +>>> tony.friends.all() +[] + +# Now let's create another person for Tony to be friends with. +>>> chris = PersonSelfRefM2M.objects.create(name="Chris") +>>> f = Friendship.objects.create(first=tony, second=chris, date_friended=datetime.now()) + +# Tony should now show that Chris is his friend. +>>> tony.friends.all() +[] + +# But we haven't established that Chris is Tony's Friend. +>>> chris.friends.all() +[] + +# So let's do that now. +>>> f2 = Friendship.objects.create(first=chris, second=tony, date_friended=datetime.now()) + +# Having added Chris as a friend, let's make sure that his friend set reflects +# that addition. +>>> chris.friends.all() +[] + +# Chris gets mad and wants to get rid of all of his friends. +>>> chris.friends.clear() + +# Now he should not have any more friends. +>>> chris.friends.all() +[] + +# Since this isn't a symmetrical relation, Tony's friend link still exists. +>>> tony.friends.all() +[] + + + +### QUERY TESTS ### + +# We can query for the related model by using its attribute name (members, in +# this case). +>>> Group.objects.filter(members__name='Bob') +[] + +# To query through the intermediary model, we specify its model name. +# In this case, membership. +>>> Group.objects.filter(membership__invite_reason="She was just awesome.") +[] + +# If we want to query in the reverse direction by the related model, use its +# model name (group, in this case). +>>> Person.objects.filter(group__name="Rock") +[, ] + +# If the m2m field has specified a related_name, using that will work. +>>> Person.objects.filter(custom__name="Rock") +[, ] + +# To query through the intermediary model in the reverse direction, we again +# specify its model name (membership, in this case). +>>> Person.objects.filter(membership__invite_reason="She was just awesome.") +[] + +# 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) +[] + +# Queries also work in the reverse direction: 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) +[, ] + +# Conceivably, queries through membership could return correct, but non-unique +# querysets. To demonstrate this, we query for all people who have joined a +# group after 2004: +>>> Person.objects.filter(membership__date_joined__gt=datetime(2004, 1, 1)) +[, , ] + +# 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'Jane', u'Rock'), (u'Jim', 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() +[, ] +"""} \ No newline at end of file diff --git a/tests/regressiontests/m2m_through_regress/__init__.py b/tests/regressiontests/m2m_through_regress/__init__.py new file mode 100644 index 0000000000..139597f9cb --- /dev/null +++ b/tests/regressiontests/m2m_through_regress/__init__.py @@ -0,0 +1,2 @@ + + diff --git a/tests/regressiontests/m2m_through_regress/models.py b/tests/regressiontests/m2m_through_regress/models.py new file mode 100644 index 0000000000..16d2c4ec1b --- /dev/null +++ b/tests/regressiontests/m2m_through_regress/models.py @@ -0,0 +1,204 @@ +from django.db import models +from datetime import datetime +from django.contrib.auth.models import User + +# Forward declared intermediate model +class Membership(models.Model): + person = models.ForeignKey('Person') + group = models.ForeignKey('Group') + date_joined = models.DateTimeField(default=datetime.now) + + def __unicode__(self): + return "%s is a member of %s" % (self.person.name, self.group.name) + +class UserMembership(models.Model): + user = models.ForeignKey(User) + group = models.ForeignKey('Group') + date_joined = models.DateTimeField(default=datetime.now) + + def __unicode__(self): + return "%s is a user and member of %s" % (self.user.username, self.group.name) + +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) + # Membership object defined as a class + members = models.ManyToManyField(Person, through=Membership) + user_members = models.ManyToManyField(User, through='UserMembership') + + def __unicode__(self): + return self.name + +__test__ = {'API_TESTS':""" +# Create some dummy data +>>> bob = Person.objects.create(name='Bob') +>>> jim = Person.objects.create(name='Jim') + +>>> rock = Group.objects.create(name='Rock') +>>> roll = Group.objects.create(name='Roll') + +>>> frank = User.objects.create_user('frank','frank@example.com','password') +>>> jane = User.objects.create_user('jane','jane@example.com','password') + +# Now test that the forward declared Membership works +>>> Membership.objects.create(person=bob, group=rock) + + +>>> Membership.objects.create(person=bob, group=roll) + + +>>> Membership.objects.create(person=jim, group=rock) + + +>>> bob.group_set.all() +[, ] + +>>> roll.members.all() +[] + +# Error messages use the model name, not repr of the class name +>>> bob.group_set = [] +Traceback (most recent call last): +... +AttributeError: Cannot set values on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +>>> roll.members = [] +Traceback (most recent call last): +... +AttributeError: Cannot set values on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +>>> rock.members.create(name='Anne') +Traceback (most recent call last): +... +AttributeError: Cannot use create() on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +>>> bob.group_set.create(name='Funk') +Traceback (most recent call last): +... +AttributeError: Cannot use create() on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +# Now test that the intermediate with a relationship outside +# the current app (i.e., UserMembership) workds +>>> UserMembership.objects.create(user=frank, group=rock) + + +>>> UserMembership.objects.create(user=frank, group=roll) + + +>>> UserMembership.objects.create(user=jane, group=rock) + + +>>> frank.group_set.all() +[, ] + +>>> roll.user_members.all() +[] + +"""} +from django.db import models +from datetime import datetime +from django.contrib.auth.models import User + +# Forward declared intermediate model +class Membership(models.Model): + person = models.ForeignKey('Person') + group = models.ForeignKey('Group') + date_joined = models.DateTimeField(default=datetime.now) + + def __unicode__(self): + return "%s is a member of %s" % (self.person.name, self.group.name) + +class UserMembership(models.Model): + user = models.ForeignKey(User) + group = models.ForeignKey('Group') + date_joined = models.DateTimeField(default=datetime.now) + + def __unicode__(self): + return "%s is a user and member of %s" % (self.user.username, self.group.name) + +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) + # Membership object defined as a class + members = models.ManyToManyField(Person, through=Membership) + user_members = models.ManyToManyField(User, through='UserMembership') + + def __unicode__(self): + return self.name + +__test__ = {'API_TESTS':""" +# Create some dummy data +>>> bob = Person.objects.create(name='Bob') +>>> jim = Person.objects.create(name='Jim') + +>>> rock = Group.objects.create(name='Rock') +>>> roll = Group.objects.create(name='Roll') + +>>> frank = User.objects.create_user('frank','frank@example.com','password') +>>> jane = User.objects.create_user('jane','jane@example.com','password') + +# Now test that the forward declared Membership works +>>> Membership.objects.create(person=bob, group=rock) + + +>>> Membership.objects.create(person=bob, group=roll) + + +>>> Membership.objects.create(person=jim, group=rock) + + +>>> bob.group_set.all() +[, ] + +>>> roll.members.all() +[] + +# Error messages use the model name, not repr of the class name +>>> bob.group_set = [] +Traceback (most recent call last): +... +AttributeError: Cannot set values on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +>>> roll.members = [] +Traceback (most recent call last): +... +AttributeError: Cannot set values on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +>>> rock.members.create(name='Anne') +Traceback (most recent call last): +... +AttributeError: Cannot use create() on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +>>> bob.group_set.create(name='Funk') +Traceback (most recent call last): +... +AttributeError: Cannot use create() on a ManyToManyField which specifies an intermediary model. Use Membership's Manager instead. + +# Now test that the intermediate with a relationship outside +# the current app (i.e., UserMembership) workds +>>> UserMembership.objects.create(user=frank, group=rock) + + +>>> UserMembership.objects.create(user=frank, group=roll) + + +>>> UserMembership.objects.create(user=jane, group=rock) + + +>>> frank.group_set.all() +[, ] + +>>> roll.user_members.all() +[] + +"""} \ No newline at end of file