Added SlugTreeEntity to models.base.__all__.
[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         attributes = property(get_attribute_mapper)
323         
324         class Meta:
325                 abstract = True
326
327
328 class TreeEntityBase(MPTTModelBase, EntityBase):
329         def __new__(meta, name, bases, attrs):
330                 attrs['_mptt_meta'] = MPTTOptions(attrs.pop('MPTTMeta', None))
331                 cls = EntityBase.__new__(meta, name, bases, attrs)
332                 
333                 return meta.register(cls)
334
335
336 class TreeEntityManager(models.Manager):
337         use_for_related_fields = True
338         
339         def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='pk'):
340                 """
341                 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).
342                 
343                 .. 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.
344                 
345                 .. 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.
346                 
347                 :param path: The path of the object
348                 :param root: The object which will be considered the root of the search
349                 :param absolute_result: Whether to return an absolute result or do a binary search
350                 :param pathsep: The path separator used in ``path``
351                 :param field: The field on the model which should be queried for ``path`` segment matching.
352                 :returns: An instance if ``absolute_result`` is ``True`` or an (instance, remaining_path) tuple otherwise.
353                 :raises django.core.exceptions.ObjectDoesNotExist: if no object can be found matching the input parameters.
354                 
355                 """
356                 
357                 segments = path.split(pathsep)
358                 
359                 # Clean out blank segments. Handles multiple consecutive pathseps.
360                 while True:
361                         try:
362                                 segments.remove('')
363                         except ValueError:
364                                 break
365                 
366                 # Special-case a lack of segments. No queries necessary.
367                 if not segments:
368                         if root is not None:
369                                 if absolute_result:
370                                         return root
371                                 return root, None
372                         else:
373                                 raise self.model.DoesNotExist('%s matching query does not exist.' % self.model._meta.object_name)
374                 
375                 def make_query_kwargs(segments, root):
376                         kwargs = {}
377                         prefix = ""
378                         revsegs = list(segments)
379                         revsegs.reverse()
380                         
381                         for segment in revsegs:
382                                 kwargs["%s%s__exact" % (prefix, field)] = segment
383                                 prefix += "parent__"
384                         
385                         if prefix:
386                                 kwargs[prefix[:-2]] = root
387                         
388                         return kwargs
389                 
390                 def find_obj(segments, depth, deepest_found=None):
391                         if deepest_found is None:
392                                 deepest_level = 0
393                         elif root is None:
394                                 deepest_level = deepest_found.get_level() + 1
395                         else:
396                                 deepest_level = deepest_found.get_level() - root.get_level()
397                         try:
398                                 obj = self.get(**make_query_kwargs(segments[deepest_level:depth], deepest_found or root))
399                         except self.model.DoesNotExist:
400                                 if not deepest_level and depth > 1:
401                                         # make sure there's a root node...
402                                         depth = 1
403                                 else:
404                                         # Try finding one with half the path since the deepest find.
405                                         depth = (deepest_level + depth)/2
406                                 
407                                 if deepest_level == depth:
408                                         # This should happen if nothing is found with any part of the given path.
409                                         if root is not None and deepest_found is None:
410                                                 return root, pathsep.join(segments)
411                                         raise
412                                 
413                                 return find_obj(segments, depth, deepest_found)
414                         else:
415                                 # Yay! Found one!
416                                 if root is None:
417                                         deepest_level = obj.get_level() + 1
418                                 else:
419                                         deepest_level = obj.get_level() - root.get_level()
420                                 
421                                 # Could there be a deeper one?
422                                 if obj.is_leaf_node():
423                                         return obj, pathsep.join(segments[deepest_level:]) or None
424                                 
425                                 depth += (len(segments) - depth)/2 or len(segments) - depth
426                                 
427                                 if depth > deepest_level + obj.get_descendant_count():
428                                         depth = deepest_level + obj.get_descendant_count()
429                                 
430                                 if deepest_level == depth:
431                                         return obj, pathsep.join(segments[deepest_level:]) or None
432                                 
433                                 try:
434                                         return find_obj(segments, depth, obj)
435                                 except self.model.DoesNotExist:
436                                         # Then this was the deepest.
437                                         return obj, pathsep.join(segments[deepest_level:])
438                 
439                 if absolute_result:
440                         return self.get(**make_query_kwargs(segments, root))
441                 
442                 # Try a modified binary search algorithm. Feed the root in so that query complexity
443                 # can be reduced. It might be possible to weight the search towards the beginning
444                 # of the path, since short paths are more likely, but how far forward? It would
445                 # need to shift depending on len(segments) - perhaps logarithmically?
446                 return find_obj(segments, len(segments)/2 or len(segments))
447
448
449 class TreeEntity(Entity, MPTTModel):
450         """An abstract subclass of Entity which represents a tree relationship."""
451         
452         __metaclass__ = TreeEntityBase
453         objects = TreeEntityManager()
454         parent = models.ForeignKey('self', related_name='children', null=True, blank=True)
455         
456         def get_path(self, root=None, pathsep='/', field='slug'):
457                 """
458                 :param root: Only return the path since this object.
459                 :param pathsep: The path separator to use when constructing an instance's path
460                 :param field: The field to pull path information from for each ancestor.
461                 :returns: A string representation of an object's path.
462                 
463                 """
464                 
465                 if root == self:
466                         return ''
467                 
468                 if root is None and self.is_root_node():
469                         return getattr(self, field, '?')
470                 
471                 if root is not None and not self.is_descendant_of(root):
472                         raise AncestorDoesNotExist(root)
473                 
474                 qs = self.get_ancestors(include_self=True)
475                 
476                 if root is not None:
477                         qs = qs.filter(**{'%s__gt' % self._mptt_meta.level_attr: root.get_level()})
478                 
479                 return pathsep.join([getattr(parent, field, '?') for parent in qs])
480         path = property(get_path)
481         
482         def get_attribute_mapper(self, mapper=None):
483                 """
484                 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.
485
486                 Example::
487
488                         >>> attr = entity.attribute_set.get(key='spam')
489                         DoesNotExist: Attribute matching query does not exist.
490                         >>> attr = entity.parent.attribute_set.get(key='spam')
491                         >>> attr.value.value
492                         u'eggs'
493                         >>> entity.attributes['spam']
494                         u'eggs'
495                 
496                 """
497                 if mapper is None:
498                         if self.parent:
499                                 mapper = TreeAttributeMapper
500                         else:
501                                 mapper = AttributeMapper
502                 return super(TreeEntity, self).get_attribute_mapper(mapper)
503         attributes = property(get_attribute_mapper)
504         
505         def __unicode__(self):
506                 return self.path
507         
508         class Meta:
509                 abstract = True
510
511
512 class SlugTreeEntityManager(TreeEntityManager):
513         def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'):
514                 return super(SlugTreeEntityManager, self).get_with_path(path, root, absolute_result, pathsep, field)
515
516
517 class SlugTreeEntity(TreeEntity):
518         objects = SlugTreeEntityManager()
519         slug = models.SlugField(max_length=255)
520         
521         def get_path(self, root=None, pathsep='/', field='slug'):
522                 return super(SlugTreeEntity, self).get_path(root, pathsep, field)
523         path = property(get_path)
524         
525         def clean(self):
526                 if self.parent is None:
527                         try:
528                                 self._default_manager.exclude(pk=self.pk).get(slug=self.slug, parent__isnull=True)
529                         except self.DoesNotExist:
530                                 pass
531                         else:
532                                 raise ValidationError(self.unique_error_message(self.__class__, ('parent', 'slug')))
533         
534         class Meta:
535                 unique_together = ('parent', 'slug')
536                 abstract = True