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
+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')
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:
class Titled(models.Model):
+ # Use of this model is deprecated.
title = models.CharField(max_length=255)
slug = models.SlugField(max_length=255)
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)
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):
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):
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()
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)
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:
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 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)
+ attributes = property(get_attribute_mapper)
class Meta:
abstract = True
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.
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 ''
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 :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)
+ attributes = property(get_attribute_mapper)
class Meta:
abstract = True
\ No newline at end of file