Moved NavigationManager cache handling into custom methods for better readability...
[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 philo.models import TreeEntity, JSONField, Node, TreeManager
6 from philo.validators import RedirectValidator
7
8 #from mptt.templatetags.mptt_tags import cache_tree_children
9
10
11 DEFAULT_NAVIGATION_DEPTH = 3
12
13
14 class NavigationManager(TreeManager):
15         
16         # Analagous to contenttypes, cache Navigation to avoid repeated lookups all over the place.
17         # Navigation will probably be used frequently.
18         _cache = {}
19         
20         def for_node(self, node):
21                 """
22                 Returns the set of Navigation objects for a given node's navigation. This
23                 will be the most recent set of defined hosted navigation among the node's
24                 ancestors. Lookups are cached so that subsequent lookups for the same node
25                 don't hit the database.
26                 
27                 TODO: Should this create the auto-generated navigation in "physical" form?
28                 """
29                 try:
30                         return self._get_from_cache(self.db, node)
31                 except KeyError:
32                         # Find the most recent host!
33                         ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation"))
34                         
35                         # Iterate down the ancestors until you find one that:
36                         # a) is cached, or
37                         # b) has hosted navigation.
38                         nodes_to_cache = []
39                         host_node = None
40                         for ancestor in ancestors:
41                                 if self._is_cached(self.db, ancestor) or ancestor.num_navigation > 0:
42                                         host_node = ancestor
43                                         break
44                                 else:
45                                         nodes_to_cache.append(ancestor)
46                         
47                         if not self._is_cached(self.db, host_node):
48                                 self._add_to_cache(self.db, host_node)
49                         
50                         # Cache the queryset instance for every node that was passed over, as well.
51                         hosted_navigation = self._get_from_cache(self.db, host_node)
52                         for node in nodes_to_cache:
53                                 self._add_to_cache(self.db, node, hosted_navigation)
54                 
55                 return hosted_navigation
56         
57         def _add_to_cache(self, using, node, qs=None):
58                 if node is None or node.pk is None:
59                         qs = self.none()
60                         key = None
61                 else:
62                         key = node.pk
63                 
64                 if qs is None:
65                         qs = node.hosted_navigation.select_related('target_node')
66                 
67                 self.__class__._cache.setdefault(using, {})[key] = qs
68         
69         def _get_from_cache(self, using, node):
70                 key = node.pk
71                 return self.__class__._cache[self.db][key]
72         
73         def _is_cached(self, using, node):
74                 try:
75                         self._get_from_cache(using, node)
76                 except KeyError:
77                         return False
78                 return True
79         
80         def clear_cache(self, navigation=None):
81                 """
82                 Clear out the navigation cache. This needs to happen during database flushes
83                 or if a navigation entry is changed to prevent caching of outdated navigation information.
84                 
85                 TODO: call this method from update() and delete()!
86                 """
87                 if navigation is None:
88                         self.__class__._cache.clear()
89                 else:
90                         cache = self.__class__._cache[self.db]
91                         for pk in cache.keys():
92                                 for qs in cache[pk]:
93                                         if navigation in qs:
94                                                 cache.pop(pk)
95                                                 break
96                                         else:
97                                                 for instance in qs:
98                                                         if navigation.is_descendant(instance):
99                                                                 cache.pop(pk)
100                                                                 break
101                                                 # necessary?
102                                                 if pk not in cache:
103                                                         break
104
105
106 class Navigation(TreeEntity):
107         objects = NavigationManager()
108         text = models.CharField(max_length=50)
109         
110         hosting_node = models.ForeignKey(Node, blank=True, null=True, related_name='hosted_navigation', help_text="Be part of this node's root navigation.")
111         
112         target_node = models.ForeignKey(Node, blank=True, null=True, related_name='targeting_navigation', help_text="Point to this node's url.")
113         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.")
114         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.")
115         
116         order = models.PositiveSmallIntegerField(blank=True, null=True)
117         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.")
118         
119         def clean(self):
120                 # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
121                 if not self.target_node and not self.url_or_subpath:
122                         raise ValidationError("Either a target node or a url must be defined.")
123                 
124                 if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
125                         raise ValidationError("Reversing parameters require a view name and a target node.")
126                 
127                 try:
128                         self.get_target_url()
129                 except NoReverseMatch, e:
130                         raise ValidationError(e.message)
131         
132         def get_target_url(self):
133                 node = self.target_node
134                 if node is not None and node.accepts_subpath and self.url_or_subpath:
135                         if self.reversing_parameters is not None:
136                                 view_name = self.url_or_subpath
137                                 params = self.reversing_parameters
138                                 args = isinstance(params, list) and params or None
139                                 kwargs = isinstance(params, dict) and params or None
140                                 return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
141                         else:
142                                 subpath = self.url_or_subpath
143                                 while subpath and subpath[0] == '/':
144                                         subpath = subpath[1:]
145                                 return '%s%s' % (node.get_absolute_url(), subpath)
146                 elif node is not None:
147                         return node.get_absolute_url()
148                 else:
149                         return self.url_or_subpath
150         target_url = property(get_target_url)
151         
152         def __unicode__(self):
153                 return self.get_path(field='text', pathsep=u' › ')
154         
155         # TODO: Add delete and save methods to handle cache clearing.
156         
157         class Meta:
158                 ordering = ['order']
159                 verbose_name_plural = 'navigation'