2 from UserDict import DictMixin
4 from django.core.exceptions import ValidationError
5 from django.core.urlresolvers import NoReverseMatch
6 from django.core.validators import RegexValidator, MinValueValidator
7 from django.db import models
8 from django.forms.models import model_to_dict
10 from philo.models.base import TreeEntity, TreeManager, Entity
11 from philo.models.nodes import Node, TargetURLModel
14 DEFAULT_NAVIGATION_DEPTH = 3
17 class NavigationMapper(object, DictMixin):
19 The :class:`NavigationMapper` is a dictionary-like object which allows easy fetching of the root items of a navigation for a node according to a key. The fetching goes through the :class:`NavigationManager` and can thus take advantage of the navigation cache. A :class:`NavigationMapper` instance will be available on each node instance as :attr:`Node.navigation` if :mod:`~philo.contrib.shipherd` is in the :setting:`INSTALLED_APPS`
22 def __init__(self, node):
25 def __getitem__(self, key):
26 return Navigation.objects.get_cache_for(self.node)[key]['root_items']
29 return Navigation.objects.get_cache_for(self.node).keys()
33 if not hasattr(self, '_navigation'):
34 self._navigation = NavigationMapper(self)
35 return self._navigation
38 Node.navigation = property(navigation)
41 class NavigationCacheQuerySet(models.query.QuerySet):
43 This subclass will trigger general cache clearing for Navigation.objects when a mass
44 update or deletion is performed. As there is no convenient way to iterate over the
45 changed or deleted instances, there's no way to be more precise about what gets cleared.
48 def update(self, *args, **kwargs):
49 super(NavigationCacheQuerySet, self).update(*args, **kwargs)
50 Navigation.objects.clear_cache()
52 def delete(self, *args, **kwargs):
53 super(NavigationCacheQuerySet, self).delete(*args, **kwargs)
54 Navigation.objects.clear_cache()
57 class NavigationManager(models.Manager):
59 Since navigation on a site will be hit frequently, is relatively costly to compute, and is changed relatively infrequently, the NavigationManager maintains a cache which maps nodes to navigations.
62 use_for_related = True
65 def get_query_set(self):
67 Returns a :class:`NavigationCacheQuerySet` instance.
70 return NavigationCacheQuerySet(self.model, using=self._db)
72 def get_cache_for(self, node, update_targets=True):
73 """Returns the navigation cache for a given :class:`.Node`. If update_targets is ``True``, then :meth:`update_targets_for` will be run with the :class:`.Node`."""
75 if not self.has_cache_for(node):
76 self.create_cache_for(node)
79 if update_targets and not created:
80 self.update_targets_for(node)
82 return self.__class__._cache[self.db][node]
84 def has_cache_for(self, node):
85 """Returns ``True`` if a cache exists for the :class:`.Node` and ``False`` otherwise."""
86 return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
88 def create_cache_for(self, node):
89 """This method loops through the :class:`.Node`\ s ancestors and caches all unique navigation keys."""
90 ancestors = node.get_ancestors(ascending=True, include_self=True)
94 for node in ancestors:
95 if self.has_cache_for(node):
96 cache = self.get_cache_for(node).copy()
99 nodes_to_cache.insert(0, node)
103 for node in nodes_to_cache:
105 cache.update(self._build_cache_for(node))
106 self.__class__._cache.setdefault(self.db, {})[node] = cache
108 def _build_cache_for(self, node):
110 tree_id_attr = NavigationItem._mptt_meta.tree_id_attr
111 level_attr = NavigationItem._mptt_meta.level_attr
113 for navigation in node.navigation_set.all():
114 tree_ids = navigation.roots.values_list(tree_id_attr)
115 items = list(NavigationItem.objects.filter(**{'%s__in' % tree_id_attr: tree_ids, '%s__lt' % level_attr: navigation.depth}).order_by('order', 'lft'))
120 item._is_cached = True
122 if not hasattr(item, '_cached_children'):
123 item._cached_children = []
126 # alternatively, if I don't want to force it to a list, I could keep track of
127 # instances where the parent hasn't yet been met and do this step later for them.
129 item.parent = items[items.index(item.parent)]
130 if not hasattr(item.parent, '_cached_children'):
131 item.parent._cached_children = []
132 item.parent._cached_children.append(item)
134 root_items.append(item)
136 cache[navigation.key] = {
137 'navigation': navigation,
138 'root_items': root_items,
144 def clear_cache_for(self, node):
145 """Clear the cache for the :class:`.Node` and all its descendants. The navigation for this node has probably changed, and it isn't worth it to figure out which descendants were actually affected by this."""
146 if not self.has_cache_for(node):
150 descendants = node.get_descendants(include_self=True)
151 cache = self.__class__._cache[self.db]
152 for node in descendants:
153 cache.pop(node, None)
155 def update_targets_for(self, node):
156 """Manually updates the target nodes for the :class:`.Node`'s cache in case something's changed there. This is a less complex operation than rebuilding the :class:`.Node`'s cache."""
157 caches = self.__class__._cache[self.db][node].values()
162 target_pks |= set([item.target_node_id for item in cache['items']])
164 # A distinct query is not strictly necessary. TODO: benchmark the efficiency
165 # with/without distinct.
166 targets = list(Node.objects.filter(pk__in=target_pks).distinct())
169 for item in cache['items']:
170 if item.target_node_id:
171 item.target_node = targets[targets.index(item.target_node)]
173 def clear_cache(self):
174 """Clears the manager's entire navigation cache."""
175 self.__class__._cache.pop(self.db, None)
178 class Navigation(Entity):
180 :class:`Navigation` represents a group of :class:`NavigationItem`\ s that have an intrinsic relationship in terms of navigating a website. For example, a ``main`` navigation versus a ``side`` navigation, or a ``authenticated`` navigation versus an ``anonymous`` navigation.
183 #: A :class:`NavigationManager` instance.
184 objects = NavigationManager()
186 #: The :class:`.Node` which the :class:`Navigation` is attached to. The :class:`Navigation` will also be available to all the :class:`.Node`'s descendants and will override any :class:`Navigation` with the same key on any of the :class:`.Node`'s ancestors.
187 node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
188 #: Each :class:`Navigation` has a ``key`` which consists of one or more word characters so that it can easily be accessed in a template as ``{{ node.navigation.this_key }}``.
189 key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
190 #: There is no limit to the depth of a tree of :class:`NavigationItem`\ s, but ``depth`` will limit how much of the tree will be displayed.
191 depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
193 def __init__(self, *args, **kwargs):
194 super(Navigation, self).__init__(*args, **kwargs)
195 self._initial_data = model_to_dict(self)
197 def __unicode__(self):
198 return "%s[%s]" % (self.node, self.key)
200 def _has_changed(self):
201 return self._initial_data != model_to_dict(self)
203 def save(self, *args, **kwargs):
204 super(Navigation, self).save(*args, **kwargs)
206 if self._has_changed():
207 Navigation.objects.clear_cache_for(self.node)
208 self._initial_data = model_to_dict(self)
210 def delete(self, *args, **kwargs):
211 super(Navigation, self).delete(*args, **kwargs)
212 Navigation.objects.clear_cache_for(self.node)
215 unique_together = ('node', 'key')
218 class NavigationItemManager(TreeManager):
219 use_for_related = True
221 def get_query_set(self):
222 """Returns a :class:`NavigationCacheQuerySet` instance."""
223 return NavigationCacheQuerySet(self.model, using=self._db)
226 class NavigationItem(TreeEntity, TargetURLModel):
227 #: A :class:`NavigationItemManager` instance
228 objects = NavigationItemManager()
230 #: A :class:`ForeignKey` to a :class:`Navigation` instance. If this is not null, then the :class:`NavigationItem` will be a root node of the :class:`Navigation` instance.
231 navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
232 #: The text which will be displayed in the navigation. This is a :class:`CharField` instance with max length 50.
233 text = models.CharField(max_length=50)
235 #: The order in which the :class:`NavigationItem` will be displayed.
236 order = models.PositiveSmallIntegerField(default=0)
238 def __init__(self, *args, **kwargs):
239 super(NavigationItem, self).__init__(*args, **kwargs)
240 self._initial_data = model_to_dict(self)
241 self._is_cached = False
243 def __unicode__(self):
244 return self.get_path(field='text', pathsep=u' › ')
247 super(NavigationItem, self).clean()
248 if bool(self.parent) == bool(self.navigation):
249 raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
251 def is_active(self, request):
252 """Returns ``True`` if the :class:`NavigationItem` is considered active for a given request and ``False`` otherwise."""
253 if self.target_url == request.path:
254 # Handle the `default` case where the target_url and requested path
258 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):
259 # If there's no target_node, double-check whether it's a full-url
263 if self.target_node and not self.url_or_subpath:
264 # If there is a target node and it's targeted simply, but the target URL is not
265 # the same as the request path, check whether the target node is an ancestor
266 # of the requested node. If so, this is active unless the target node
267 # is the same as the ``host node`` for this navigation structure.
269 host_node = self.get_root().navigation.node
270 except AttributeError:
273 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
278 def has_active_descendants(self, request):
279 """Returns ``True`` if the :class:`NavigationItem` has active descendants and ``False`` otherwise."""
280 for child in self.get_children():
281 if child.is_active(request) or child.has_active_descendants(request):
285 def _has_changed(self):
286 if model_to_dict(self) == self._initial_data:
290 def _clear_cache(self):
292 root = self.get_root()
293 if self.get_level() < root.navigation.depth:
294 Navigation.objects.clear_cache_for(self.get_root().navigation.node)
295 except AttributeError:
298 def save(self, *args, **kwargs):
299 super(NavigationItem, self).save(*args, **kwargs)
301 if self._has_changed():
304 def delete(self, *args, **kwargs):
305 super(NavigationItem, self).delete(*args, **kwargs)