Merge branch 'attribute_access' into release
[philo.git] / philo / models / base.py
index af1e880..7d78383 100644 (file)
@@ -1,25 +1,29 @@
 from django import forms
-from django.db import models
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes import generic
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.validators import RegexValidator
+from django.db import models
 from django.utils import simplejson as json
 from django.utils.encoding import force_unicode
+from mptt.models import MPTTModel, MPTTModelBase, MPTTOptions
+
 from philo.exceptions import AncestorDoesNotExist
 from philo.models.fields import JSONField
-from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter
 from philo.signals import entity_class_prepared
+from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter, AttributeMapper, TreeAttributeMapper
 from philo.validators import json_validator
-from UserDict import DictMixin
-from mptt.models import MPTTModel, MPTTModelBase, MPTTOptions
 
 
 class Tag(models.Model):
+       """A simple, generic model for tagging."""
+       #: A CharField (max length 255) which contains the name of the tag.
        name = models.CharField(max_length=255)
+       #: A CharField (max length 255) which contains the tag's unique slug.
        slug = models.SlugField(max_length=255, unique=True)
        
        def __unicode__(self):
+               """Returns the value of the :attr:`name` field"""
                return self.name
        
        class Meta:
@@ -38,10 +42,12 @@ class Titled(models.Model):
                abstract = True
 
 
+#: An instance of :class:`ContentTypeRegistryLimiter` which is used to track the content types which can be related to by :class:`ForeignKeyValue`\ s and :class:`ManyToManyValue`\ s.
 value_content_type_limiter = ContentTypeRegistryLimiter()
 
 
 def register_value_model(model):
+       """Registers a model as a valid content type for a :class:`ForeignKeyValue` or :class:`ManyToManyValue` through the :data:`value_content_type_limiter`."""
        value_content_type_limiter.register_class(model)
 
 
@@ -49,21 +55,37 @@ register_value_model(Tag)
 
 
 def unregister_value_model(model):
+       """Registers a model as a valid content type for a :class:`ForeignKeyValue` or :class:`ManyToManyValue` through the :data:`value_content_type_limiter`."""
        value_content_type_limiter.unregister_class(model)
 
 
 class AttributeValue(models.Model):
+       """
+       This is an abstract base class for models that can be used as values for :class:`Attribute`\ s.
+       
+       AttributeValue subclasses are expected to supply access to a clean version of their value through an attribute called "value".
+       
+       """
+       
+       #: :class:`GenericRelation` to :class:`Attribute`
        attribute_set = generic.GenericRelation('Attribute', content_type_field='value_content_type', object_id_field='value_object_id')
        
        def set_value(self, value):
+               """Given a ``value``, sets the appropriate fields so that it can be correctly stored in the database."""
                raise NotImplementedError
        
        def value_formfields(self, **kwargs):
-               """Define any formfields that would be used to construct an instance of this value."""
+               """
+               Returns any formfields that would be used to construct an instance of this value.
+               
+               :returns: A dictionary mapping field names to formfields.
+               
+               """
+               
                raise NotImplementedError
        
        def construct_instance(self, **kwargs):
-               """Apply cleaned data from the formfields generated by valid_formfields to oneself."""
+               """Applies cleaned data from the formfields generated by valid_formfields to oneself."""
                raise NotImplementedError
        
        def __unicode__(self):
@@ -73,10 +95,12 @@ class AttributeValue(models.Model):
                abstract = True
 
 
+#: An instance of :class:`ContentTypeSubclassLimiter` which is used to track the content types which are considered valid value models for an :class:`Attribute`.
 attribute_value_limiter = ContentTypeSubclassLimiter(AttributeValue)
 
 
 class JSONValue(AttributeValue):
+       """Stores a python object as a json string."""
        value = JSONField(verbose_name='Value (JSON)', help_text='This value must be valid JSON.', default='null', db_index=True)
        
        def __unicode__(self):
@@ -99,6 +123,7 @@ class JSONValue(AttributeValue):
 
 
 class ForeignKeyValue(AttributeValue):
+       """Stores a generic relationship to an instance of any value content type (as defined by the :data:`value_content_type_limiter`)."""
        content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
        object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
        value = generic.GenericForeignKey()
