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
10 DEFAULT_NAVIGATION_DEPTH = 3
13 class NavigationQuerySet(models.query.QuerySet):
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
20 def update(self, *args, **kwargs):
21 super(NavigationQuerySet, self).update(*args, **kwargs)
22 Navigation.objects.clear_cache()
24 def delete(self, *args, **kwargs):
25 super(NavigationQuerySet, self).delete(*args, **kwargs)
26 Navigation.objects.clear_cache()
29 class NavigationManager(TreeManager):
31 # Analagous to contenttypes, cache Navigation to avoid repeated lookups all over the place.
32 # Navigation will probably be used frequently.
35 def get_queryset(self):
36 return NavigationQuerySet(self.model, using=self._db)
38 def closest_navigation(self, node):
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.
46 return self._get_cache_for(self.db, node)
48 # Find the most recent host!
49 ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation"))
51 # Iterate down the ancestors until you find one that:
53 # b) has hosted navigation.
56 for ancestor in ancestors:
57 if self.has_cache_for(ancestor) or ancestor.num_navigation > 0:
61 nodes_to_cache.append(ancestor)
63 if not self.has_cache_for(host_node):
64 self._add_to_cache(self.db, host_node)
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)
71 return hosted_navigation
73 def _add_to_cache(self, using, node, qs=None):
78 roots = node.hosted_navigation.select_related('target_node')
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)
85 self.__class__._cache.setdefault(using, {})[node] = qs
88 return self.db in self.__class__._cache and self.__class__._cache[self.db]
90 def _get_cache_for(self, using, node):
91 return self.__class__._cache[self.db][node]
93 def is_cached(self, navigation):
94 return self._is_cached(self.db, navigation)
96 def _is_cached(self, using, navigation):
97 cache = self.__class__._cache[using]
98 for qs in cache.values():
103 def has_cache_for(self, node):
104 return self._has_cache_for(self.db, node)
106 def _has_cache_for(self, using, node):
108 self._get_cache_for(using, node)
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)
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)
126 def clear_cache(self, navigation=None):
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.
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():
140 class Navigation(TreeEntity):
141 objects = NavigationManager()
142 text = models.CharField(max_length=50)
144 hosting_node = models.ForeignKey(Node, blank=True, null=True, related_name='hosted_navigation', help_text="Be part of this node's root navigation.")
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.")
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.")
153 def __init__(self, *args, **kwargs):
154 super(Navigation, self).__init__(*args, **kwargs)
155 self._initial_data = model_to_dict(self)
157 def __unicode__(self):
158 return self.get_path(field='text', pathsep=u' › ')
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.")
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.")
169 self.get_target_url()
170 except NoReverseMatch, e:
171 raise ValidationError(e.message)
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)
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()
190 return self.url_or_subpath
191 target_url = property(get_target_url)
193 def is_active(self, request):
196 if self.target_url == request.path:
197 # Handle the `default` case where the target_url and requested path
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
206 ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation")).filter(num_navigation__gt=0)
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:
215 # Always fall back to whether the node has active children.
216 return self.has_active_children(request)
219 """Shortcut method for Navigation.objects.is_cached"""
220 return Navigation.objects.is_cached(self)
222 def has_active_children(self, request):
223 for child in self.get_children():
224 if child.is_active(request):
228 def _has_changed(self):
229 if model_to_dict(self) == self._initial_data:
233 def save(self, *args, **kwargs):
234 super(Navigation, self).save(*args, **kwargs)
236 if self._has_changed():
237 self._initial_data = model_to_dict(self)
238 if Navigation.objects.has_cache():
240 Navigation.objects.clear_cache(self)
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)
246 if self.hosting_node and Navigation.objects.has_cache_for(self.hosting_node):
247 Navigation.objects.clear_cache_for(self.hosting_node)
249 def delete(self, *args, **kwargs):
250 super(Navigation, self).delete(*args, **kwargs)
251 Navigation.objects.clear_cache(self)
254 # Should I even try ordering?
255 ordering = ['order', 'lft']
256 verbose_name_plural = 'navigation'