Added memoization (optional but enabled by default) to TreeEntity.get_path.
[philo.git] / philo / models / base.py
index af1e880..d1dc38a 100644 (file)
@@ -1,25 +1,33 @@
 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.exceptions import ValidationError
 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
+from philo.utils.entities import AttributeMapper, TreeAttributeMapper
 from philo.validators import json_validator
-from UserDict import DictMixin
-from mptt.models import MPTTModel, MPTTModelBase, MPTTOptions
+
+
+__all__ = ('Tag', 'value_content_type_limiter', 'register_value_model', 'unregister_value_model', 'JSONValue', 'ForeignKeyValue', 'ManyToManyValue', 'Attribute', 'Entity', 'TreeEntity', 'SlugTreeEntity')
 
 
 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:
@@ -27,21 +35,12 @@ class Tag(models.Model):
                ordering = ('name',)
 
 
-class Titled(models.Model):
-       title = models.CharField(max_length=255)
-       slug = models.SlugField(max_length=255)
-       
-       def __unicode__(self):
-               return self.title
-       
-       class Meta:
-               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 +48,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 +88,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 +116,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 +154,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 +234,50 @@ class ManyToManyValue(AttributeValue):
 
 
 class Attribute(models.Model):
+       """
+       :class:`Attribute`\ s exist primarily to let arbitrary data be attached to arbitrary model instances without altering the database schema and without guaranteeing that the data will be available on every instance of that model.
+       
+       Generally, :class:`Attribute`\ s will not be accessed as models; instead, they will be accessed through the :attr:`Entity.attributes` property, which allows direct dictionary getting and setting of the value of an :class:`Attribute` with its key.
+       
+       """
        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,36 +300,65 @@ 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')
        
+       def get_attribute_mapper(self, mapper=AttributeMapper):
+               """
+               Returns an :class:`.AttributeMapper` 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)
+       
        @property
        def attributes(self):
-               return QuerySetMapper(self.attribute_set.all())
+               if not hasattr(self, '_attributes'):
+                       self._attributes = self.get_attribute_mapper()
+               return self._attributes
        
        class Meta:
                abstract = True
 
 
-class TreeManager(models.Manager):
+class TreeEntityBase(MPTTModelBase, EntityBase):
+       def __new__(meta, name, bases, attrs):
+               attrs['_mptt_meta'] = MPTTOptions(attrs.pop('MPTTMeta', None))
+               cls = EntityBase.__new__(meta, name, bases, attrs)
+               
+               return meta.register(cls)
+
+
+class TreeEntityManager(models.Manager):
        use_for_related_fields = True
        
-       def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'):
+       def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='pk'):
                """
-               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.
@@ -401,50 +451,105 @@ class TreeManager(models.Manager):
                return find_obj(segments, len(segments)/2 or len(segments))
 
 
-class TreeModel(MPTTModel):
-       objects = TreeManager()
+class TreeEntity(Entity, MPTTModel):
+       """An abstract subclass of Entity which represents a tree relationship."""
+       
+       __metaclass__ = TreeEntityBase
+       objects = TreeEntityManager()
        parent = models.ForeignKey('self', related_name='children', null=True, blank=True)
-       slug = models.SlugField(max_length=255)
        
-       def get_path(self, root=None, pathsep='/', field='slug'):
+       def get_path(self, root=None, pathsep='/', field='pk', memoize=True):
+               """
+               :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.
+               :param memoize: Whether to use memoized results. Since, in most cases, the ancestors of a TreeEntity will not change over the course of an instance's lifetime, this defaults to ``True``.
+               :returns: A string representation of an object's path.
+               
+               """
+               
                if root == self:
                        return ''
                
+               if root is None and self.is_root_node():
+                       return getattr(self, field, '?')
+               
                if root is not None and not self.is_descendant_of(root):
                        raise AncestorDoesNotExist(root)
                
+               if memoize:
+                       memo_args = (getattr(self, "%s_id" % self._mptt_meta.parent_attr), getattr(root, 'pk', None), pathsep, getattr(self, field, '?'))
+                       try:
+                               return self._path_memo[memo_args]
+                       except AttributeError:
+                               self._path_memo = {}
+                       except KeyError:
+                               pass
+               
                qs = self.get_ancestors(include_self=True)
                
                if root is not None:
                        qs = qs.filter(**{'%s__gt' % self._mptt_meta.level_attr: root.get_level()})
                
-               return pathsep.join([getattr(parent, field, '?') for parent in qs])
+               path = pathsep.join([getattr(parent, field, '?') for parent in qs])
+               
+               if memoize:
+                       self._path_memo[memo_args] = path
+               
+               return path
        path = property(get_path)
        
+       def get_attribute_mapper(self, mapper=None):
+               """
+               Returns a :class:`.TreeAttributeMapper` or :class:`.AttributeMapper` which can be used to retrieve related :class:`Attribute`\ s' values directly. If an :class:`Attribute` with a given key is not related to the :class:`Entity`, then the mapper 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)
+       
        def __unicode__(self):
                return self.path
        
        class Meta:
-               unique_together = (('parent', 'slug'),)
                abstract = True
 
 
-class TreeEntityBase(MPTTModelBase, EntityBase):
-       def __new__(meta, name, bases, attrs):
-               attrs['_mptt_meta'] = MPTTOptions(attrs.pop('MPTTMeta', None))
-               cls = EntityBase.__new__(meta, name, bases, attrs)
-               
-               return meta.register(cls)
+class SlugTreeEntityManager(TreeEntityManager):
+       def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'):
+               return super(SlugTreeEntityManager, self).get_with_path(path, root, absolute_result, pathsep, field)
 
 
-class TreeEntity(Entity, TreeModel):
-       __metaclass__ = TreeEntityBase
+class SlugTreeEntity(TreeEntity):
+       objects = SlugTreeEntityManager()
+       slug = models.SlugField(max_length=255)
        
-       @property
-       def attributes(self):
-               if self.parent:
-                       return QuerySetMapper(self.attribute_set.all(), passthrough=self.parent.attributes)
-               return super(TreeEntity, self).attributes
+       def get_path(self, root=None, pathsep='/', field='slug', memoize=True):
+               return super(SlugTreeEntity, self).get_path(root, pathsep, field, memoize)
+       path = property(get_path)
+       
+       def clean(self):
+               if self.parent is None:
+                       try:
+                               self._default_manager.exclude(pk=self.pk).get(slug=self.slug, parent__isnull=True)
+                       except self.DoesNotExist:
+                               pass
+                       else:
+                               raise ValidationError(self.unique_error_message(self.__class__, ('parent', 'slug')))
        
        class Meta:
+               unique_together = ('parent', 'slug')
                abstract = True
\ No newline at end of file