@@ -136,6 +161,7 @@ class ForeignKeyValue(AttributeValue):
 
 
 class ManyToManyValue(AttributeValue):
+       """Stores a generic relationship to many instances of any value content type (as defined by the :data:`value_content_type_limiter`)."""
        content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
        values = models.ManyToManyField(ForeignKeyValue, blank=True, null=True)
        
@@ -215,48 +241,45 @@ class ManyToManyValue(AttributeValue):
 
 
 class Attribute(models.Model):
+       """Represents an arbitrary key/value pair on an arbitrary :class:`Model` where the key consists of word characters and the value is a subclass of :class:`AttributeValue`."""
        entity_content_type = models.ForeignKey(ContentType, related_name='attribute_entity_set', verbose_name='Entity type')
        entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID', db_index=True)
+       
+       #: :class:`GenericForeignKey` to anything (generally an instance of an Entity subclass).
        entity = generic.GenericForeignKey('entity_content_type', 'entity_object_id')
        
        value_content_type = models.ForeignKey(ContentType, related_name='attribute_value_set', limit_choices_to=attribute_value_limiter, verbose_name='Value type', null=True, blank=True)
        value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
+       
+       #: :class:`GenericForeignKey` to an instance of a subclass of :class:`AttributeValue` as determined by the :data:`attribute_value_limiter`.
        value = generic.GenericForeignKey('value_content_type', 'value_object_id')
        
+       #: :class:`CharField` containing a key (up to 255 characters) consisting of alphanumeric characters and underscores.
        key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
        
        def __unicode__(self):
                return u'"%s": %s' % (self.key, self.value)
        
