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, TreeEntityManager, 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 = dict([(n.pk, n) for n in 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[item.target_node_id]
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.
182 A :class:`Navigation`'s :class:`NavigationItem`\ s will be accessible from its related :class:`.Node` and that :class:`.Node`'s descendants through a :class:`NavigationMapper` instance at :attr:`Node.navigation`. Example::
184 >>> node.navigation_set.all()
186 >>> parent = node.parent
187 >>> items = parent.navigation_set.get(key='main').roots.all()
188 >>> parent.navigation["main"] == node.navigation["main"] == list(items)
192 #: A :class:`NavigationManager` instance.
193 objects = NavigationManager()
195 #: 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.
196 node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
197 #: 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 }}``.
198 key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
199 #: 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.
200 depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
202 def __init__(self, *args, **kwargs):
203 super(Navigation, self).__init__(*args, **kwargs)
204 self._initial_data = model_to_dict(self)
206 def __unicode__(self):
207 return "%s[%s]" % (self.node, self.key)
209 def _has_changed(self):
210 return self._initial_data != model_to_dict(self)
212 def save(self, *args, **kwargs):
213 super(Navigation, self).save(*args, **kwargs)
215 if self._has_changed():
216 Navigation.objects.clear_cache_for(self.node)
217 self._initial_data = model_to_dict(self)
219 def delete(self, *args, **kwargs):
220 super(Navigation, self).delete(*args, **kwargs)
221 Navigation.objects.clear_cache_for(self.node)
224 unique_together = ('node', 'key')
227 class NavigationItemManager(TreeEntityManager):
228 use_for_related = True
230 def get_query_set(self):
231 """Returns a :class:`NavigationCacheQuerySet` instance."""
232 return NavigationCacheQuerySet(self.model, using=self._db)
235 class NavigationItem(TreeEntity, TargetURLModel):
236 #: A :class:`NavigationItemManager` instance
237 objects = NavigationItemManager()
239 #: 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.
240 navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
241 #: The text which will be displayed in the navigation. This is a :class:`CharField` instance with max length 50.
242 text = models.CharField(max_length=50)
244 #: The order in which the :class:`NavigationItem` will be displayed.
245 order = models.PositiveSmallIntegerField(default=0)
247 def __init__(self, *args, **kwargs):
248 super(NavigationItem, self).__init__(*args, **kwargs)
249 self._initial_data = model_to_dict(self)
250 self._is_cached = False
252 def get_path(self, root=None, pathsep=u' › ', field='text'):
253 return super(NavigationItem, self).get_path(root, pathsep, field)
254 path = property(get_path)
257 super(NavigationItem, self).clean()
258 if bool(self.parent) == bool(self.navigation):
259 raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
261 def is_active(self, request):
262 """Returns ``True`` if the :class:`NavigationItem` is considered active for a given request and ``False`` otherwise."""
263 if self.target_url == request.path:
264 # Handle the `default` case where the target_url and requested path
268 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):
269 # If there's no target_node, double-check whether it's a full-url
273 if self.target_node and not self.url_or_subpath:
274 # If there is a target node and it's targeted simply, but the target URL is not
275 # the same as the request path, check whether the target node is an ancestor
276 # of the requested node. If so, this is active unless the target node
277 # is the same as the ``host node`` for this navigation structure.
279 host_node = self.get_root().navigation.node
280 except AttributeError:
283 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
288 def has_active_descendants(self, request):
289 """Returns ``True`` if the :class:`NavigationItem` has active descendants and ``False`` otherwise."""
290 for child in self.get_children():
291 if child.is_active(request) or child.has_active_descendants(request):
295 def _has_changed(self):
296 if model_to_dict(self) == self._initial_data:
300 def _clear_cache(self):
302 root = self.get_root()
303 if self.get_level() < root.navigation.depth:
304 Navigation.objects.clear_cache_for(self.get_root().navigation.node)
305 except AttributeError:
308 def save(self, *args, **kwargs):
309 super(NavigationItem, self).save(*args, **kwargs)
311 if self._has_changed():
314 def delete(self, *args, **kwargs):
315 super(NavigationItem, self).delete(*args, **kwargs)