Shifted NavigationManager caching to use node instances rather than node pks. Improve...
[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.db import models
5 from django.forms.models import model_to_dict
6 from philo.models import TreeEntity, JSONField, Node, TreeManager
7 from philo.validators import RedirectValidator
8
9
10 DEFAULT_NAVIGATION_DEPTH = 3
11
12
13 class NavigationQuerySet(models.query.QuerySet):
14         """
15         This subclass is necessary to trigger cache clearing for Navigation when a mass update
16         or deletion is performed. For now, either action will trigger a clearing of the entire
17         navigation cache, since there's no convenient way to iterate over the changed or
18         deleted instances.
19         """
20         def update(self, *args, **kwargs):
21                 super(NavigationQuerySet, self).update(*args, **kwargs)
22                 Navigation.objects.clear_cache()
23         
24         def delete(self, *args, **kwargs):
25                 super(NavigationQuerySet, self).delete(*args, **kwargs)
26                 Navigation.objects.clear_cache()
27
28
29 class NavigationManager(TreeManager):
30         
31         # Analagous to contenttypes, cache Navigation to avoid repeated lookups all over the place.
32         # Navigation will probably be used frequently.
33         _cache = {}
34         
35         def get_queryset(self):
36                 return NavigationQuerySet(self.model, using=self._db)
37         
38         def closest_navigation(self, node):
39                 """
40                 Returns the set of Navigation objects for a given node's navigation. This
41                 will be the most recent set of defined hosted navigation among the node's
42                 ancestors. Lookups are cached so that subsequent lookups for the same node
43                 don't hit the database.
44                 """
45                 try:
46                         return self._get_cache_for(self.db, node)
47                 except KeyError:
48                         # Find the most recent host!
49                         ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation"))
50                         
51                         # Iterate down the ancestors until you find one that:
52                         # a) is cached, or
53                         # b) has hosted navigation.
54                         nodes_to_cache = []
55                         host_node = None
56                         for ancestor in ancestors:
57                                 if self.has_cache_for(ancestor) or ancestor.num_navigation > 0:
58                                         host_node = ancestor
59                                         break
60                                 else:
61                                         nodes_to_cache.append(ancestor)
62                         
63                         if not self.has_cache_for(host_node):
64                                 self._add_to_cache(self.db, host_node)
65                         
66                         # Cache the queryset instance for every node that was passed over, as well.
67                         hosted_navigation = self._get_cache_for(self.db, host_node)
68                         for node in nodes_to_cache:
69                                 self._add_to_cache(self.db, node, hosted_navigation)
70                 
71                 return hosted_navigation
72         
73         def _add_to_cache(self, using, node, qs=None):
74                 if node.pk is None:
75                         return
76                 
77                 if qs is None:
78                         roots = node.hosted_navigation.select_related('target_node')
79                         qs = self.none()
80                         
81                         for root in roots:
82                                 root_qs = root.get_descendants(include_self=True).complex_filter({'%s__lte' % root._mptt_meta.level_attr: root.get_level() + root.depth}).exclude(depth__isnull=True)
83                                 qs |= root_qs
84                 
85                 self.__class__._cache.setdefault(using, {})[node] = qs
86         
87         def has_cache(self):
88                 return self.db in self.__class__._cache and self.__class__._cache[self.db]
89         
90         def _get_cache_for(self, using, node):
91                 return self.__class__._cache[self.db][node]
92         
93         def is_cached(self, navigation):
94                 return self._is_cached(self.db, navigation)
95         
96         def _is_cached(self, using, navigation):
97                 cache = self.__class__._cache[using]
98                 for qs in cache.values():
99                         if navigation in qs:
100                                 return True
101                 return False
102         
103         def has_cache_for(self, node):
104                 return self._has_cache_for(self.db, node)
105         
106         def _has_cache_for(self, using, node):
107                 try:
108                         self._get_cache_for(using, node)
109                 except KeyError:
110                         return False
111                 return True
112         
113         def clear_cache_for(self, node):
114                 """Clear the cache for a node and all its descendants"""
115                 self._clear_cache_for(self.db, node)
116         
117         def _clear_cache_for(self, using, node):
118                 # Clear the cache for all descendants of the node. Ideally we would
119                 # only clear up to another hosting node, but the complexity is not
120                 # necessary and may not be possible.
121                 descendants = node.get_descendants(include_self=True)
122                 cache = self.__class__._cache[using]
123                 for node in descendants:
124                         cache.pop(node, None)
125         
126         def clear_cache(self, navigation=None):
127                 """
128                 Clear out the navigation cache. This needs to happen during database flushes
129                 or if a navigation entry is changed to prevent caching of outdated navigation information.
130                 """
131                 if navigation is None:
132                         self.__class__._cache.clear()
133                 elif self.db in self.__class__._cache:
134                         cache = self.__class__._cache[self.db]
135                         for node, qs in cache.items():
136                                 if navigation in qs:
137                                         cache.pop(node)
138
139
140 class Navigation(TreeEntity):
141         objects = NavigationManager()
142         text = models.CharField(max_length=50)
143         
144         hosting_node = models.ForeignKey(Node, blank=True, null=True, related_name='hosted_navigation', help_text="Be part of this node's root navigation.")
145         
146         target_node = models.ForeignKey(Node, blank=True, null=True, related_name='targeting_navigation', help_text="Point to this node's url.")
147         url_or_subpath = models.CharField(max_length=200, validators=[RedirectValidator()], blank=True, help_text="Point to this url or, if a node is defined and accepts subpaths, this subpath of the node.")
148         reversing_parameters = JSONField(blank=True, help_text="If reversing parameters are defined, url_or_subpath will instead be interpreted as the view name to be reversed.")
149         
150         order = models.PositiveSmallIntegerField(blank=True, null=True)
151         depth = models.PositiveSmallIntegerField(blank=True, null=True, default=DEFAULT_NAVIGATION_DEPTH, help_text="For the root of a hosted tree, defines the depth of the tree. A blank depth will hide this section of navigation. Otherwise, depth is ignored.")
152         
153         def __init__(self, *args, **kwargs):
154                 super(Navigation, self).__init__(*args, **kwargs)
155                 self._initial_data = model_to_dict(self)
156         
157         def __unicode__(self):
158                 return self.get_path(field='text', pathsep=u' › ')
159         
160         def clean(self):
161                 # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
162                 if not self.target_node and not self.url_or_subpath:
163                         raise ValidationError("Either a target node or a url must be defined.")
164                 
165                 if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
166                         raise ValidationError("Reversing parameters require a view name and a target node.")
167                 
168                 try:
169                         self.get_target_url()
170                 except NoReverseMatch, e:
171                         raise ValidationError(e.message)
172         
173         def get_target_url(self):
174                 node = self.target_node
175                 if node is not None and node.accepts_subpath and self.url_or_subpath:
176                         if self.reversing_parameters is not None:
177                                 view_name = self.url_or_subpath
178                                 params = self.reversing_parameters
179                                 args = isinstance(params, list) and params or None
180                                 kwargs = isinstance(params, dict) and params or None
181                                 return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
182                         else:
183                                 subpath = self.url_or_subpath
184                                 while subpath and subpath[0] == '/':
185                                         subpath = subpath[1:]
186                                 return '%s%s' % (node.get_absolute_url(), subpath)
187                 elif node is not None:
188                         return node.get_absolute_url()
189                 else:
190                         return self.url_or_subpath
191         target_url = property(get_target_url)
192         
193         def is_active(self, request):
194                 node = request.node
195                 
196                 if self.target_url == request.path:
197                         # Handle the `default` case where the target_url and requested path
198                         # are identical.
199                         return True
200                 
201                 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):
202                         # If there's no target_node, double-check whether it's a full-url
203                         # match.
204                         return True
205                 
206                 ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation")).filter(num_navigation__gt=0)
207                 if ancestors:
208                         # If the target node is an ancestor of the requested node, this is
209                         # active - unless the target node is the `home` node for this set of
210                         # navigation or this navigation points to some other url.
211                         host_node = ancestors[0]
212                         if self.target_node.is_ancestor_of(node) and self.target_node != host_node and not self.url_or_subpath:
213                                 return True
214                 
215                 # Always fall back to whether the node has active children.
216                 return self.has_active_children(request)
217         
218         def is_cached(self):
219                 """Shortcut method for Navigation.objects.is_cached"""
220                 return Navigation.objects.is_cached(self)
221         
222         def has_active_children(self, request):
223                 for child in self.get_children():
224                         if child.is_active(request):
225                                 return True
226                 return False
227         
228         def _has_changed(self):
229                 if model_to_dict(self) == self._initial_data:
230                         return False
231                 return True
232         
233         def save(self, *args, **kwargs):
234                 super(Navigation, self).save(*args, **kwargs)
235                 
236                 if self._has_changed():
237                         self._initial_data = model_to_dict(self)
238                         if Navigation.objects.has_cache():
239                                 if self.is_cached():
240                                         Navigation.objects.clear_cache(self)
241                                 else:
242                                         for navigation in self.get_ancestors():
243                                                 if navigation.hosting_node and navigation.is_cached() and self.get_level() <= (navigation.get_level() + navigation.depth):
244                                                         Navigation.objects.clear_cache(navigation)
245                                         
246                                         if self.hosting_node and Navigation.objects.has_cache_for(self.hosting_node):
247                                                 Navigation.objects.clear_cache_for(self.hosting_node)
248         
249         def delete(self, *args, **kwargs):
250                 super(Navigation, self).delete(*args, **kwargs)
251                 Navigation.objects.clear_cache(self)
252         
253         class Meta:
254                 # Should I even try ordering?
255                 ordering = ['order', 'lft']
256                 verbose_name_plural = 'navigation'