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.
45 TODO: Should this create the auto-generated navigation in "physical" form?
48 return self._get_from_cache(self.db, node)
50 # Find the most recent host!
51 ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation"))
53 # Iterate down the ancestors until you find one that:
55 # b) has hosted navigation.
58 for ancestor in ancestors:
59 if self.is_cached(ancestor) or ancestor.num_navigation > 0:
63 nodes_to_cache.append(ancestor)
65 if not self.is_cached(host_node):
66 self._add_to_cache(self.db, host_node)
68 # Cache the queryset instance for every node that was passed over, as well.
69 hosted_navigation = self._get_from_cache(self.db, host_node)
70 for node in nodes_to_cache:
71 self._add_to_cache(self.db, node, hosted_navigation)
73 return hosted_navigation
75 def is_cached(self, node):
76 return self._is_cached(self.db, node)
78 def _add_to_cache(self, using, node, qs=None):
79 key = getattr(node, 'pk', None)
85 roots = node.hosted_navigation.select_related('target_node')
88 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)
97 self.__class__._cache.setdefault(using, {})[key] = qs
99 def _get_from_cache(self, using, node):
100 key = getattr(node, 'pk', None)
101 return self.__class__._cache[self.db][key]
103 def _is_cached(self, using, node):
105 self._get_from_cache(using, node)
110 def clear_cache(self, navigation=None):
112 Clear out the navigation cache. This needs to happen during database flushes
113 or if a navigation entry is changed to prevent caching of outdated navigation information.
115 if navigation is None:
116 self.__class__._cache.clear()
117 elif self.db in self.__class__._cache:
118 cache = self.__class__._cache[self.db]
119 for pk, qs in cache.items():
124 class Navigation(TreeEntity):
125 objects = NavigationManager()
126 text = models.CharField(max_length=50)
128 hosting_node = models.ForeignKey(Node, blank=True, null=True, related_name='hosted_navigation', help_text="Be part of this node's root navigation.")
130 target_node = models.ForeignKey(Node, blank=True, null=True, related_name='targeting_navigation', help_text="Point to this node's url.")
131 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.")
132 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.")
134 order = models.PositiveSmallIntegerField(blank=True, null=True)
135 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.")
137 def __init__(self, *args, **kwargs):
138 super(Navigation, self).__init__(*args, **kwargs)
139 self._initial_data = model_to_dict(self)
141 def __unicode__(self):
142 return self.get_path(field='text', pathsep=u' › ')
145 # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
146 if not self.target_node and not self.url_or_subpath:
147 raise ValidationError("Either a target node or a url must be defined.")
149 if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
150 raise ValidationError("Reversing parameters require a view name and a target node.")
153 self.get_target_url()
154 except NoReverseMatch, e:
155 raise ValidationError(e.message)
157 def get_target_url(self):
158 node = self.target_node
159 if node is not None and node.accepts_subpath and self.url_or_subpath:
160 if self.reversing_parameters is not None:
161 view_name = self.url_or_subpath
162 params = self.reversing_parameters
163 args = isinstance(params, list) and params or None
164 kwargs = isinstance(params, dict) and params or None
165 return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
167 subpath = self.url_or_subpath
168 while subpath and subpath[0] == '/':
169 subpath = subpath[1:]
170 return '%s%s' % (node.get_absolute_url(), subpath)
171 elif node is not None:
172 return node.get_absolute_url()
174 return self.url_or_subpath
175 target_url = property(get_target_url)
177 def is_active(self, request):
178 # First check if this particular navigation is active. It is considered active if:
179 # - the requested node is this instance's target node and its subpath matches the requested path.
180 # - the requested node is a descendant of this instance's target node and this instance's target
181 # node is not the hosting node of this navigation structure.
182 # - this instance has no target node and the url matches either the request path or the full url.
183 # - any of this instance's children are active.
186 if self.target_node == node:
187 if self.target_url == request.path:
189 elif self.target_node is None:
190 if self.url_or_subpath == request.path or self.url_or_subpath == "http%s://%s%s" % (request.is_secure() and 's' or '', request.get_host(), request.path):
192 elif self.target_node.is_ancestor_of(node) and self.target_node != self.hosting_node:
195 # Always fall back to whether the node has active children.
196 return self.has_active_children(request)
199 """Shortcut method for Navigation.objects.is_cached"""
200 return Navigation.objects.is_cached(self)
202 def has_active_children(self, request):
203 for child in self.get_children():
204 if child.is_active(request):
208 def _has_changed(self):
209 if model_to_dict(self) == self._initial_data:
213 def save(self, *args, **kwargs):
214 super(Navigation, self).save(*args, **kwargs)
216 if self._has_changed():
217 self._initial_data = model_to_dict(self)
219 Navigation.objects.clear_cache(self)
221 for navigation in self.get_ancestors():
222 if navigation.hosting_node and navigation.is_cached() and self.get_level() <= (navigation.get_level() + navigation.depth):
223 Navigation.objects.clear_cache(navigation)
225 def delete(self, *args, **kwargs):
226 super(Navigation, self).delete(*args, **kwargs)
227 Navigation.objects.clear_cache(self)
231 verbose_name_plural = 'navigation'