Added memoization (optional but enabled by default) to TreeEntity.get_path.
[philo.git] / philo / models / base.py
1 from django import forms
2 from django.contrib.contenttypes.models import ContentType
3 from django.contrib.contenttypes import generic
4 from django.core.exceptions import ValidationError
5 from django.core.validators import RegexValidator
6 from django.db import models
7 from django.utils import simplejson as json
8 from django.utils.encoding import force_unicode
9 from mptt.models import MPTTModel, MPTTModelBase, MPTTOptions
10
11 from philo.exceptions import AncestorDoesNotExist
12 from philo.models.fields import JSONField
13 from philo.signals import entity_class_prepared
14 from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter
15 from philo.utils.entities import AttributeMapper, TreeAttributeMapper
16 from philo.validators import json_validator
17
18
19 __all__ = ('Tag', 'value_content_type_limiter', 'register_value_model', 'unregister_value_model', 'JSONValue', 'ForeignKeyValue', 'ManyToManyValue', 'Attribute', 'Entity', 'TreeEntity', 'SlugTreeEntity')
20
21
22 class Tag(models.Model):
23         """A simple, generic model for tagging."""
24         #: A CharField (max length 255) which contains the name of the tag.
25         name = models.CharField(max_length=255)
26         #: A CharField (max length 255) which contains the tag's unique slug.
27         slug = models.SlugField(max_length=255, unique=True)
28         
29         def __unicode__(self):
30                 """Returns the value of the :attr:`name` field"""
31                 return self.name
32         
33         class Meta:
34                 app_label = 'philo'
35                 ordering = ('name',)
36
37
38 #: 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.
39 value_content_type_limiter = ContentTypeRegistryLimiter()
40
41
42 def register_value_model(model):
43         """Registers a model as a valid content type for a :class:`ForeignKeyValue` or :class:`ManyToManyValue` through the :data:`value_content_type_limiter`."""
44         value_content_type_limiter.register_class(model)
45
46
47 register_value_model(Tag)
48
49
50 def unregister_value_model(model):
51         """Registers a model as a valid content type for a :class:`ForeignKeyValue` or :class:`ManyToManyValue` through the :data:`value_content_type_limiter`."""
52         value_content_type_limiter.unregister_class(model)
53
54
55 class AttributeValue(models.Model):
56         """
57         This is an abstract base class for models that can be used as values for :class:`Attribute`\ s.
58         
59         AttributeValue subclasses are expected to supply access to a clean version of their value through an attribute called "value".
60         
61         """
62         
63         #: :class:`GenericRelation` to :class:`Attribute`
64         attribute_set = generic.GenericRelation('Attribute', content_type_field='value_content_type', object_id_field='value_object_id')
65         
66         def set_value(self, value):
67                 """Given a ``value``, sets the appropriate fields so that it can be correctly stored in the database."""
68                 raise NotImplementedError
69         
70         def value_formfields(self, **kwargs):
71                 """
72                 Returns any formfields that would be used to construct an instance of this value.
73                 
74                 :returns: A dictionary mapping field names to formfields.
75                 
76                 """
77                 
78                 raise NotImplementedError
79         
80         def construct_instance(self, **kwargs):
81                 """Applies cleaned data from the formfields generated by valid_formfields to oneself."""
82                 raise NotImplementedError
83         
84         def __unicode__(self):
85                 return unicode(self.value)
86         
87         class Meta:
88                 abstract = True
89
90
91 #: An instance of :class:`.ContentTypeSubclassLimiter` which is used to track the content types which are considered valid value models for an :class:`Attribute`.
92 attribute_value_limiter = ContentTypeSubclassLimiter(AttributeValue)
93
94
95 class JSONValue(AttributeValue):
96         """Stores a python object as a json string."""
97         value = JSONField(verbose_name='Value (JSON)', help_text='This value must be valid JSON.', default='null', db_index=True)
98         
99         def __unicode__(self):
100                 return force_unicode(self.value)
101         
102         def value_formfields(self):
103                 kwargs = {'initial': self.value_json}
104                 field = self._meta.get_field('value')
105                 return {field.name: field.formfield(**kwargs)}
106         
107         def construct_instance(self, **kwargs):
108                 field_name = self._meta.get_field('value').name
109                 self.set_value(kwargs.pop(field_name, None))
110         
111         def set_value(self, value):
112                 self.value = value
113         
114         class Meta:
115                 app_label = 'philo'
116
117
118 class ForeignKeyValue(AttributeValue):
119         """Stores a generic relationship to an instance of any value content type (as defined by the :data:`value_content_type_limiter`)."""
120         content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
121         object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
122         value = generic.GenericForeignKey()
123         
124         def value_formfields(self):
125                 field = self._meta.get_field('content_type')
126                 fields = {field.name: field.formfield(initial=getattr(self.content_type, 'pk', None))}
127                 
128                 if self.content_type:
129                         kwargs = {
130                                 'initial': self.object_id,
131                                 'required': False,
132                                 'queryset': self.content_type.model_class()._default_manager.all()
133                         }
134                         fields['value'] = forms.ModelChoiceField(**kwargs)
135                 return fields
136         
137         def construct_instance(self, **kwargs):
138                 field_name = self._meta.get_field('content_type').name
139                 ct = kwargs.pop(field_name, None)
140                 if ct is None or ct != self.content_type:
141                         self.object_id = None
142                         self.content_type = ct
143                 else:
144                         value = kwargs.pop('value', None)
145                         self.set_value(value)
146                         if value is None:
147                                 self.content_type = ct
148         
149         def set_value(self, value):
150                 self.value = value
151         
152         class Meta:
153                 app_label = 'philo'
154
155
156 class ManyToManyValue(AttributeValue):
157         """Stores a generic relationship to many instances of any value content type (as defined by the :data:`value_content_type_limiter`)."""
158         content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
159         values = models.ManyToManyField(ForeignKeyValue, blank=True, null=True)
160         
161         def get_object_ids(self):
162                 return self.values.values_list('object_id', flat=True)
163         object_ids = property(get_object_ids)
164         
165         def set_value(self, value):
166                 # Value must be a queryset. Watch out for ModelMultipleChoiceField;
167                 # it returns its value as a list if empty.
168                 
169                 self.content_type = ContentType.objects.get_for_model(value.model)
170                 
171                 # Before we can fiddle with the many-to-many to foreignkeyvalues, we need
172                 # a pk.
173                 if self.pk is None:
174                         self.save()
175                 
176                 object_ids = value.values_list('id', flat=True)
177                 
178                 # These lines shouldn't be necessary; however, if object_ids is an EmptyQuerySet,
179                 # the code (specifically the object_id__in query) won't work without them. Unclear why...
180                 # TODO: is this still the case?
181                 if not object_ids:
182                         self.values.all().delete()
183                 else:
184                         self.values.exclude(object_id__in=object_ids, content_type=self.content_type).delete()
185                         
186                         current_ids = self.object_ids
187                         
188                         for object_id in object_ids:
189                                 if object_id in current_ids:
190                                         continue
191                                 self.values.create(content_type=self.content_type, object_id=object_id)
192         
193         def get_value(self):
194                 if self.content_type is None:
195                         return None
196                 
197                 # HACK to be safely explicit until http://code.djangoproject.com/ticket/15145 is resolved
198                 object_ids = self.object_ids
199                 manager = self.content_type.model_class()._default_manager
200                 if not object_ids:
201                         return manager.none()
202                 return manager.filter(id__in=self.object_ids)
203         
204         value = property(get_value, set_value)
205         
206         def value_formfields(self):
207                 field = self._meta.get_field('content_type')
208                 fields = {field.name: field.formfield(initial=getattr(self.content_type, 'pk', None))}
209                 
210                 if self.content_type:
211                         kwargs = {
212                                 'initial': self.object_ids,
213                                 'required': False,
214                                 'queryset': self.content_type.model_class()._default_manager.all()
215                         }
216                         fields['value'] = forms.ModelMultipleChoiceField(**kwargs)
217                 return fields
218         
219         def construct_instance(self, **kwargs):
220                 field_name = self._meta.get_field('content_type').name
221                 ct = kwargs.pop(field_name, None)
222                 if ct is None or ct != self.content_type:
223                         self.values.clear()
224                         self.content_type = ct
225                 else:
226                         value = kwargs.get('value', None)
227                         if not value:
228                                 value = self.content_type.model_class()._default_manager.none()
229                         self.set_value(value)
230         construct_instance.alters_data = True
231         
232         class Meta:
233                 app_label = 'philo'
234
235
236 class Attribute(models.Model):
237         """
238         :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.
239         
240         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.
241         
242         """
243         entity_content_type = models.ForeignKey(ContentType, related_name='attribute_entity_set', verbose_name='Entity type')
244         entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID', db_index=True)
245         
246         #: :class:`GenericForeignKey` to anything (generally an instance of an Entity subclass).
247         entity = generic.GenericForeignKey('entity_content_type', 'entity_object_id')
248         
249         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)
250         value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
251         
252         #: :class:`GenericForeignKey` to an instance of a subclass of :class:`AttributeValue` as determined by the :data:`attribute_value_limiter`.
253         value = generic.GenericForeignKey('value_content_type', 'value_object_id')
254         
255         #: :class:`CharField` containing a key (up to 255 characters) consisting of alphanumeric characters and underscores.
256         key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
257         
258         def __unicode__(self):
259                 return u'"%s": %s' % (self.key, self.value)
260         
261         def set_value(self, value, value_class=JSONValue):
262                 """Given a value and a value class, sets up self.value appropriately."""
263                 if isinstance(self.value, value_class):
264                         val = self.value
265                 else:
266                         if isinstance(self.value, models.Model):
267                                 self.value.delete()
268                         val = value_class()
269                 
270                 val.set_value(value)
271                 val.save()
272                 
273                 self.value = val
274                 self.save()
275         
276         class Meta:
277                 app_label = 'philo'
278                 unique_together = (('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))
279
280
281 class EntityOptions(object):
282         def __init__(self, options):
283                 if options is not None:
284                         for key, value in options.__dict__.items():
285                                 setattr(self, key, value)
286                 if not hasattr(self, 'proxy_fields'):
287                         self.proxy_fields = []
288         
289         def add_proxy_field(self, proxy_field):
290                 self.proxy_fields.append(proxy_field)
291
292
293 class EntityBase(models.base.ModelBase):
294         def __new__(cls, name, bases, attrs):
295                 entity_meta = attrs.pop('EntityMeta', None)
296                 new = super(EntityBase, cls).__new__(cls, name, bases, attrs)
297                 new.add_to_class('_entity_meta', EntityOptions(entity_meta))
298                 entity_class_prepared.send(sender=new)
299                 return new
300
301
302 class Entity(models.Model):
303         """An abstract class that simplifies access to related attributes. Most models provided by Philo subclass Entity."""
304         __metaclass__ = EntityBase
305         
306         attribute_set = generic.GenericRelation(Attribute, content_type_field='entity_content_type', object_id_field='entity_object_id')
307         
308         def get_attribute_mapper(self, mapper=AttributeMapper):
309                 """
310                 Returns an :class:`.AttributeMapper` which can be used to retrieve related :class:`Attribute`\ s' values directly.
311
312                 Example::
313
314                         >>> attr = entity.attribute_set.get(key='spam')
315                         >>> attr.value.value
316                         u'eggs'
317                         >>> entity.attributes['spam']
318                         u'eggs'
319                 
320                 """
321                 return mapper(self)
322         
323         @property
324         def attributes(self):
325                 if not hasattr(self, '_attributes'):
326                         self._attributes = self.get_attribute_mapper()
327                 return self._attributes
328         
329         class Meta:
330                 abstract = True
331
332
333 class TreeEntityBase(MPTTModelBase, EntityBase):
334         def __new__(meta, name, bases, attrs):
335                 attrs['_mptt_meta'] = MPTTOptions(attrs.pop('MPTTMeta', None))
336                 cls = EntityBase.__new__(meta, name, bases, attrs)
337                 
338                 return meta.register(cls)
339
340
341 class TreeEntityManager(models.Manager):
342         use_for_related_fields = True
343         
344         def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='pk'):
345                 """
346                 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).
347                 
348                 .. 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.
349                 
350                 .. 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.
351                 
352                 :param path: The path of the object
353                 :param root: The object which will be considered the root of the search
354                 :param absolute_result: Whether to return an absolute result or do a binary search
355                 :param pathsep: The path separator used in ``path``
356                 :param field: The field on the model which should be queried for ``path`` segment matching.
357                 :returns: An instance if ``absolute_result`` is ``True`` or an (instance, remaining_path) tuple otherwise.
358                 :raises django.core.exceptions.ObjectDoesNotExist: if no object can be found matching the input parameters.
359                 
360                 """
361                 
362                 segments = path.split(pathsep)
363                 
364                 # Clean out blank segments. Handles multiple consecutive pathseps.
365                 while True:
366                         try:
367                                 segments.remove('')
368                         except ValueError:
369                                 break
370                 
371                 # Special-case a lack of segments. No queries necessary.
372                 if not segments:
373                         if root is not None:
374                                 if absolute_result:
375                                         return root
376                                 return root, None
377                         else:
378                                 raise self.model.DoesNotExist('%s matching query does not exist.' % self.model._meta.object_name)
379                 
380                 def make_query_kwargs(segments, root):
381                         kwargs = {}
382                         prefix = ""
383                         revsegs = list(segments)
384                         revsegs.reverse()
385                         
386                         for segment in revsegs:
387                                 kwargs["%s%s__exact" % (prefix, field)] = segment
388                                 prefix += "parent__"
389                         
390                         if prefix:
391                                 kwargs[prefix[:-2]] = root
392                         
393                         return kwargs
394                 
395                 def find_obj(segments, depth, deepest_found=None):
396                         if deepest_found is None:
397                                 deepest_level = 0
398                         elif root is None:
399                                 deepest_level = deepest_found.get_level() + 1
400                         else:
401                                 deepest_level = deepest_found.get_level() - root.get_level()
402                         try:
403                                 obj = self.get(**make_query_kwargs(segments[deepest_level:depth], deepest_found or root))
404                         except self.model.DoesNotExist:
405                                 if not deepest_level and depth > 1:
406                                         # make sure there's a root node...
407                                         depth = 1
408                                 else:
409                                         # Try finding one with half the path since the deepest find.
410                                         depth = (deepest_level + depth)/2
411                                 
412                                 if deepest_level == depth:
413                                         # This should happen if nothing is found with any part of the given path.
414                                         if root is not None and deepest_found is None:
415                                                 return root, pathsep.join(segments)
416                                         raise
417                                 
418                                 return find_obj(segments, depth, deepest_found)
419                         else:
420                                 # Yay! Found one!
421                                 if root is None:
422                                         deepest_level = obj.get_level() + 1
423                                 else:
424                                         deepest_level = obj.get_level() - root.get_level()
425                                 
426                                 # Could there be a deeper one?
427                                 if obj.is_leaf_node():
428                                         return obj, pathsep.join(segments[deepest_level:]) or None
429                                 
430                                 depth += (len(segments) - depth)/2 or len(segments) - depth
431                                 
432                                 if depth > deepest_level + obj.get_descendant_count():
433                                         depth = deepest_level + obj.get_descendant_count()
434                                 
435                                 if deepest_level == depth:
436                                         return obj, pathsep.join(segments[deepest_level:]) or None
437                                 
438                                 try:
439                                         return find_obj(segments, depth, obj)
440                                 except self.model.DoesNotExist:
441                                         # Then this was the deepest.
442                                         return obj, pathsep.join(segments[deepest_level:])
443                 
444                 if absolute_result:
445                         return self.get(**make_query_kwargs(segments, root))
446                 
447                 # Try a modified binary search algorithm. Feed the root in so that query complexity
448                 # can be reduced. It might be possible to weight the search towards the beginning
449                 # of the path, since short paths are more likely, but how far forward? It would
450                 # need to shift depending on len(segments) - perhaps logarithmically?
451                 return find_obj(segments, len(segments)/2 or len(segments))
452
453
454 class TreeEntity(Entity, MPTTModel):
455         """An abstract subclass of Entity which represents a tree relationship."""
456         
457         __metaclass__ = TreeEntityBase
458         objects = TreeEntityManager()
459         parent = models.ForeignKey('self', related_name='children', null=True, blank=True)
460         
461         def get_path(self, root=None, pathsep='/', field='pk', memoize=True):
462                 """
463                 :param root: Only return the path since this object.
464                 :param pathsep: The path separator to use when constructing an instance's path
465                 :param field: The field to pull path information from for each ancestor.
466                 :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``.
467                 :returns: A string representation of an object's path.
468                 
469                 """
470                 
471                 if root == self:
472                         return ''
473                 
474                 if root is None and self.is_root_node():
475                         return getattr(self, field, '?')
476                 
477                 if root is not None and not self.is_descendant_of(root):
478                         raise AncestorDoesNotExist(root)
479                 
480                 if memoize:
481                         memo_args = (getattr(self, "%s_id" % self._mptt_meta.parent_attr), getattr(root, 'pk', None), pathsep, getattr(self, field, '?'))
482                         try:
483                                 return self._path_memo[memo_args]
484                         except AttributeError:
485                                 self._path_memo = {}
486                         except KeyError:
487                                 pass
488                 
489                 qs = self.get_ancestors(include_self=True)
490                 
491                 if root is not None:
492                         qs = qs.filter(**{'%s__gt' % self._mptt_meta.level_attr: root.get_level()})
493                 
494                 path = pathsep.join([getattr(parent, field, '?') for parent in qs])
495                 
496                 if memoize:
497                         self._path_memo[memo_args] = path
498                 
499                 return path
500         path = property(get_path)
501         
502         def get_attribute_mapper(self, mapper=None):
503                 """
504                 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.
505
506                 Example::
507
508                         >>> attr = entity.attribute_set.get(key='spam')
509                         DoesNotExist: Attribute matching query does not exist.
510                         >>> attr = entity.parent.attribute_set.get(key='spam')
511                         >>> attr.value.value
512                         u'eggs'
513                         >>> entity.attributes['spam']
514                         u'eggs'
515                 
516                 """
517                 if mapper is None:
518                         if self.parent:
519                                 mapper = TreeAttributeMapper
520                         else:
521                                 mapper = AttributeMapper
522                 return super(TreeEntity, self).get_attribute_mapper(mapper)
523         
524         def __unicode__(self):
525                 return self.path
526         
527         class Meta:
528                 abstract = True
529
530
531 class SlugTreeEntityManager(TreeEntityManager):
532         def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'):
533                 return super(SlugTreeEntityManager, self).get_with_path(path, root, absolute_result, pathsep, field)
534
535
536 class SlugTreeEntity(TreeEntity):
537         objects = SlugTreeEntityManager()
538         slug = models.SlugField(max_length=255)
539         
540         def get_path(self, root=None, pathsep='/', field='slug', memoize=True):
541                 return super(SlugTreeEntity, self).get_path(root, pathsep, field, memoize)
542         path = property(get_path)
543         
544         def clean(self):
545                 if self.parent is None:
546                         try:
547                                 self._default_manager.exclude(pk=self.pk).get(slug=self.slug, parent__isnull=True)
548                         except self.DoesNotExist:
549                                 pass
550                         else:
551                                 raise ValidationError(self.unique_error_message(self.__class__, ('parent', 'slug')))
552         
553         class Meta:
554                 unique_together = ('parent', 'slug')
555                 abstract = True