Tweaked LazyNode to handle trailing slashes. Corrected missing import in models/nodes.py.
[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                 items = []
152                 
153                 for cache in caches:
154                         items += 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(shipherd_navigationitem_related__in=items).distinct())
159                 
160                 for cache in caches:
161                         for item in cache['items']:
162                                 item.target_node = targets[targets.index(item.target_node)]
163         
164         def clear_cache(self):
165                 self.__class__._cache.pop(self.db, None)
166
167
168 class Navigation(Entity):
169         objects = NavigationManager()
170         
171         node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
172         key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.")
173         depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
174         
175         def __init__(self, *args, **kwargs):
176                 super(Navigation, self).__init__(*args, **kwargs)
177                 self._initial_data = model_to_dict(self)
178         
179         def __unicode__(self):
180                 return "%s[%s]" % (self.node, self.key)
181         
182         def _has_changed(self):
183                 return self._initial_data != model_to_dict(self)
184         
185         def save(self, *args, **kwargs):
186                 super(Navigation, self).save(*args, **kwargs)
187                 
188                 if self._has_changed():
189                         Navigation.objects.clear_cache_for(self.node)
190                         self._initial_data = model_to_dict(self)
191         
192         def delete(self, *args, **kwargs):
193                 super(Navigation, self).delete(*args, **kwargs)
194                 Navigation.objects.clear_cache_for(self.node)
195         
196         class Meta:
197                 unique_together = ('node', 'key')
198
199
200 class NavigationItemManager(TreeManager):
201         use_for_related = True
202         
203         def get_queryset(self):
204                 return NavigationCacheQuerySet(self.model, using=self._db)
205
206
207 class NavigationItem(TreeEntity, TargetURLModel):
208         objects = NavigationItemManager()
209         
210         navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
211         text = models.CharField(max_length=50)
212         
213         order = models.PositiveSmallIntegerField(default=0)
214         
215         def __init__(self, *args, **kwargs):
216                 super(NavigationItem, self).__init__(*args, **kwargs)
217                 self._initial_data = model_to_dict(self)
218                 self._is_cached = False
219         
220         def __unicode__(self):
221                 return self.get_path(field='text', pathsep=u' › ')
222         
223         def clean(self):
224                 super(NavigationItem, self).clean()
225                 if bool(self.parent) == bool(self.navigation):
226                         raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
227         
228         def is_active(self, request):
229                 if self.target_url == request.path:
230                         # Handle the `default` case where the target_url and requested path
231                         # are identical.
232                         return True
233                 
234                 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):
235                         # If there's no target_node, double-check whether it's a full-url
236                         # match.
237                         return True
238                 
239                 if self.target_node and not self.url_or_subpath:
240                         # If there is a target node and it's targeted simply, but the target URL is not
241                         # the same as the request path, check whether the target node is an ancestor
242                         # of the requested node. If so, this is active unless the target node
243                         # is the same as the ``host node`` for this navigation structure.
244                         try:
245                                 host_node = self.get_root().navigation.node
246                         except AttributeError:
247                                 pass
248                         else:
249                                 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
250                                         return True
251                 
252                 return False
253         
254         def has_active_descendants(self, request):
255                 for child in self.get_children():
256                         if child.is_active(request) or child.has_active_descendants(request):
257                                 return True
258                 return False
259         
260         def _has_changed(self):
261                 if model_to_dict(self) == self._initial_data:
262                         return False
263                 return True
264         
265         def _clear_cache(self):
266                 try:
267                         root = self.get_root()
268                         if self.get_level() < root.navigation.depth:
269                                 Navigation.objects.clear_cache_for(self.get_root().navigation.node)
270                 except AttributeError:
271                         pass
272         
273         def save(self, *args, **kwargs):
274                 super(NavigationItem, self).save(*args, **kwargs)
275                 
276                 if self._has_changed():
277                         self._clear_cache()
278         
279         def delete(self, *args, **kwargs):
280                 super(NavigationItem, self).delete(*args, **kwargs)
281                 self._clear_cache()