Added basic docs for entities and attributes.
[philo.git] / contrib / shipherd / models.py
1 #encoding: utf-8
2 from django.core.exceptions import ValidationError
3 from django.core.urlresolvers import NoReverseMatch
4 from django.core.validators import RegexValidator, MinValueValidator
5 from django.db import models
6 from django.forms.models import model_to_dict
7 from philo.models import TreeEntity, Node, TreeManager, Entity, TargetURLModel
8 from philo.validators import RedirectValidator
9 from UserDict import DictMixin
10
11
12 DEFAULT_NAVIGATION_DEPTH = 3
13
14
15 class NavigationQuerySetMapper(object, DictMixin):
16         """This class exists to prevent setting of items in the navigation cache through node.navigation."""
17         def __init__(self, node):
18                 self.node = node
19         
20         def __getitem__(self, key):
21                 return Navigation.objects.get_cache_for(self.node)[key]['root_items']
22         
23         def keys(self):
24                 return Navigation.objects.get_cache_for(self.node).keys()
25
26
27 def navigation(self):
28         if not hasattr(self, '_navigation'):
29                 self._navigation = NavigationQuerySetMapper(self)
30         return self._navigation
31
32
33 Node.navigation = property(navigation)
34
35
36 class NavigationCacheQuerySet(models.query.QuerySet):
37         """
38         This subclass will trigger general cache clearing for Navigation.objects when a mass
39         update or deletion is performed. As there is no convenient way to iterate over the
40         changed or deleted instances, there's no way to be more precise about what gets cleared.
41         """
42         def update(self, *args, **kwargs):
43                 super(NavigationCacheQuerySet, self).update(*args, **kwargs)
44                 Navigation.objects.clear_cache()
45         
46         def delete(self, *args, **kwargs):
47                 super(NavigationCacheQuerySet, self).delete(*args, **kwargs)
48                 Navigation.objects.clear_cache()
49
50
51 class NavigationManager(models.Manager):
52         # Since navigation is going to be hit frequently and changed
53         # relatively infrequently, cache it. Analogous to contenttypes.
54         use_for_related = True
55         _cache = {}
56         
57         def get_queryset(self):
58                 return NavigationCacheQuerySet(self.model, using=self._db)
59         
60         def get_cache_for(self, node, update_targets=True):
61                 created = False
62                 if not self.has_cache_for(node):
63                         self.create_cache_for(node)
64                         created = True
65                 
66                 if update_targets and not created:
67                         self.update_targets_for(node)
68                 
69                 return self.__class__._cache[self.db][node]
70         
71         def has_cache_for(self, node):
72                 return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
73         
74         def create_cache_for(self, node):
75                 "This method loops through the nodes ancestors and caches all unique navigation keys."
76                 ancestors = node.get_ancestors(ascending=True, include_self=True)
77                 
78                 nodes_to_cache = []
79                 
80                 for node in ancestors:
81                         if self.has_cache_for(node):
82                                 cache = self.get_cache_for(node).copy()
83                                 break
84                         else:
85                                 nodes_to_cache.insert(0, node)
86                 else:
87                         cache = {}
88                 
89                 for node in nodes_to_cache:
90                         cache = cache.copy()
91                         cache.update(self._build_cache_for(node))
92                         self.__class__._cache.setdefault(self.db, {})[node] = cache
93         
94         def _build_cache_for(self, node):
95                 cache = {}
96                 tree_id_attr = NavigationItem._mptt_meta.tree_id_attr
97                 level_attr = NavigationItem._mptt_meta.level_attr
98                 
99                 for navigation in node.navigation_set.all():
100                         tree_ids = navigation.roots.values_list(tree_id_attr)
101                         items = list(NavigationItem.objects.filter(**{'%s__in' % tree_id_attr: tree_ids, '%s__lt' % level_attr: navigation.depth}).order_by('order', 'lft'))
102                         
103                         root_items = []
104                         
105                         for item in items:
106                                 item._is_cached = True
107                                 
108                                 if not hasattr(item, '_cached_children'):
109                                         item._cached_children = []
110                                 
111                                 if item.parent:
112                                         # alternatively, if I don't want to force it to a list, I could keep track of
113                                         # instances where the parent hasn't yet been met and do this step later for them.
114                                         # delayed action.
115                                         item.parent = items[items.index(item.parent)]
116                                         if not hasattr(item.parent, '_cached_children'):
117                                                 item.parent._cached_children = []
118                                         item.parent._cached_children.append(item)
119                                 else:
120                                         root_items.append(item)
121                         
122                         cache[navigation.key] = {
123                                 'navigation': navigation,
124                                 'root_items': root_items,
125                                 'items': items
126                         }
127                 
128                 return cache
129         
130         def clear_cache_for(self, node):
131                 # Clear the cache for this node and all its descendants. The
132                 # navigation for this node has probably changed, and for now,
133                 # it isn't worth it to only clear the descendants actually
134                 # affected by this.
135                 if not self.has_cache_for(node):
136                         # Already cleared.
137                         return
138                 
139                 descendants = node.get_descendants(include_self=True)
140                 cache = self.__class__._cache[self.db]
141                 for node in descendants:
142                         cache.pop(node, None)
143         
144         def update_targets_for(self, node):
145                 # Manually update a cache's target nodes in case something's changed there.
146                 # This should be a less complex operation than reloading the models each
147                 # time. Not as good as selective updates... but not much to be done
148                 # about that. TODO: Benchmark it.
149                 caches = self.__class__._cache[self.db][node].values()
150                 
151                 target_pks = set()
152                 
153                 for cache in caches:
154                         target_pks |= set([item.target_node_id for item in cache['items']])
155                 
156                 # A distinct query is not strictly necessary. TODO: benchmark the efficiency
157                 # with/without distinct.
158                 targets = list(Node.objects.filter(pk__in=target_pks).distinct())
159                 
160                 for cache in caches:
161                         for item in cache['items']:
162                                 if item.target_node_id:
163                                         item.target_node = targets[targets.index(item.target_node)]
164         
165         def clear_cache(self):
166                 self.__class__._cache.pop(self.db, None)
167
168
169 class Navigation(Entity):
170         objects = NavigationManager()
171         
172         node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
173         key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
174         depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
175         
176         def __init__(self, *args, **kwargs):
177                 super(Navigation, self).__init__(*args, **kwargs)
178                 self._initial_data = model_to_dict(self)
179         
180         def __unicode__(self):
181                 return "%s[%s]" % (self.node, self.key)
182         
183         def _has_changed(self):
184                 return self._initial_data != model_to_dict(self)
185         
186         def save(self, *args, **kwargs):
187                 super(Navigation, self).save(*args, **kwargs)
188                 
189                 if self._has_changed():
190                         Navigation.objects.clear_cache_for(self.node)
191                         self._initial_data = model_to_dict(self)
192         
193         def delete(self, *args, **kwargs):
194                 super(Navigation, self).delete(*args, **kwargs)
195                 Navigation.objects.clear_cache_for(self.node)
196         
197         class Meta:
198                 unique_together = ('node', 'key')
199
200
201 class NavigationItemManager(TreeManager):
202         use_for_related = True
203         
204         def get_queryset(self):
205                 return NavigationCacheQuerySet(self.model, using=self._db)
206
207
208 class NavigationItem(TreeEntity, TargetURLModel):
209         objects = NavigationItemManager()
210         
211         navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
212         text = models.CharField(max_length=50)
213         
214         order = models.PositiveSmallIntegerField(default=0)
215         
216         def __init__(self, *args, **kwargs):
217                 super(NavigationItem, self).__init__(*args, **kwargs)
218                 self._initial_data = model_to_dict(self)
219                 self._is_cached = False
220         
221         def __unicode__(self):
222                 return self.get_path(field='text', pathsep=u' › ')
223         
224         def clean(self):
225                 super(NavigationItem, self).clean()
226                 if bool(self.parent) == bool(self.navigation):
227                         raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
228         
229         def is_active(self, request):
230                 if self.target_url == request.path:
231                         # Handle the `default` case where the target_url and requested path
232                         # are identical.
233                         return True
234                 
235                 if self.target_node is None and self.url_or_subpath == "http%s://%s%s" % (request.is_secure() and 's' or '', request.get_host(), request.path):
236                         # If there's no target_node, double-check whether it's a full-url
237                         # match.
238                         return True
239                 
240                 if self.target_node and not self.url_or_subpath:
241                         # If there is a target node and it's targeted simply, but the target URL is not
242                         # the same as the request path, check whether the target node is an ancestor
243                         # of the requested node. If so, this is active unless the target node
244                         # is the same as the ``host node`` for this navigation structure.
245                         try:
246                                 host_node = self.get_root().navigation.node
247                         except AttributeError:
248                                 pass
249                         else:
250                                 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
251                                         return True
252                 
253                 return False
254         
255         def has_active_descendants(self, request):
256                 for child in self.get_children():
257                         if child.is_active(request) or child.has_active_descendants(request):
258                                 return True
259                 return False
260         
261         def _has_changed(self):
262                 if model_to_dict(self) == self._initial_data:
263                         return False
264                 return True
265         
266         def _clear_cache(self):
267                 try:
268                         root = self.get_root()
269                         if self.get_level() < root.navigation.depth:
270                                 Navigation.objects.clear_cache_for(self.get_root().navigation.node)
271                 except AttributeError:
272                         pass
273         
274         def save(self, *args, **kwargs):
275                 super(NavigationItem, self).save(*args, **kwargs)
276                 
277                 if self._has_changed():
278                         self._clear_cache()
279         
280         def delete(self, *args, **kwargs):
281                 super(NavigationItem, self).delete(*args, **kwargs)
282                 self._clear_cache()