+       def set_value(self, value, value_class=JSONValue):
+               """Given a value and a value class, sets up self.value appropriately."""
+               if isinstance(self.value, value_class):
+                       val = self.value
+               else:
+                       if isinstance(self.value, models.Model):
+                               self.value.delete()
+                       val = value_class()
+               
+               val.set_value(value)
+               val.save()
+               
+               self.value = val
+               self.save()
+       
        class Meta:
                app_label = 'philo'
                unique_together = (('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))
 
 
-class QuerySetMapper(object, DictMixin):
-       def __init__(self, queryset, passthrough=None):
-               self.queryset = queryset
-               self.passthrough = passthrough
-       
-       def __getitem__(self, key):
-               try:
-                       value = self.queryset.get(key__exact=key).value
-               except ObjectDoesNotExist:
-                       if self.passthrough is not None:
-                               return self.passthrough.__getitem__(key)
-                       raise KeyError
-               else:
-                       if value is not None:
-                               return value.value
-                       return value
-       
-       def keys(self):
-               keys = set(self.queryset.values_list('key', flat=True).distinct())
-               if self.passthrough is not None:
-                       keys |= set(self.passthrough.keys())
-               return list(keys)
-
-
 class EntityOptions(object):
        def __init__(self, options):
                if options is not None:
@@ -279,13 +302,26 @@ class EntityBase(models.base.ModelBase):
 
 
 class Entity(models.Model):
+       """An abstract class that simplifies access to related attributes. Most models provided by Philo subclass Entity."""
        __metaclass__ = EntityBase
        
        attribute_set = generic.GenericRelation(Attribute, content_type_field='entity_content_type', object_id_field='entity_object_id')
        
-       @property
-       def attributes(self):
-               return QuerySetMapper(self.attribute_set.all())
+       def get_attribute_mapper(self, mapper=AttributeMapper):
+               """
+               Returns a dictionary-like object which can be used to retrieve related :class:`Attribute`\ s' values directly.
+
+               Example::
+
+                       >>> attr = entity.attribute_set.get(key='spam')
+                       >>> attr.value.value
+                       u'eggs'
+                       >>> entity.attributes['spam']
+                       u'eggs'
+               
+               """
+               return mapper(self)
+       attributes = property(get_attribute_mapper)
        
        class Meta:
                abstract = True
@@ -296,19 +332,22 @@ class TreeManager(models.Manager):
        
        def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'):
                """
-               Returns the object with the path, unless absolute_result is set to False, in which
-               case it returns a tuple containing the deepest object found along the path, and the
-               remainder of the path after that object as a string (or None if there is no remaining
-               path). Raises a DoesNotExist exception if no object is found with the given path.
-               
-               If the path you're searching for is known to exist, it is always faster to use
-               absolute_result=True - unless the path depth is over ~40, in which case the high cost
-               of the absolute query makes a binary search (i.e. non-absolute) faster.
+               If ``absolute_result`` is ``True``, returns the object at ``path`` (starting at ``root``) or raises an :class:`~django.core.exceptions.ObjectDoesNotExist` exception. Otherwise, returns a tuple containing the deepest object found along ``path`` (or ``root`` if no deeper object is found) and the remainder of the path after that object as a string (or None if there is no remaining path).
+               
+               .. note:: If you are looking for something with an exact path, it is faster to use absolute_result=True, unless the path depth is over ~40, in which case the high cost of the absolute query may make a binary search (i.e. non-absolute) faster.
+               
+               .. note:: SQLite allows max of 64 tables in one join. That means the binary search will only work on paths with a max depth of 127 and the absolute fetch will only work to a max depth of (surprise!) 63. Larger depths could be handled, but since the common use case will not have a tree structure that deep, they are not.
+               
+               :param path: The path of the object
+               :param root: The object which will be considered the root of the search
+               :param absolute_result: Whether to return an absolute result or do a binary search
+               :param pathsep: The path separator used in ``path``
+               :param field: The field on the model which should be queried for ``path`` segment matching.
+               :returns: An instance if ``absolute_result`` is ``True`` or an (instance, remaining_path) tuple otherwise.
+               :raises django.core.exceptions.ObjectDoesNotExist: if no object can be found matching the input parameters.
+               
                """
-               # Note: SQLite allows max of 64 tables in one join. That means the binary search will
-               # only work on paths with a max depth of 127 and the absolute fetch will only work
-               # to a max depth of (surprise!) 63. Although this could be handled, chances are your
-               # tree structure won't be that deep.
+               
                segments = path.split(pathsep)
                
                # Clean out blank segments. Handles multiple consecutive pathseps.
@@ -407,6 +446,14 @@ class TreeModel(MPTTModel):
        slug = models.SlugField(max_length=255)
        
        def get_path(self, root=None, pathsep='/', field='slug'):
+               """
+               :param root: Only return the path since this object.
+               :param pathsep: The path separator to use when constructing an instance's path
+               :param field: The field to pull path information from for each ancestor.
+               :returns: A string representation of an object's path.
+               
+               """
+               
                if root == self:
                        return ''
                
@@ -438,13 +485,32 @@ class TreeEntityBase(MPTTModelBase, EntityBase):
 
 
 class TreeEntity(Entity, TreeModel):
+       """An abstract subclass of Entity which represents a tree relationship."""
+       
        __metaclass__ = TreeEntityBase
        
-       @property
-       def attributes(self):
-               if self.parent:
-                       return QuerySetMapper(self.attribute_set.all(), passthrough=self.parent.attributes)
-               return super(TreeEntity, self).attributes
+       def get_attribute_mapper(self, mapper=None):
+               """
+               Returns a dictionary-like object which can be used to retrieve related :class:`Attribute`\ s' values directly. If an attribute with a given key is not related to the :class:`Entity`, then the object will check the parent's attributes.
+
+               Example::
+
+                       >>> attr = entity.attribute_set.get(key='spam')
+                       DoesNotExist: Attribute matching query does not exist.
+                       >>> attr = entity.parent.attribute_set.get(key='spam')
+                       >>> attr.value.value
+                       u'eggs'
+                       >>> entity.attributes['spam']
+                       u'eggs'
+               
+               """
+               if mapper is None:
+                       if self.parent:
+                               mapper = TreeAttributeMapper
+                       else:
+                               mapper = AttributeMapper
+               return super(TreeEntity, self).get_attribute_mapper(mapper)
+       attributes = property(get_attribute_mapper)
        
        class Meta:
                abstract = True
\ No newline at end of file