Added basic docs for entities and attributes.
[philo.git] / models / base.py
1 from django import forms
2 from django.db import models
3 from django.contrib.contenttypes.models import ContentType
4 from django.contrib.contenttypes import generic
5 from django.core.exceptions import ObjectDoesNotExist
6 from django.core.validators import RegexValidator
7 from django.utils import simplejson as json
8 from django.utils.encoding import force_unicode
9 from philo.exceptions import AncestorDoesNotExist
10 from philo.models.fields import JSONField
11 from philo.utils import ContentTypeRegistryLimiter, ContentTypeSubclassLimiter
12 from philo.signals import entity_class_prepared
13 from philo.validators import json_validator
14 from UserDict import DictMixin
15 from mptt.models import MPTTModel, MPTTModelBase, MPTTOptions
16
17
18 class Tag(models.Model):
19         name = models.CharField(max_length=255)
20         slug = models.SlugField(max_length=255, unique=True)
21         
22         def __unicode__(self):
23                 return self.name
24         
25         class Meta:
26                 app_label = 'philo'
27                 ordering = ('name',)
28
29
30 class Titled(models.Model):
31         title = models.CharField(max_length=255)
32         slug = models.SlugField(max_length=255)
33         
34         def __unicode__(self):
35                 return self.title
36         
37         class Meta:
38                 abstract = True
39
40
41 value_content_type_limiter = ContentTypeRegistryLimiter()
42
43
44 def register_value_model(model):
45         value_content_type_limiter.register_class(model)
46
47
48 register_value_model(Tag)
49
50
51 def unregister_value_model(model):
52         value_content_type_limiter.unregister_class(model)
53
54
55 class AttributeValue(models.Model):
56         attribute_set = generic.GenericRelation('Attribute', content_type_field='value_content_type', object_id_field='value_object_id')
57         
58         @property
59         def attribute(self):
60                 return self.attribute_set.all()[0]
61         
62         def set_value(self, value):
63                 raise NotImplementedError
64         
65         def value_formfields(self, **kwargs):
66                 """Define any formfields that would be used to construct an instance of this value."""
67                 raise NotImplementedError
68         
69         def construct_instance(self, **kwargs):
70                 """Apply cleaned data from the formfields generated by valid_formfields to oneself."""
71                 raise NotImplementedError
72         
73         def __unicode__(self):
74                 return unicode(self.value)
75         
76         class Meta:
77                 abstract = True
78
79
80 attribute_value_limiter = ContentTypeSubclassLimiter(AttributeValue)
81
82
83 class JSONValue(AttributeValue):
84         value = JSONField(verbose_name='Value (JSON)', help_text='This value must be valid JSON.', default='null', db_index=True)
85         
86         def __unicode__(self):
87                 return force_unicode(self.value)
88         
89         def value_formfields(self):
90                 kwargs = {'initial': self.value_json}
91                 field = self._meta.get_field('value')
92                 return {field.name: field.formfield(**kwargs)}
93         
94         def construct_instance(self, **kwargs):
95                 field_name = self._meta.get_field('value').name
96                 self.set_value(kwargs.pop(field_name, None))
97         
98         def set_value(self, value):
99                 self.value = value
100         
101         class Meta:
102                 app_label = 'philo'
103
104
105 class ForeignKeyValue(AttributeValue):
106         content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
107         object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
108         value = generic.GenericForeignKey()
109         
110         def value_formfields(self):
111                 field = self._meta.get_field('content_type')
112                 fields = {field.name: field.formfield(initial=getattr(self.content_type, 'pk', None))}
113                 
114                 if self.content_type:
115                         kwargs = {
116                                 'initial': self.object_id,
117                                 'required': False,
118                                 'queryset': self.content_type.model_class()._default_manager.all()
119                         }
120                         fields['value'] = forms.ModelChoiceField(**kwargs)
121                 return fields
122         
123         def construct_instance(self, **kwargs):
124                 field_name = self._meta.get_field('content_type').name
125                 ct = kwargs.pop(field_name, None)
126                 if ct is None or ct != self.content_type:
127                         self.object_id = None
128                         self.content_type = ct
129                 else:
130                         value = kwargs.pop('value', None)
131                         self.set_value(value)
132                         if value is None:
133                                 self.content_type = ct
134         
135         def set_value(self, value):
136                 self.value = value
137         
138         class Meta:
139                 app_label = 'philo'
140
141
142 class ManyToManyValue(AttributeValue):
143         content_type = models.ForeignKey(ContentType, limit_choices_to=value_content_type_limiter, verbose_name='Value type', null=True, blank=True)
144         values = models.ManyToManyField(ForeignKeyValue, blank=True, null=True)
145         
146         def get_object_ids(self):
147                 return self.values.values_list('object_id', flat=True)
148         object_ids = property(get_object_ids)
149         
150         def set_value(self, value):
151                 # Value must be a queryset. Watch out for ModelMultipleChoiceField;
152                 # it returns its value as a list if empty.
153                 
154                 self.content_type = ContentType.objects.get_for_model(value.model)
155                 
156                 # Before we can fiddle with the many-to-many to foreignkeyvalues, we need
157                 # a pk.
158                 if self.pk is None:
159                         self.save()
160                 
161                 object_ids = value.values_list('id', flat=True)
162                 
163                 # These lines shouldn't be necessary; however, if object_ids is an EmptyQuerySet,
164                 # the code (specifically the object_id__in query) won't work without them. Unclear why...
165                 # TODO: is this still the case?
166                 if not object_ids:
167                         self.values.all().delete()
168                 else:
169                         self.values.exclude(object_id__in=object_ids, content_type=self.content_type).delete()
170                         
171                         current_ids = self.object_ids
172                         
173                         for object_id in object_ids:
174                                 if object_id in current_ids:
175                                         continue
176                                 self.values.create(content_type=self.content_type, object_id=object_id)
177         
178         def get_value(self):
179                 if self.content_type is None:
180                         return None
181                 
182                 # HACK to be safely explicit until http://code.djangoproject.com/ticket/15145 is resolved
183                 object_ids = self.object_ids
184                 manager = self.content_type.model_class()._default_manager
185                 if not object_ids:
186                         return manager.none()
187                 return manager.filter(id__in=self.object_ids)
188         
189         value = property(get_value, set_value)
190         
191         def value_formfields(self):
192                 field = self._meta.get_field('content_type')
193                 fields = {field.name: field.formfield(initial=getattr(self.content_type, 'pk', None))}
194                 
195                 if self.content_type:
196                         kwargs = {
197                                 'initial': self.object_ids,
198                                 'required': False,
199                                 'queryset': self.content_type.model_class()._default_manager.all()
200                         }
201                         fields['value'] = forms.ModelMultipleChoiceField(**kwargs)
202                 return fields
203         
204         def construct_instance(self, **kwargs):
205                 field_name = self._meta.get_field('content_type').name
206                 ct = kwargs.pop(field_name, None)
207                 if ct is None or ct != self.content_type:
208                         self.values.clear()
209                         self.content_type = ct
210                 else:
211                         value = kwargs.get('value', None)
212                         if not value:
213                                 value = self.content_type.model_class()._default_manager.none()
214                         self.set_value(value)
215         construct_instance.alters_data = True
216         
217         class Meta:
218                 app_label = 'philo'
219
220
221 class Attribute(models.Model):
222         entity_content_type = models.ForeignKey(ContentType, related_name='attribute_entity_set', verbose_name='Entity type')
223         entity_object_id = models.PositiveIntegerField(verbose_name='Entity ID', db_index=True)
224         entity = generic.GenericForeignKey('entity_content_type', 'entity_object_id')
225         
226         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)
227         value_object_id = models.PositiveIntegerField(verbose_name='Value ID', null=True, blank=True, db_index=True)
228         value = generic.GenericForeignKey('value_content_type', 'value_object_id')
229         
230         key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
231         
232         def __unicode__(self):
233                 return u'"%s": %s' % (self.key, self.value)
234         
235         class Meta:
236                 app_label = 'philo'
237                 unique_together = (('key', 'entity_content_type', 'entity_object_id'), ('value_content_type', 'value_object_id'))
238
239
240 class QuerySetMapper(object, DictMixin):
241         def __init__(self, queryset, passthrough=None):
242                 self.queryset = queryset
243                 self.passthrough = passthrough
244         
245         def __getitem__(self, key):
246                 try:
247                         value = self.queryset.get(key__exact=key).value
248                 except ObjectDoesNotExist:
249                         if self.passthrough is not None:
250                                 return self.passthrough.__getitem__(key)
251                         raise KeyError
252                 else:
253                         if value is not None:
254                                 return value.value
255                         return value
256         
257         def keys(self):
258                 keys = set(self.queryset.values_list('key', flat=True).distinct())
259                 if self.passthrough is not None:
260                         keys |= set(self.passthrough.keys())
261                 return list(keys)
262
263
264 class EntityOptions(object):
265         def __init__(self, options):
266                 if options is not None:
267                         for key, value in options.__dict__.items():
268                                 setattr(self, key, value)
269                 if not hasattr(self, 'proxy_fields'):
270                         self.proxy_fields = []
271         
272         def add_proxy_field(self, proxy_field):
273                 self.proxy_fields.append(proxy_field)
274
275
276 class EntityBase(models.base.ModelBase):
277         def __new__(cls, name, bases, attrs):
278                 new = super(EntityBase, cls).__new__(cls, name, bases, attrs)
279                 entity_options = attrs.pop('EntityMeta', None)
280                 setattr(new, '_entity_meta', EntityOptions(entity_options))
281                 entity_class_prepared.send(sender=new)
282                 return new
283
284
285 class Entity(models.Model):
286         __metaclass__ = EntityBase
287         
288         attribute_set = generic.GenericRelation(Attribute, content_type_field='entity_content_type', object_id_field='entity_object_id')
289         
290         @property
291         def attributes(self):
292                 return QuerySetMapper(self.attribute_set.all())
293         
294         class Meta:
295                 abstract = True
296
297
298 class TreeManager(models.Manager):
299         use_for_related_fields = True
300         
301         def get_with_path(self, path, root=None, absolute_result=True, pathsep='/', field='slug'):
302                 """
303                 Returns the object with the path, unless absolute_result is set to False, in which
304                 case it returns a tuple containing the deepest object found along the path, and the
305                 remainder of the path after that object as a string (or None if there is no remaining
306                 path). Raises a DoesNotExist exception if no object is found with the given path.
307                 
308                 If the path you're searching for is known to exist, it is always faster to use
309                 absolute_result=True - unless the path depth is over ~40, in which case the high cost
310                 of the absolute query makes a binary search (i.e. non-absolute) faster.
311                 """
312                 # Note: SQLite allows max of 64 tables in one join. That means the binary search will
313                 # only work on paths with a max depth of 127 and the absolute fetch will only work
314                 # to a max depth of (surprise!) 63. Although this could be handled, chances are your
315                 # tree structure won't be that deep.
316                 segments = path.split(pathsep)
317                 
318                 # Clean out blank segments. Handles multiple consecutive pathseps.
319                 while True:
320                         try:
321                                 segments.remove('')
322                         except ValueError:
323                                 break
324                 
325                 # Special-case a lack of segments. No queries necessary.
326                 if not segments:
327                         if root is not None:
328                                 if absolute_result:
329                                         return root
330                                 return root, None
331                         else:
332                                 raise self.model.DoesNotExist('%s matching query does not exist.' % self.model._meta.object_name)
333                 
334                 def make_query_kwargs(segments, root):
335                         kwargs = {}
336                         prefix = ""
337                         revsegs = list(segments)
338                         revsegs.reverse()
339                         
340                         for segment in revsegs:
341                                 kwargs["%s%s__exact" % (prefix, field)] = segment
342                                 prefix += "parent__"
343                         
344                         if prefix:
345                                 kwargs[prefix[:-2]] = root
346                         
347                         return kwargs
348                 
349                 def find_obj(segments, depth, deepest_found=None):
350                         if deepest_found is None:
351                                 deepest_level = 0
352                         elif root is None:
353                                 deepest_level = deepest_found.get_level() + 1
354                         else:
355                                 deepest_level = deepest_found.get_level() - root.get_level()
356                         try:
357                                 obj = self.get(**make_query_kwargs(segments[deepest_level:depth], deepest_found or root))
358                         except self.model.DoesNotExist:
359                                 if not deepest_level and depth > 1:
360                                         # make sure there's a root node...
361                                         depth = 1
362                                 else:
363                                         # Try finding one with half the path since the deepest find.
364                                         depth = (deepest_level + depth)/2
365                                 
366                                 if deepest_level == depth:
367                                         # This should happen if nothing is found with any part of the given path.
368                                         if root is not None and deepest_found is None:
369                                                 return root, pathsep.join(segments)
370                                         raise
371                                 
372                                 return find_obj(segments, depth, deepest_found)
373                         else:
374                                 # Yay! Found one!
375                                 if root is None:
376                                         deepest_level = obj.get_level() + 1
377                                 else:
378                                         deepest_level = obj.get_level() - root.get_level()
379                                 
380                                 # Could there be a deeper one?
381                                 if obj.is_leaf_node():
382                                         return obj, pathsep.join(segments[deepest_level:]) or None
383                                 
384                                 depth += (len(segments) - depth)/2 or len(segments) - depth
385                                 
386                                 if depth > deepest_level + obj.get_descendant_count():
387                                         depth = deepest_level + obj.get_descendant_count()
388                                 
389                                 if deepest_level == depth:
390                                         return obj, pathsep.join(segments[deepest_level:]) or None
391                                 
392                                 try:
393                                         return find_obj(segments, depth, obj)
394                                 except self.model.DoesNotExist:
395                                         # Then this was the deepest.
396                                         return obj, pathsep.join(segments[deepest_level:])
397                 
398                 if absolute_result:
399                         return self.get(**make_query_kwargs(segments, root))
400                 
401                 # Try a modified binary search algorithm. Feed the root in so that query complexity
402                 # can be reduced. It might be possible to weight the search towards the beginning
403                 # of the path, since short paths are more likely, but how far forward? It would
404                 # need to shift depending on len(segments) - perhaps logarithmically?
405                 return find_obj(segments, len(segments)/2 or len(segments))
406
407
408 class TreeModel(MPTTModel):
409         objects = TreeManager()
410         parent = models.ForeignKey('self', related_name='children', null=True, blank=True)
411         slug = models.SlugField(max_length=255)
412         
413         def get_path(self, root=None, pathsep='/', field='slug'):
414                 if root == self:
415                         return ''
416                 
417                 if root is not None and not self.is_descendant_of(root):
418                         raise AncestorDoesNotExist(root)
419                 
420                 qs = self.get_ancestors(include_self=True)
421                 
422                 if root is not None:
423                         qs = qs.filter(**{'%s__gt' % self._mptt_meta.level_attr: root.get_level()})
424                 
425                 return pathsep.join([getattr(parent, field, '?') for parent in qs])
426         path = property(get_path)
427         
428         def __unicode__(self):
429                 return self.path
430         
431         class Meta:
432                 unique_together = (('parent', 'slug'),)
433                 abstract = True
434
435
436 class TreeEntityBase(MPTTModelBase, EntityBase):
437         def __new__(meta, name, bases, attrs):
438                 attrs['_mptt_meta'] = MPTTOptions(attrs.pop('MPTTMeta', None))
439                 cls = EntityBase.__new__(meta, name, bases, attrs)
440                 
441                 return meta.register(cls)
442
443
444 class TreeEntity(Entity, TreeModel):
445         __metaclass__ = TreeEntityBase
446         
447         @property
448         def attributes(self):
449                 if self.parent:
450                         return QuerySetMapper(self.attribute_set.all(), passthrough=self.parent.attributes)
451                 return super(TreeEntity, self).attributes
452         
453         class Meta:
454                 abstract = True