Added is_active method to Navigation class. Replaced the get_navigation and is_active...
[philo.git] / contrib / navigation / models.py
1 #encoding: utf-8
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
8
9 #from mptt.templatetags.mptt_tags import cache_tree_children
10
11
12 DEFAULT_NAVIGATION_DEPTH = 3
13
14
15 class NavigationManager(TreeManager):
16         
17         # Analagous to contenttypes, cache Navigation to avoid repeated lookups all over the place.
18         # Navigation will probably be used frequently.
19         _cache = {}
20         
21         def closest_navigation(self, node):
22                 """
23                 Returns the set of Navigation objects for a given node's navigation. This
24                 will be the most recent set of defined hosted navigation among the node's
25                 ancestors. Lookups are cached so that subsequent lookups for the same node
26                 don't hit the database.
27                 
28                 TODO: Should this create the auto-generated navigation in "physical" form?
29                 """
30                 try:
31                         return self._get_from_cache(self.db, node)
32                 except KeyError:
33                         # Find the most recent host!
34                         ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation"))
35                         
36                         # Iterate down the ancestors until you find one that:
37                         # a) is cached, or
38                         # b) has hosted navigation.
39                         nodes_to_cache = []
40                         host_node = None
41                         for ancestor in ancestors:
42                                 if self._is_cached(self.db, ancestor) or ancestor.num_navigation > 0:
43                                         host_node = ancestor
44                                         break
45                                 else:
46                                         nodes_to_cache.append(ancestor)
47                         
48                         if not self._is_cached(self.db, host_node):
49                                 self._add_to_cache(self.db, host_node)
50                         
51                         # Cache the queryset instance for every node that was passed over, as well.
52                         hosted_navigation = self._get_from_cache(self.db, host_node)
53                         for node in nodes_to_cache:
54                                 self._add_to_cache(self.db, node, hosted_navigation)
55                 
56                 return hosted_navigation
57         
58         def _add_to_cache(self, using, node, qs=None):
59                 key = getattr(node, 'pk', None)
60                 
61                 if qs is None:
62                         if key is None:
63                                 roots = self.none()
64                         else:
65                                 roots = node.hosted_navigation.select_related('target_node')
66                         
67                         for root in roots:
68                                 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)
69                                 if qs is None:
70                                         qs = root_qs
71                                 else:
72                                         qs |= root_qs
73                 
74                         if qs is None:
75                                 qs = self.none()
76                 
77                 self.__class__._cache.setdefault(using, {})[key] = qs
78         
79         def _get_from_cache(self, using, node):
80                 key = getattr(node, 'pk', None)
81                 return self.__class__._cache[self.db][key]
82         
83         def _is_cached(self, using, node):
84                 try:
85                         self._get_from_cache(using, node)
86                 except KeyError:
87                         return False
88                 return True
89         
90         def clear_cache(self, navigation=None):
91                 """
92                 Clear out the navigation cache. This needs to happen during database flushes
93                 or if a navigation entry is changed to prevent caching of outdated navigation information.
94                 
95                 TODO: call this method from update() and delete()! - But how? Those aren't methods available
96                 from the manager. The only solution would be to make a special QuerySet subclass that calls
97                 this method for each instance.
98                 """
99                 if navigation is None:
100                         self.__class__._cache.clear()
101                 else:
102                         cache = self.__class__._cache[self.db]
103                         for pk, qs in cache.items():
104                                 if navigation in qs:
105                                         cache.pop(pk)
106
107
108 class Navigation(TreeEntity):
109         objects = NavigationManager()
110         text = models.CharField(max_length=50)
111         
112         hosting_node = models.ForeignKey(Node, blank=True, null=True, related_name='hosted_navigation', help_text="Be part of this node's root navigation.")
113         
114         target_node = models.ForeignKey(Node, blank=True, null=True, related_name='targeting_navigation', help_text="Point to this node's url.")
115         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.")
116         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.")
117         
118         order = models.PositiveSmallIntegerField(blank=True, null=True)
119         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.")
120         
121         def __init__(self, *args, **kwargs):
122                 super(Navigation, self).__init__(*args, **kwargs)
123                 self._initial_data = model_to_dict(self)
124         
125         def __unicode__(self):
126                 return self.get_path(field='text', pathsep=u' › ')
127         
128         def clean(self):
129                 # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
130                 if not self.target_node and not self.url_or_subpath:
131                         raise ValidationError("Either a target node or a url must be defined.")
132                 
133                 if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
134                         raise ValidationError("Reversing parameters require a view name and a target node.")
135                 
136                 try:
137                         self.get_target_url()
138                 except NoReverseMatch, e:
139                         raise ValidationError(e.message)
140         
141         def get_target_url(self):
142                 node = self.target_node
143                 if node is not None and node.accepts_subpath and self.url_or_subpath:
144                         if self.reversing_parameters is not None:
145                                 view_name = self.url_or_subpath
146                                 params = self.reversing_parameters
147                                 args = isinstance(params, list) and params or None
148                                 kwargs = isinstance(params, dict) and params or None
149                                 return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
150                         else:
151                                 subpath = self.url_or_subpath
152                                 while subpath and subpath[0] == '/':
153                                         subpath = subpath[1:]
154                                 return '%s%s' % (node.get_absolute_url(), subpath)
155                 elif node is not None:
156                         return node.get_absolute_url()
157                 else:
158                         return self.url_or_subpath
159         target_url = property(get_target_url)
160         
161         def is_active(self, request):
162                 # First check if this particular navigation is active. It is considered active if:
163                 # - the requested node is this instance's target node and its subpath matches the requested path.
164                 # - the requested node is a descendant of this instance's target node and this instance's target
165                 #   node is not the hosting node of this navigation structure.
166                 # - this instance has no target node and the url matches either the request path or the full url.
167                 # - any of this instance's children are active.
168                 node = request.node
169                 
170                 if self.target_node == node:
171                         if self.target_url == request.path:
172                                 return True
173                 elif self.target_node is None:
174                         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):
175                                 return True
176                 elif self.target_node.is_ancestor_of(node) and self.target_node != self.hosting_node:
177                         return True
178                 
179                 # Always fall back to whether the node has active children.
180                 return self.has_active_children(request)
181         
182         def has_active_children(self, request):
183                 for child in self.get_children():
184                         if child.is_active(request):
185                                 return True
186                 return False
187         
188         def _has_changed(self):
189                 if model_to_dict(self) == self._initial_data:
190                         return False
191                 return True
192         
193         def save(self, *args, **kwargs):
194                 super(Navigation, self).save(*args, **kwargs)
195                 if self._has_changed():
196                         self._initial_data = model_to_dict(self)
197                         Navigation.objects.clear_cache(self)
198         
199         def delete(self, *args, **kwargs):
200                 super(Navigation, self).delete(*args, **kwargs)
201                 Navigation.objects.clear_cache(self)
202         
203         class Meta:
204                 ordering = ['order']
205                 verbose_name_plural = 'navigation'