diff --git a/django/core/management.py b/django/core/management.py index 87db88611a..61213d0965 100644 --- a/django/core/management.py +++ b/django/core/management.py @@ -211,35 +211,38 @@ def _get_sql_for_pending_references(klass, pending_references): def _get_many_to_many_sql_for_model(klass): from django.db import backend, get_creation_module + from django.db.models import GenericRel + data_types = get_creation_module().DATA_TYPES opts = klass._meta final_output = [] for f in opts.many_to_many: - table_output = [style.SQL_KEYWORD('CREATE TABLE') + ' ' + \ - style.SQL_TABLE(backend.quote_name(f.m2m_db_table())) + ' ('] - table_output.append(' %s %s %s,' % \ - (style.SQL_FIELD(backend.quote_name('id')), - style.SQL_COLTYPE(data_types['AutoField']), - style.SQL_KEYWORD('NOT NULL PRIMARY KEY'))) - table_output.append(' %s %s %s %s (%s),' % \ - (style.SQL_FIELD(backend.quote_name(f.m2m_column_name())), - style.SQL_COLTYPE(data_types[get_rel_data_type(opts.pk)] % opts.pk.__dict__), - style.SQL_KEYWORD('NOT NULL REFERENCES'), - style.SQL_TABLE(backend.quote_name(opts.db_table)), - style.SQL_FIELD(backend.quote_name(opts.pk.column)))) - table_output.append(' %s %s %s %s (%s),' % \ - (style.SQL_FIELD(backend.quote_name(f.m2m_reverse_name())), - style.SQL_COLTYPE(data_types[get_rel_data_type(f.rel.to._meta.pk)] % f.rel.to._meta.pk.__dict__), - style.SQL_KEYWORD('NOT NULL REFERENCES'), - style.SQL_TABLE(backend.quote_name(f.rel.to._meta.db_table)), - style.SQL_FIELD(backend.quote_name(f.rel.to._meta.pk.column)))) - table_output.append(' %s (%s, %s)' % \ - (style.SQL_KEYWORD('UNIQUE'), - style.SQL_FIELD(backend.quote_name(f.m2m_column_name())), - style.SQL_FIELD(backend.quote_name(f.m2m_reverse_name())))) - table_output.append(');') - final_output.append('\n'.join(table_output)) + if not isinstance(f.rel, GenericRel): + table_output = [style.SQL_KEYWORD('CREATE TABLE') + ' ' + \ + style.SQL_TABLE(backend.quote_name(f.m2m_db_table())) + ' ('] + table_output.append(' %s %s %s,' % \ + (style.SQL_FIELD(backend.quote_name('id')), + style.SQL_COLTYPE(data_types['AutoField']), + style.SQL_KEYWORD('NOT NULL PRIMARY KEY'))) + table_output.append(' %s %s %s %s (%s),' % \ + (style.SQL_FIELD(backend.quote_name(f.m2m_column_name())), + style.SQL_COLTYPE(data_types[get_rel_data_type(opts.pk)] % opts.pk.__dict__), + style.SQL_KEYWORD('NOT NULL REFERENCES'), + style.SQL_TABLE(backend.quote_name(opts.db_table)), + style.SQL_FIELD(backend.quote_name(opts.pk.column)))) + table_output.append(' %s %s %s %s (%s),' % \ + (style.SQL_FIELD(backend.quote_name(f.m2m_reverse_name())), + style.SQL_COLTYPE(data_types[get_rel_data_type(f.rel.to._meta.pk)] % f.rel.to._meta.pk.__dict__), + style.SQL_KEYWORD('NOT NULL REFERENCES'), + style.SQL_TABLE(backend.quote_name(f.rel.to._meta.db_table)), + style.SQL_FIELD(backend.quote_name(f.rel.to._meta.pk.column)))) + table_output.append(' %s (%s, %s)' % \ + (style.SQL_KEYWORD('UNIQUE'), + style.SQL_FIELD(backend.quote_name(f.m2m_column_name())), + style.SQL_FIELD(backend.quote_name(f.m2m_reverse_name())))) + table_output.append(');') + final_output.append('\n'.join(table_output)) return final_output def get_sql_delete(app): diff --git a/django/db/models/__init__.py b/django/db/models/__init__.py index 6d6197c162..82b1238723 100644 --- a/django/db/models/__init__.py +++ b/django/db/models/__init__.py @@ -8,6 +8,7 @@ from django.db.models.manager import Manager from django.db.models.base import Model, AdminOptions from django.db.models.fields import * from django.db.models.fields.related import ForeignKey, OneToOneField, ManyToManyField, ManyToOneRel, ManyToManyRel, OneToOneRel, TABULAR, STACKED +from django.db.models.fields.generic import GenericRelation, GenericRel, GenericForeignKey from django.db.models import signals from django.utils.functional import curry from django.utils.text import capfirst diff --git a/django/db/models/fields/generic.py b/django/db/models/fields/generic.py new file mode 100644 index 0000000000..5f4de40e69 --- /dev/null +++ b/django/db/models/fields/generic.py @@ -0,0 +1,259 @@ +""" +Classes allowing "generic" relations through ContentType and object-id fields. +""" + +from django import forms +from django.core.exceptions import ObjectDoesNotExist +from django.db import backend +from django.db.models import signals +from django.db.models.fields.related import RelatedField, Field, ManyToManyRel +from django.db.models.loading import get_model +from django.dispatch import dispatcher +from django.utils.functional import curry + +class GenericForeignKey(object): + """ + Provides a generic relation to any object through content-type/object-id + fields. + """ + + def __init__(self, ct_field="content_type", fk_field="object_id"): + self.ct_field = ct_field + self.fk_field = fk_field + + def contribute_to_class(self, cls, name): + # Make sure the fields exist (these raise FieldDoesNotExist, + # which is a fine error to raise here) + self.name = name + self.model = cls + self.cache_attr = "_%s_cache" % name + + # For some reason I don't totally understand, using weakrefs here doesn't work. + dispatcher.connect(self.instance_pre_init, signal=signals.pre_init, sender=cls, weak=False) + + # Connect myself as the descriptor for this field + setattr(cls, name, self) + + def instance_pre_init(self, signal, sender, args, kwargs): + # Handle initalizing an object with the generic FK instaed of + # content-type/object-id fields. + if kwargs.has_key(self.name): + value = kwargs.pop(self.name) + kwargs[self.ct_field] = self.get_content_type(value) + kwargs[self.fk_field] = value._get_pk_val() + + def get_content_type(self, obj): + # Convenience function using get_model avoids a circular import when using this model + ContentType = get_model("contenttypes", "contenttype") + return ContentType.objects.get_for_model(obj) + + def __get__(self, instance, instance_type=None): + if instance is None: + raise AttributeError, "%s must be accessed via instance" % self.name + + try: + return getattr(instance, self.cache_attr) + except AttributeError: + rel_obj = None + ct = getattr(instance, self.ct_field) + if ct: + try: + rel_obj = ct.get_object_for_this_type(pk=getattr(instance, self.fk_field)) + except ObjectDoesNotExist: + pass + setattr(instance, self.cache_attr, rel_obj) + return rel_obj + + def __set__(self, instance, value): + if instance is None: + raise AttributeError, "%s must be accessed via instance" % self.related.opts.object_name + + ct = None + fk = None + if value is not None: + ct = self.get_content_type(value) + fk = value._get_pk_val() + + setattr(instance, self.ct_field, ct) + setattr(instance, self.fk_field, fk) + setattr(instance, self.cache_attr, value) + +class GenericRelation(RelatedField, Field): + """Provides an accessor to generic related objects (i.e. comments)""" + + def __init__(self, to, **kwargs): + kwargs['verbose_name'] = kwargs.get('verbose_name', None) + kwargs['rel'] = GenericRel(to, + related_name=kwargs.pop('related_name', None), + limit_choices_to=kwargs.pop('limit_choices_to', None), + symmetrical=kwargs.pop('symmetrical', True)) + + # 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") + + kwargs['blank'] = True + kwargs['editable'] = False + Field.__init__(self, **kwargs) + + def get_manipulator_field_objs(self): + choices = self.get_choices_default() + return [curry(forms.SelectMultipleField, size=min(max(len(choices), 5), 15), choices=choices)] + + def get_choices_default(self): + return Field.get_choices(self, include_blank=False) + + def flatten_data(self, follow, obj = None): + new_data = {} + if obj: + instance_ids = [instance._get_pk_val() for instance in getattr(obj, self.name).all()] + new_data[self.name] = instance_ids + return new_data + + def m2m_db_table(self): + return self.rel.to._meta.db_table + + def m2m_column_name(self): + return self.object_id_field_name + + def m2m_reverse_name(self): + return self.model._meta.pk.attname + + def contribute_to_class(self, cls, name): + super(GenericRelation, self).contribute_to_class(cls, name) + + # Save a reference to which model this class is on for future use + self.model = cls + + # Add the descriptor for the m2m relation + setattr(cls, self.name, ReverseGenericRelatedObjectsDescriptor(self)) + + def contribute_to_related_class(self, cls, related): + pass + + def set_attributes_from_rel(self): + pass + + def get_internal_type(self): + return "ManyToManyField" + +class ReverseGenericRelatedObjectsDescriptor(object): + """ + This class provides the functionality that makes the related-object + managers available as attributes on a model class, for fields that have + multiple "remote" values and have a GenericRelation defined in their model + (rather than having another model pointed *at* them). In the example + "article.publications", the publications attribute is a + ReverseGenericRelatedObjectsDescriptor instance. + """ + def __init__(self, field): + self.field = field + + def __get__(self, instance, instance_type=None): + if instance is None: + raise AttributeError, "Manager must be accessed via instance" + + # This import is done here to avoid circular import importing this module + from django.contrib.contenttypes.models import ContentType + + # Dynamically create a class that subclasses the related model's + # default manager. + rel_model = self.field.rel.to + superclass = rel_model._default_manager.__class__ + RelatedManager = create_generic_related_manager(superclass) + + manager = RelatedManager( + model = rel_model, + instance = instance, + symmetrical = (self.field.rel.symmetrical and instance.__class__ == rel_model), + join_table = backend.quote_name(self.field.m2m_db_table()), + source_col_name = backend.quote_name(self.field.m2m_column_name()), + target_col_name = backend.quote_name(self.field.m2m_reverse_name()), + content_type = ContentType.objects.get_for_model(self.field.model), + content_type_field_name = self.field.content_type_field_name, + object_id_field_name = self.field.object_id_field_name + ) + + return manager + + def __set__(self, instance, value): + if instance is None: + raise AttributeError, "Manager must be accessed via instance" + + manager = self.__get__(instance) + manager.clear() + for obj in value: + manager.add(obj) + +def create_generic_related_manager(superclass): + """ + Factory function for a manager that subclasses 'superclass' (which is a + Manager) and adds behavior for generic related objects. + """ + + class GenericRelatedObjectManager(superclass): + def __init__(self, model=None, core_filters=None, instance=None, symmetrical=None, + join_table=None, source_col_name=None, target_col_name=None, content_type=None, + content_type_field_name=None, object_id_field_name=None): + + super(GenericRelatedObjectManager, self).__init__() + self.core_filters = core_filters or {} + self.model = model + self.content_type = content_type + self.symmetrical = symmetrical + self.instance = instance + self.join_table = join_table + self.join_table = model._meta.db_table + self.source_col_name = source_col_name + self.target_col_name = target_col_name + self.content_type_field_name = content_type_field_name + self.object_id_field_name = object_id_field_name + self.pk_val = self.instance._get_pk_val() + + def get_query_set(self): + query = { + '%s__pk' % self.content_type_field_name : self.content_type.id, + '%s__exact' % self.object_id_field_name : self.pk_val, + } + return superclass.get_query_set(self).filter(**query) + + def add(self, *objs): + for obj in objs: + setattr(obj, self.content_type_field_name, self.content_type) + setattr(obj, self.object_id_field_name, self.pk_val) + obj.save() + add.alters_data = True + + def remove(self, *objs): + for obj in objs: + obj.delete() + remove.alters_data = True + + def clear(self): + for obj in self.all(): + obj.delete() + clear.alters_data = True + + def create(self, **kwargs): + kwargs[self.content_type_field_name] = self.content_type + kwargs[self.object_id_field_name] = self.pk_val + obj = self.model(**kwargs) + obj.save() + return obj + create.alters_data = True + + return GenericRelatedObjectManager + +class GenericRel(ManyToManyRel): + def __init__(self, to, related_name=None, limit_choices_to=None, symmetrical=True): + self.to = to + self.num_in_admin = 0 + self.related_name = related_name + self.filter_interface = None + self.limit_choices_to = limit_choices_to or {} + self.edit_inline = False + self.raw_id_admin = False + self.symmetrical = symmetrical + self.multiple = True + assert not (self.raw_id_admin and self.filter_interface), \ + "Generic relations may not use both raw_id_admin and filter_interface" diff --git a/tests/modeltests/generic_relations/__init__.py b/tests/modeltests/generic_relations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/modeltests/generic_relations/models.py b/tests/modeltests/generic_relations/models.py new file mode 100644 index 0000000000..a9a775ad6e --- /dev/null +++ b/tests/modeltests/generic_relations/models.py @@ -0,0 +1,108 @@ +""" +33. Generic relations + +Generic relations let an object have a foreign key to any object through a +content-type/object-id field. A generic foreign key can point to any object, +be it animal, vegetable, or mineral. + +The cannonical example is tags (although this example implementation is *far* +from complete). +""" + +from django.db import models +from django.contrib.contenttypes.models import ContentType + +class TaggedItem(models.Model): + """A tag on an item.""" + tag = models.SlugField() + content_type = models.ForeignKey(ContentType) + object_id = models.PositiveIntegerField() + + content_object = models.GenericForeignKey() + + class Meta: + ordering = ["tag"] + + def __str__(self): + return self.tag + +class Animal(models.Model): + common_name = models.CharField(maxlength=150) + latin_name = models.CharField(maxlength=150) + + tags = models.GenericRelation(TaggedItem) + + def __str__(self): + return self.common_name + +class Vegetable(models.Model): + name = models.CharField(maxlength=150) + is_yucky = models.BooleanField(default=True) + + tags = models.GenericRelation(TaggedItem) + + def __str__(self): + return self.name + +class Mineral(models.Model): + name = models.CharField(maxlength=150) + hardness = models.PositiveSmallIntegerField() + + # note the lack of an explicit GenericRelation here... + + def __str__(self): + return self.name + +API_TESTS = """ +# Create the world in 7 lines of code... +>>> lion = Animal(common_name="Lion", latin_name="Panthera leo") +>>> platypus = Animal(common_name="Platypus", latin_name="Ornithorhynchus anatinus") +>>> eggplant = Vegetable(name="Eggplant", is_yucky=True) +>>> bacon = Vegetable(name="Bacon", is_yucky=False) +>>> quartz = Mineral(name="Quartz", hardness=7) +>>> for o in (lion, platypus, eggplant, bacon, quartz): +... o.save() + +# Objects with declared GenericRelations can be tagged directly -- the API +# mimics the many-to-many API +>>> lion.tags.create(tag="yellow") + +>>> lion.tags.create(tag="hairy") + +>>> bacon.tags.create(tag="fatty") + +>>> bacon.tags.create(tag="salty") + + +>>> lion.tags.all() +[, ] +>>> bacon.tags.all() +[, ] + +# You can easily access the content object like a foreign key +>>> t = TaggedItem.objects.get(tag="salty") +>>> t.content_object + + +# Recall that the Mineral class doesn't have an explicit GenericRelation +# defined. That's OK since you can create TaggedItems explicitally. +>>> tag1 = TaggedItem(content_object=quartz, tag="shiny") +>>> tag2 = TaggedItem(content_object=quartz, tag="clearish") +>>> tag1.save() +>>> tag2.save() + +# However, not having the convience takes a small toll when it comes +# to do lookups +>>> from django.contrib.contenttypes.models import ContentType +>>> ctype = ContentType.objects.get_for_model(quartz) +>>> TaggedItem.objects.filter(content_type__pk=ctype.id, object_id=quartz.id) +[, ] + +# You can set a generic foreign key in the way you'd expect +>>> tag1.content_object = platypus +>>> tag1.save() +>>> platypus.tags.all() +[] +>>> TaggedItem.objects.filter(content_type__pk=ctype.id, object_id=quartz.id) +[] +""" \ No newline at end of file