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