Added catch to NavigationManager.get_for_node for cases where items do not have a...
[philo.git] / philo / contrib / shipherd / models.py
1 #encoding: utf-8
2 from UserDict import DictMixin
3 from hashlib import sha1
4
5 from django.contrib.sites.models import Site
6 from django.core.cache import cache
7 from django.core.exceptions import ValidationError
8 from django.core.urlresolvers import NoReverseMatch
9 from django.core.validators import RegexValidator, MinValueValidator
10 from django.db import models
11 from django.forms.models import model_to_dict
12
13 from philo.models.base import TreeEntity, TreeEntityManager, Entity
14 from philo.models.nodes import Node, TargetURLModel
15
16
17 DEFAULT_NAVIGATION_DEPTH = 3
18
19
20 class NavigationMapper(object, DictMixin):
21         """
22         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`
23         
24         """
25         def __init__(self, node):
26                 self.node = node
27                 self._cache = {}
28         
29         def __getitem__(self, key):
30                 if key not in self._cache:
31                         try:
32                                 self._cache[key] = Navigation.objects.get_for_node(self.node, key)
33                         except Navigation.DoesNotExist:
34                                 self._cache[key] = None
35                 return self._cache[key]
36
37
38 def navigation(self):
39         if not hasattr(self, '_navigation'):
40                 self._navigation = NavigationMapper(self)
41         return self._navigation
42
43
44 Node.navigation = property(navigation)
45
46
47 class NavigationManager(models.Manager):
48         use_for_related = True
49         
50         def get_for_node(self, node, key):
51                 cache_key = self._get_cache_key(node, key)
52                 cached = cache.get(cache_key)
53                 
54                 if cached is None:
55                         opts = Node._mptt_meta
56                         left = getattr(node, opts.left_attr)
57                         right = getattr(node, opts.right_attr)
58                         tree_id = getattr(node, opts.tree_id_attr)
59                         kwargs = {
60                                 "node__%s__lte" % opts.left_attr: left,
61                                 "node__%s__gte" % opts.right_attr: right,
62                                 "node__%s" % opts.tree_id_attr: tree_id
63                         }
64                         navs = self.filter(key=key, **kwargs).select_related('node').order_by('-node__%s' % opts.level_attr)
65                         nav = navs[0]
66                         roots = nav.roots.all().select_related('target_node').order_by('order')
67                         item_opts = NavigationItem._mptt_meta
68                         by_pk = {}
69                         tree_ids = []
70                         
71                         site_root_node = Site.objects.get_current().root_node
72                         
73                         for root in roots:
74                                 by_pk[root.pk] = root
75                                 tree_ids.append(getattr(root, item_opts.tree_id_attr))
76                                 root._cached_children = []
77                                 if root.target_node:
78                                         root.target_node.get_path(root=site_root_node)
79                                 root.navigation = nav
80                         
81                         kwargs = {
82                                 '%s__in' % item_opts.tree_id_attr: tree_ids,
83                                 '%s__lt' % item_opts.level_attr: nav.depth,
84                                 '%s__gt' % item_opts.level_attr: 0
85                         }
86                         items = NavigationItem.objects.filter(**kwargs).select_related('target_node').order_by('level', 'order')
87                         for item in items:
88                                 by_pk[item.pk] = item
89                                 item._cached_children = []
90                                 parent_pk = getattr(item, '%s_id' % item_opts.parent_attr)
91                                 item.parent = by_pk[parent_pk]
92                                 item.parent._cached_children.append(item)
93                                 if item.target_node:
94                                         item.target_node.get_path(root=site_root_node)
95                         
96                         cached = roots
97                         cache.set(cache_key, cached)
98                 
99                 return cached
100         
101         def _get_cache_key(self, node, key):
102                 opts = Node._mptt_meta
103                 left = getattr(node, opts.left_attr)
104                 right = getattr(node, opts.right_attr)
105                 tree_id = getattr(node, opts.tree_id_attr)
106                 parent_id = getattr(node, "%s_id" % opts.parent_attr)
107                 
108                 return sha1(unicode(left) + unicode(right) + unicode(tree_id) + unicode(parent_id) + unicode(node.pk) + unicode(key)).hexdigest()
109
110
111 class Navigation(Entity):
112         """
113         :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.
114         
115         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::
116         
117                 >>> node.navigation_set.all()
118                 []
119                 >>> parent = node.parent
120                 >>> items = parent.navigation_set.get(key='main').roots.all()
121                 >>> parent.navigation["main"] == node.navigation["main"] == list(items)
122                 True
123         
124         """
125         #: A :class:`NavigationManager` instance.
126         objects = NavigationManager()
127         
128         #: 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.
129         node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
130         #: 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 }}``.
131         key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
132         #: 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.
133         depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
134         
135         def __unicode__(self):
136                 return "%s[%s]" % (self.node, self.key)
137         
138         class Meta:
139                 unique_together = ('node', 'key')
140
141
142 class NavigationItem(TreeEntity, TargetURLModel):
143         #: 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.
144         navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
145         #: The text which will be displayed in the navigation. This is a :class:`CharField` instance with max length 50.
146         text = models.CharField(max_length=50)
147         
148         #: The order in which the :class:`NavigationItem` will be displayed.
149         order = models.PositiveSmallIntegerField(default=0)
150         
151         def get_path(self, root=None, pathsep=u' › ', field='text'):
152                 return super(NavigationItem, self).get_path(root, pathsep, field)
153         path = property(get_path)
154         
155         def clean(self):
156                 super(NavigationItem, self).clean()
157                 if bool(self.parent) == bool(self.navigation):
158                         raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
159         
160         def is_active(self, request):
161                 """Returns ``True`` if the :class:`NavigationItem` is considered active for a given request and ``False`` otherwise."""
162                 if self.target_url == request.path:
163                         # Handle the `default` case where the target_url and requested path
164                         # are identical.
165                         return True
166                 
167                 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):
168                         # If there's no target_node, double-check whether it's a full-url
169                         # match.
170                         return True
171                 
172                 if self.target_node and not self.url_or_subpath:
173                         # If there is a target node and it's targeted simply, but the target URL is not
174                         # the same as the request path, check whether the target node is an ancestor
175                         # of the requested node. If so, this is active unless the target node
176                         # is the same as the ``host node`` for this navigation structure.
177                         root = self
178                         
179                         # The common case will be cached items, whose parents are cached with them.
180                         while root.parent is not None:
181                                 root = root.parent
182                         
183                         host_node_id = root.navigation.node_id
184                         if self.target_node.pk != host_node_id and self.target_node.is_ancestor_of(request.node):
185                                 return True
186                 
187                 return False
188         
189         def has_active_descendants(self, request):
190                 """Returns ``True`` if the :class:`NavigationItem` has active descendants and ``False`` otherwise."""
191                 for child in self.get_children():
192                         if child.is_active(request) or child.has_active_descendants(request):
193                                 return True
194                 return False