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(self.db, ancestor) or ancestor.num_navigation > 0:
63 nodes_to_cache.append(ancestor)
65 if not self._is_cached(self.db, 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 _add_to_cache(self, using, node, qs=None):
76 key = getattr(node, 'pk', None)
82 roots = node.hosted_navigation.select_related('target_node')
85 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)
94 self.__class__._cache.setdefault(using, {})[key] = qs
96 def _get_from_cache(self, using, node):
97 key = getattr(node, 'pk', None)
98 return self.__class__._cache[self.db][key]
100 def _is_cached(self, using, node):
102 self._get_from_cache(using, node)
107 def clear_cache(self, navigation=None):
109 Clear out the navigation cache. This needs to happen during database flushes
110 or if a navigation entry is changed to prevent caching of outdated navigation information.
112 if navigation is None:
113 self.__class__._cache.clear()
115 cache = self.__class__._cache[self.db]
116 for pk, qs in cache.items():
121 class Navigation(TreeEntity):
122 objects = NavigationManager()
123 text = models.CharField(max_length=50)
125 hosting_node = models.ForeignKey(Node, blank=True, null=True, related_name='hosted_navigation', help_text="Be part of this node's root navigation.")
127 target_node = models.ForeignKey(Node, blank=True, null=True, related_name='targeting_navigation', help_text="Point to this node's url.")
128 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.")
129 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.")
131 order = models.PositiveSmallIntegerField(blank=True, null=True)
132 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.")
134 def __init__(self, *args, **kwargs):
135 super(Navigation, self).__init__(*args, **kwargs)
136 self._initial_data = model_to_dict(self)
138 def __unicode__(self):
139 return self.get_path(field='text', pathsep=u' › ')
142 # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
143 if not self.target_node and not self.url_or_subpath:
144 raise ValidationError("Either a target node or a url must be defined.")
146 if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
147 raise ValidationError("Reversing parameters require a view name and a target node.")
150 self.get_target_url()
151 except NoReverseMatch, e:
152 raise ValidationError(e.message)
154 def get_target_url(self):
155 node = self.target_node
156 if node is not None and node.accepts_subpath and self.url_or_subpath:
157 if self.reversing_parameters is not None:
158 view_name = self.url_or_subpath
159 params = self.reversing_parameters
160 args = isinstance(params, list) and params or None
161 kwargs = isinstance(params, dict) and params or None
162 return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
164 subpath = self.url_or_subpath
165 while subpath and subpath[0] == '/':
166 subpath = subpath[1:]
167 return '%s%s' % (node.get_absolute_url(), subpath)
168 elif node is not None:
169 return node.get_absolute_url()
171 return self.url_or_subpath
172 target_url = property(get_target_url)
174 def is_active(self, request):
175 # First check if this particular navigation is active. It is considered active if:
176 # - the requested node is this instance's target node and its subpath matches the requested path.
177 # - the requested node is a descendant of this instance's target node and this instance's target
178 # node is not the hosting node of this navigation structure.
179 # - this instance has no target node and the url matches either the request path or the full url.
180 # - any of this instance's children are active.
183 if self.target_node == node:
184 if self.target_url == request.path:
186 elif self.target_node is None:
187 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):
189 elif self.target_node.is_ancestor_of(node) and self.target_node != self.hosting_node:
192 # Always fall back to whether the node has active children.
193 return self.has_active_children(request)
195 def has_active_children(self, request):
196 for child in self.get_children():
197 if child.is_active(request):
201 def _has_changed(self):
202 if model_to_dict(self) == self._initial_data:
206 def save(self, *args, **kwargs):
207 super(Navigation, self).save(*args, **kwargs)
208 if self._has_changed():
209 self._initial_data = model_to_dict(self)
210 Navigation.objects.clear_cache(self)
212 def delete(self, *args, **kwargs):
213 super(Navigation, self).delete(*args, **kwargs)
214 Navigation.objects.clear_cache(self)
218 verbose_name_plural = 'navigation'