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
9 #from mptt.templatetags.mptt_tags import cache_tree_children
12 DEFAULT_NAVIGATION_DEPTH = 3
15 class NavigationManager(TreeManager):
17 # Analagous to contenttypes, cache Navigation to avoid repeated lookups all over the place.
18 # Navigation will probably be used frequently.
21 def closest_navigation(self, node):
23 Returns the set of Navigation objects for a given node's navigation. This
24 will be the most recent set of defined hosted navigation among the node's
25 ancestors. Lookups are cached so that subsequent lookups for the same node
26 don't hit the database.
28 TODO: Should this create the auto-generated navigation in "physical" form?
31 return self._get_from_cache(self.db, node)
33 # Find the most recent host!
34 ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation"))
36 # Iterate down the ancestors until you find one that:
38 # b) has hosted navigation.
41 for ancestor in ancestors:
42 if self._is_cached(self.db, ancestor) or ancestor.num_navigation > 0:
46 nodes_to_cache.append(ancestor)
48 if not self._is_cached(self.db, host_node):
49 self._add_to_cache(self.db, host_node)
51 # Cache the queryset instance for every node that was passed over, as well.
52 hosted_navigation = self._get_from_cache(self.db, host_node)
53 for node in nodes_to_cache:
54 self._add_to_cache(self.db, node, hosted_navigation)
56 return hosted_navigation
58 def _add_to_cache(self, using, node, qs=None):
59 key = getattr(node, 'pk', None)
65 roots = node.hosted_navigation.select_related('target_node')
68 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)
77 self.__class__._cache.setdefault(using, {})[key] = qs
79 def _get_from_cache(self, using, node):
80 key = getattr(node, 'pk', None)
81 return self.__class__._cache[self.db][key]
83 def _is_cached(self, using, node):
85 self._get_from_cache(using, node)
90 def clear_cache(self, navigation=None):
92 Clear out the navigation cache. This needs to happen during database flushes
93 or if a navigation entry is changed to prevent caching of outdated navigation information.
95 TODO: call this method from update() and delete()! - But how? Those aren't methods available
96 from the manager. The only solution would be to make a special QuerySet subclass that calls
97 this method for each instance.
99 if navigation is None:
100 self.__class__._cache.clear()
102 cache = self.__class__._cache[self.db]
103 for pk, qs in cache.items():
108 class Navigation(TreeEntity):
109 objects = NavigationManager()
110 text = models.CharField(max_length=50)
112 hosting_node = models.ForeignKey(Node, blank=True, null=True, related_name='hosted_navigation', help_text="Be part of this node's root navigation.")
114 target_node = models.ForeignKey(Node, blank=True, null=True, related_name='targeting_navigation', help_text="Point to this node's url.")
115 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.")
116 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.")
118 order = models.PositiveSmallIntegerField(blank=True, null=True)
119 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.")
121 def __init__(self, *args, **kwargs):
122 super(Navigation, self).__init__(*args, **kwargs)
123 self._initial_data = model_to_dict(self)
125 def __unicode__(self):
126 return self.get_path(field='text', pathsep=u' › ')
129 # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
130 if not self.target_node and not self.url_or_subpath:
131 raise ValidationError("Either a target node or a url must be defined.")
133 if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
134 raise ValidationError("Reversing parameters require a view name and a target node.")
137 self.get_target_url()
138 except NoReverseMatch, e:
139 raise ValidationError(e.message)
141 def get_target_url(self):
142 node = self.target_node
143 if node is not None and node.accepts_subpath and self.url_or_subpath:
144 if self.reversing_parameters is not None:
145 view_name = self.url_or_subpath
146 params = self.reversing_parameters
147 args = isinstance(params, list) and params or None
148 kwargs = isinstance(params, dict) and params or None
149 return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
151 subpath = self.url_or_subpath
152 while subpath and subpath[0] == '/':
153 subpath = subpath[1:]
154 return '%s%s' % (node.get_absolute_url(), subpath)
155 elif node is not None:
156 return node.get_absolute_url()
158 return self.url_or_subpath
159 target_url = property(get_target_url)
161 def is_active(self, request):
162 # First check if this particular navigation is active. It is considered active if:
163 # - the requested node is this instance's target node and its subpath matches the requested path.
164 # - the requested node is a descendant of this instance's target node and this instance's target
165 # node is not the hosting node of this navigation structure.
166 # - this instance has no target node and the url matches either the request path or the full url.
167 # - any of this instance's children are active.
170 if self.target_node == node:
171 if self.target_url == request.path:
173 elif self.target_node is None:
174 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):
176 elif self.target_node.is_ancestor_of(node) and self.target_node != self.hosting_node:
179 # Always fall back to whether the node has active children.
180 return self.has_active_children(request)
182 def has_active_children(self, request):
183 for child in self.get_children():
184 if child.is_active(request):
188 def _has_changed(self):
189 if model_to_dict(self) == self._initial_data:
193 def save(self, *args, **kwargs):
194 super(Navigation, self).save(*args, **kwargs)
195 if self._has_changed():
196 self._initial_data = model_to_dict(self)
197 Navigation.objects.clear_cache(self)
199 def delete(self, *args, **kwargs):
200 super(Navigation, self).delete(*args, **kwargs)
201 Navigation.objects.clear_cache(self)
205 verbose_name_plural = 'navigation'