X-Git-Url: http://git.ithinksw.org/philo.git/blobdiff_plain/d19e216035b14d8f60b24dda0c0670e6997f16ce..7b3b752db47aaa16e136e245a6e3f350c9566690:/philo/contrib/shipherd/models.py diff --git a/philo/contrib/shipherd/models.py b/philo/contrib/shipherd/models.py index 654f5f8..95be501 100644 --- a/philo/contrib/shipherd/models.py +++ b/philo/contrib/shipherd/models.py @@ -1,225 +1,156 @@ #encoding: utf-8 +from UserDict import DictMixin +from hashlib import sha1 + +from django.contrib.sites.models import Site +from django.core.cache import cache from django.core.exceptions import ValidationError from django.core.urlresolvers import NoReverseMatch from django.core.validators import RegexValidator, MinValueValidator from django.db import models from django.forms.models import model_to_dict -from philo.models import TreeEntity, Node, TreeManager, Entity, TargetURLModel -from philo.validators import RedirectValidator -from UserDict import DictMixin + +from philo.models.base import TreeEntity, TreeEntityManager, Entity +from philo.models.nodes import Node, TargetURLModel DEFAULT_NAVIGATION_DEPTH = 3 -class NavigationQuerySetMapper(object, DictMixin): - """This class exists to prevent setting of items in the navigation cache through node.navigation.""" +class NavigationMapper(object, DictMixin): + """ + 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. 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` + + """ def __init__(self, node): self.node = node + self._cache = {} def __getitem__(self, key): - return Navigation.objects.get_cache_for(self.node)[key]['root_items'] - - def keys(self): - return Navigation.objects.get_cache_for(self.node).keys() + if key not in self._cache: + try: + self._cache[key] = Navigation.objects.get_for_node(self.node, key) + except Navigation.DoesNotExist: + self._cache[key] = None + return self._cache[key] def navigation(self): if not hasattr(self, '_navigation'): - self._navigation = NavigationQuerySetMapper(self) + self._navigation = NavigationMapper(self) return self._navigation Node.navigation = property(navigation) -class NavigationCacheQuerySet(models.query.QuerySet): - """ - This subclass will trigger general cache clearing for Navigation.objects when a mass - update or deletion is performed. As there is no convenient way to iterate over the - changed or deleted instances, there's no way to be more precise about what gets cleared. - """ - def update(self, *args, **kwargs): - super(NavigationCacheQuerySet, self).update(*args, **kwargs) - Navigation.objects.clear_cache() - - def delete(self, *args, **kwargs): - super(NavigationCacheQuerySet, self).delete(*args, **kwargs) - Navigation.objects.clear_cache() - - class NavigationManager(models.Manager): - # Since navigation is going to be hit frequently and changed - # relatively infrequently, cache it. Analogous to contenttypes. use_for_related = True - _cache = {} - def get_queryset(self): - return NavigationCacheQuerySet(self.model, using=self._db) - - def get_cache_for(self, node, update_targets=True): - created = False - if not self.has_cache_for(node): - self.create_cache_for(node) - created = True - - if update_targets and not created: - self.update_targets_for(node) - - return self.__class__._cache[self.db][node] - - def has_cache_for(self, node): - return self.db in self.__class__._cache and node in self.__class__._cache[self.db] - - def create_cache_for(self, node): - "This method loops through the nodes ancestors and caches all unique navigation keys." - ancestors = node.get_ancestors(ascending=True, include_self=True) - - nodes_to_cache = [] - - for node in ancestors: - if self.has_cache_for(node): - cache = self.get_cache_for(node).copy() - break - else: - nodes_to_cache.insert(0, node) - else: - cache = {} - - for node in nodes_to_cache: - cache = cache.copy() - cache.update(self._build_cache_for(node)) - self.__class__._cache.setdefault(self.db, {})[node] = cache - - def _build_cache_for(self, node): - cache = {} - tree_id_attr = NavigationItem._mptt_meta.tree_id_attr - level_attr = NavigationItem._mptt_meta.level_attr - - for navigation in node.navigation_set.all(): - tree_ids = navigation.roots.values_list(tree_id_attr) - items = list(NavigationItem.objects.filter(**{'%s__in' % tree_id_attr: tree_ids, '%s__lt' % level_attr: navigation.depth}).order_by('order', 'lft')) + def get_for_node(self, node, key): + cache_key = self._get_cache_key(node, key) + cached = cache.get(cache_key) + + if cached is None: + opts = Node._mptt_meta + left = getattr(node, opts.left_attr) + right = getattr(node, opts.right_attr) + tree_id = getattr(node, opts.tree_id_attr) + kwargs = { + "node__%s__lte" % opts.left_attr: left, + "node__%s__gte" % opts.right_attr: right, + "node__%s" % opts.tree_id_attr: tree_id + } + navs = self.filter(key=key, **kwargs).select_related('node').order_by('-node__%s' % opts.level_attr) + nav = navs[0] + roots = nav.roots.all().select_related('target_node').order_by('order') + item_opts = NavigationItem._mptt_meta + by_pk = {} + tree_ids = [] - root_items = [] + site_root_node = Site.objects.get_current().root_node - for item in items: - item._is_cached = True - - if not hasattr(item, '_cached_children'): - item._cached_children = [] - - if item.parent: - # alternatively, if I don't want to force it to a list, I could keep track of - # instances where the parent hasn't yet been met and do this step later for them. - # delayed action. - item.parent = items[items.index(item.parent)] - if not hasattr(item.parent, '_cached_children'): - item.parent._cached_children = [] - item.parent._cached_children.append(item) - else: - root_items.append(item) + for root in roots: + by_pk[root.pk] = root + tree_ids.append(getattr(root, item_opts.tree_id_attr)) + root._cached_children = [] + if root.target_node: + root.target_node.get_path(root=site_root_node) + root.navigation = nav - cache[navigation.key] = { - 'navigation': navigation, - 'root_items': root_items, - 'items': items + kwargs = { + '%s__in' % item_opts.tree_id_attr: tree_ids, + '%s__lt' % item_opts.level_attr: nav.depth, + '%s__gt' % item_opts.level_attr: 0 } + items = NavigationItem.objects.filter(**kwargs).select_related('target_node').order_by('level', 'order') + for item in items: + by_pk[item.pk] = item + item._cached_children = [] + parent_pk = getattr(item, '%s_id' % item_opts.parent_attr) + item.parent = by_pk[parent_pk] + item.parent._cached_children.append(item) + if item.target_node: + item.target_node.get_path(root=site_root_node) + + cached = roots + cache.set(cache_key, cached) - return cache - - def clear_cache_for(self, node): - # Clear the cache for this node and all its descendants. The - # navigation for this node has probably changed, and for now, - # it isn't worth it to only clear the descendants actually - # affected by this. - if not self.has_cache_for(node): - # Already cleared. - return - - descendants = node.get_descendants(include_self=True) - cache = self.__class__._cache[self.db] - for node in descendants: - cache.pop(node, None) + return cached - def update_targets_for(self, node): - # Manually update a cache's target nodes in case something's changed there. - # This should be a less complex operation than reloading the models each - # time. Not as good as selective updates... but not much to be done - # about that. TODO: Benchmark it. - caches = self.__class__._cache[self.db][node].values() - - target_pks = set() + def _get_cache_key(self, node, key): + opts = Node._mptt_meta + left = getattr(node, opts.left_attr) + right = getattr(node, opts.right_attr) + tree_id = getattr(node, opts.tree_id_attr) + parent_id = getattr(node, "%s_id" % opts.parent_attr) - for cache in caches: - target_pks |= set([item.target_node_id for item in cache['items']]) - - # A distinct query is not strictly necessary. TODO: benchmark the efficiency - # with/without distinct. - targets = list(Node.objects.filter(pk__in=target_pks).distinct()) - - for cache in caches: - for item in cache['items']: - if item.target_node_id: - item.target_node = targets[targets.index(item.target_node)] - - def clear_cache(self): - self.__class__._cache.pop(self.db, None) + return sha1(unicode(left) + unicode(right) + unicode(tree_id) + unicode(parent_id) + unicode(node.pk) + unicode(key)).hexdigest() class Navigation(Entity): + """ + :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. + + 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:: + + >>> node.navigation_set.all() + [] + >>> parent = node.parent + >>> items = parent.navigation_set.get(key='main').roots.all() + >>> parent.navigation["main"] == node.navigation["main"] == list(items) + True + + """ + #: A :class:`NavigationManager` instance. objects = NavigationManager() + #: 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. node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.") + #: 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 }}``. key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True) + #: 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. depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.") - def __init__(self, *args, **kwargs): - super(Navigation, self).__init__(*args, **kwargs) - self._initial_data = model_to_dict(self) - def __unicode__(self): return "%s[%s]" % (self.node, self.key) - def _has_changed(self): - return self._initial_data != model_to_dict(self) - - def save(self, *args, **kwargs): - super(Navigation, self).save(*args, **kwargs) - - if self._has_changed(): - Navigation.objects.clear_cache_for(self.node) - self._initial_data = model_to_dict(self) - - def delete(self, *args, **kwargs): - super(Navigation, self).delete(*args, **kwargs) - Navigation.objects.clear_cache_for(self.node) - class Meta: unique_together = ('node', 'key') -class NavigationItemManager(TreeManager): - use_for_related = True - - def get_queryset(self): - return NavigationCacheQuerySet(self.model, using=self._db) - - class NavigationItem(TreeEntity, TargetURLModel): - objects = NavigationItemManager() - + #: 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. navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.") + #: The text which will be displayed in the navigation. This is a :class:`CharField` instance with max length 50. text = models.CharField(max_length=50) + #: The order in which the :class:`NavigationItem` will be displayed. order = models.PositiveSmallIntegerField(default=0) - def __init__(self, *args, **kwargs): - super(NavigationItem, self).__init__(*args, **kwargs) - self._initial_data = model_to_dict(self) - self._is_cached = False - - def __unicode__(self): - return self.get_path(field='text', pathsep=u' › ') + def get_path(self, root=None, pathsep=u' › ', field='text'): + return super(NavigationItem, self).get_path(root, pathsep, field) + path = property(get_path) def clean(self): super(NavigationItem, self).clean() @@ -227,6 +158,7 @@ class NavigationItem(TreeEntity, TargetURLModel): raise ValidationError("Exactly one of `parent` and `navigation` must be defined.") def is_active(self, request): + """Returns ``True`` if the :class:`NavigationItem` is considered active for a given request and ``False`` otherwise.""" if self.target_url == request.path: # Handle the `default` case where the target_url and requested path # are identical. @@ -242,41 +174,21 @@ class NavigationItem(TreeEntity, TargetURLModel): # the same as the request path, check whether the target node is an ancestor # of the requested node. If so, this is active unless the target node # is the same as the ``host node`` for this navigation structure. - try: - host_node = self.get_root().navigation.node - except AttributeError: - pass - else: - if self.target_node != host_node and self.target_node.is_ancestor_of(request.node): - return True + root = self + + # The common case will be cached items, whose parents are cached with them. + while root.parent is not None: + root = root.parent + + host_node_id = root.navigation.node_id + if self.target_node.pk != host_node_id and self.target_node.is_ancestor_of(request.node): + return True return False def has_active_descendants(self, request): + """Returns ``True`` if the :class:`NavigationItem` has active descendants and ``False`` otherwise.""" for child in self.get_children(): if child.is_active(request) or child.has_active_descendants(request): return True - return False - - def _has_changed(self): - if model_to_dict(self) == self._initial_data: - return False - return True - - def _clear_cache(self): - try: - root = self.get_root() - if self.get_level() < root.navigation.depth: - Navigation.objects.clear_cache_for(self.get_root().navigation.node) - except AttributeError: - pass - - def save(self, *args, **kwargs): - super(NavigationItem, self).save(*args, **kwargs) - - if self._has_changed(): - self._clear_cache() - - def delete(self, *args, **kwargs): - super(NavigationItem, self).delete(*args, **kwargs) - self._clear_cache() \ No newline at end of file + return False \ No newline at end of file