2 from django.core.exceptions import ValidationError
3 from django.core.urlresolvers import NoReverseMatch
4 from django.core.validators import RegexValidator, MinValueValidator
5 from django.db import models
6 from django.forms.models import model_to_dict
7 from philo.models import TreeEntity, JSONField, Node, TreeManager, Entity
8 from philo.validators import RedirectValidator
9 from UserDict import DictMixin
12 DEFAULT_NAVIGATION_DEPTH = 3
15 class NavigationQuerySetMapper(object, DictMixin):
16 """This class exists to prevent setting of items in the navigation cache through node.navigation."""
17 def __init__(self, node):
20 def __getitem__(self, key):
21 return Navigation.objects.get_cache_for(self.node)[key]['root_items']
24 return Navigation.objects.get_cache_for(self.node).keys()
28 if not hasattr(self, '_navigation'):
29 self._navigation = NavigationQuerySetMapper(self)
30 return self._navigation
33 Node.navigation = property(navigation)
36 class NavigationCacheQuerySet(models.query.QuerySet):
38 This subclass will trigger general cache clearing for Navigation.objects when a mass
39 update or deletion is performed. As there is no convenient way to iterate over the
40 changed or deleted instances, there's no way to be more precise about what gets cleared.
42 def update(self, *args, **kwargs):
43 super(NavigationCacheQuerySet, self).update(*args, **kwargs)
44 Navigation.objects.clear_cache()
46 def delete(self, *args, **kwargs):
47 super(NavigationCacheQuerySet, self).delete(*args, **kwargs)
48 Navigation.objects.clear_cache()
51 class NavigationManager(models.Manager):
52 # Since navigation is going to be hit frequently and changed
53 # relatively infrequently, cache it. Analogous to contenttypes.
54 use_for_related = True
57 def get_queryset(self):
58 return NavigationCacheQuerySet(self.model, using=self._db)
60 def get_cache_for(self, node, update_targets=True):
62 if not self.has_cache_for(node):
63 self.create_cache_for(node)
66 if update_targets and not created:
67 self.update_targets_for(node)
69 return self.__class__._cache[self.db][node]
71 def has_cache_for(self, node):
72 return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
74 def create_cache_for(self, node):
75 "This method loops through the nodes ancestors and caches all unique navigation keys."
76 ancestors = node.get_ancestors(ascending=True, include_self=True)
80 for node in ancestors:
81 if self.has_cache_for(node):
82 cache = self.get_cache_for(node).copy()
85 nodes_to_cache.insert(0, node)
89 for node in nodes_to_cache:
91 cache.update(self._build_cache_for(node))
92 self.__class__._cache.setdefault(self.db, {})[node] = cache
94 def _build_cache_for(self, node):
96 tree_id_attr = NavigationItem._mptt_meta.tree_id_attr
97 level_attr = NavigationItem._mptt_meta.level_attr
99 for navigation in node.navigation_set.all():
100 tree_ids = navigation.roots.values_list(tree_id_attr)
101 items = list(NavigationItem.objects.filter(**{'%s__in' % tree_id_attr: tree_ids, '%s__lt' % level_attr: navigation.depth}).order_by('order', 'lft'))
106 item._is_cached = True
108 if not hasattr(item, '_cached_children'):
109 item._cached_children = []
112 # alternatively, if I don't want to force it to a list, I could keep track of
113 # instances where the parent hasn't yet been met and do this step later for them.
115 item.parent = items[items.index(item.parent)]
116 if not hasattr(item.parent, '_cached_children'):
117 item.parent._cached_children = []
118 item.parent._cached_children.append(item)
120 root_items.append(item)
122 cache[navigation.key] = {
123 'navigation': navigation,
124 'root_items': root_items,
130 def clear_cache_for(self, node):
131 # Clear the cache for this node and all its descendants. The
132 # navigation for this node has probably changed, and for now,
133 # it isn't worth it to only clear the descendants actually
135 if not self.has_cache_for(node):
139 descendants = node.get_descendants(include_self=True)
140 cache = self.__class__._cache[self.db]
141 for node in descendants:
142 cache.pop(node, None)
144 def update_targets_for(self, node):
145 # Manually update a cache's target nodes in case something's changed there.
146 # This should be a less complex operation than reloading the models each
147 # time. Not as good as selective updates... but not much to be done
148 # about that. TODO: Benchmark it.
149 caches = self.__class__._cache[self.db][node].values()
154 items += cache['items']
156 # A distinct query is not strictly necessary. TODO: benchmark the efficiency
157 # with/without distinct.
158 targets = list(Node.objects.filter(navigation_items__in=cache['items']).distinct())
161 for item in cache['items']:
162 item.target_node = targets[targets.index(item.target_node)]
164 def clear_cache(self):
165 self.__class__._cache.pop(self.db, None)
168 class Navigation(Entity):
169 objects = NavigationManager()
171 node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
172 key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.")
173 depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
175 def __init__(self, *args, **kwargs):
176 super(Navigation, self).__init__(*args, **kwargs)
177 self._initial_data = model_to_dict(self)
179 def __unicode__(self):
180 return "%s[%s]" % (self.node, self.key)
182 def _has_changed(self):
183 return self._initial_data != model_to_dict(self)
185 def save(self, *args, **kwargs):
186 super(Navigation, self).save(*args, **kwargs)
188 if self._has_changed():
189 Navigation.objects.clear_cache_for(self.node)
190 self._initial_data = model_to_dict(self)
192 def delete(self, *args, **kwargs):
193 super(Navigation, self).delete(*args, **kwargs)
194 Navigation.objects.clear_cache_for(self.node)
197 unique_together = ('node', 'key')
200 class NavigationItemManager(TreeManager):
201 use_for_related = True
203 def get_queryset(self):
204 return NavigationCacheQuerySet(self.model, using=self._db)
207 class NavigationItem(TreeEntity):
208 objects = NavigationItemManager()
210 navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
211 text = models.CharField(max_length=50)
213 target_node = models.ForeignKey(Node, blank=True, null=True, related_name='navigation_items', help_text="Point to this node's url.")
214 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.")
215 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.")
217 order = models.PositiveSmallIntegerField(default=0)
219 def __init__(self, *args, **kwargs):
220 super(NavigationItem, self).__init__(*args, **kwargs)
221 self._initial_data = model_to_dict(self)
222 self._is_cached = False
224 def __unicode__(self):
225 return self.get_path(field='text', pathsep=u' › ')
228 # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
229 if not self.target_node and not self.url_or_subpath:
230 raise ValidationError("Either a target node or a url must be defined.")
232 if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
233 raise ValidationError("Reversing parameters require a view name and a target node.")
236 self.get_target_url()
237 except NoReverseMatch, e:
238 raise ValidationError(e.message)
240 if bool(self.parent) == bool(self.navigation):
241 raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
243 def get_target_url(self):
244 node = self.target_node
245 if node is not None and node.accepts_subpath and self.url_or_subpath:
246 if self.reversing_parameters is not None:
247 view_name = self.url_or_subpath
248 params = self.reversing_parameters
249 args = isinstance(params, list) and params or None
250 kwargs = isinstance(params, dict) and params or None
251 return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
253 subpath = self.url_or_subpath
254 while subpath and subpath[0] == '/':
255 subpath = subpath[1:]
256 return '%s%s' % (node.get_absolute_url(), subpath)
257 elif node is not None:
258 return node.get_absolute_url()
260 return self.url_or_subpath
261 target_url = property(get_target_url)
263 def is_active(self, request):
264 if self.target_url == request.path:
265 # Handle the `default` case where the target_url and requested path
269 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):
270 # If there's no target_node, double-check whether it's a full-url
274 if self.target_node and not self.url_or_subpath:
275 # If there is a target node and it's targeted simply, but the target URL is not
276 # the same as the request path, check whether the target node is an ancestor
277 # of the requested node. If so, this is active unless the target node
278 # is the same as the ``host node`` for this navigation structure.
280 host_node = self.get_root().navigation.node
281 except AttributeError:
284 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
289 def has_active_descendants(self, request):
290 for child in self.get_children():
291 if child.is_active(request) or child.has_active_descendants(request):
295 def _has_changed(self):
296 if model_to_dict(self) == self._initial_data:
300 def _clear_cache(self):
302 root = self.get_root()
303 if self.get_level() < root.navigation.depth:
304 Navigation.objects.clear_cache_for(self.get_root().navigation.node)
305 except AttributeError:
308 def save(self, *args, **kwargs):
309 super(NavigationItem, self).save(*args, **kwargs)
311 if self._has_changed():
314 def delete(self, *args, **kwargs):
315 super(NavigationItem, self).delete(*args, **kwargs)