2 from UserDict import DictMixin
4 from django.core.exceptions import ValidationError
5 from django.core.urlresolvers import NoReverseMatch
6 from django.core.validators import RegexValidator, MinValueValidator
7 from django.db import models
8 from django.forms.models import model_to_dict
10 from philo.models import TreeEntity, Node, TreeManager, Entity, TargetURLModel
13 DEFAULT_NAVIGATION_DEPTH = 3
16 class NavigationQuerySetMapper(object, DictMixin):
17 """This class exists to prevent setting of items in the navigation cache through node.navigation."""
18 def __init__(self, node):
21 def __getitem__(self, key):
22 return Navigation.objects.get_cache_for(self.node)[key]['root_items']
25 return Navigation.objects.get_cache_for(self.node).keys()
29 if not hasattr(self, '_navigation'):
30 self._navigation = NavigationQuerySetMapper(self)
31 return self._navigation
34 Node.navigation = property(navigation)
37 class NavigationCacheQuerySet(models.query.QuerySet):
39 This subclass will trigger general cache clearing for Navigation.objects when a mass
40 update or deletion is performed. As there is no convenient way to iterate over the
41 changed or deleted instances, there's no way to be more precise about what gets cleared.
43 def update(self, *args, **kwargs):
44 super(NavigationCacheQuerySet, self).update(*args, **kwargs)
45 Navigation.objects.clear_cache()
47 def delete(self, *args, **kwargs):
48 super(NavigationCacheQuerySet, self).delete(*args, **kwargs)
49 Navigation.objects.clear_cache()
52 class NavigationManager(models.Manager):
53 # Since navigation is going to be hit frequently and changed
54 # relatively infrequently, cache it. Analogous to contenttypes.
55 use_for_related = True
58 def get_queryset(self):
59 return NavigationCacheQuerySet(self.model, using=self._db)
61 def get_cache_for(self, node, update_targets=True):
63 if not self.has_cache_for(node):
64 self.create_cache_for(node)
67 if update_targets and not created:
68 self.update_targets_for(node)
70 return self.__class__._cache[self.db][node]
72 def has_cache_for(self, node):
73 return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
75 def create_cache_for(self, node):
76 "This method loops through the nodes ancestors and caches all unique navigation keys."
77 ancestors = node.get_ancestors(ascending=True, include_self=True)
81 for node in ancestors:
82 if self.has_cache_for(node):
83 cache = self.get_cache_for(node).copy()
86 nodes_to_cache.insert(0, node)
90 for node in nodes_to_cache:
92 cache.update(self._build_cache_for(node))
93 self.__class__._cache.setdefault(self.db, {})[node] = cache
95 def _build_cache_for(self, node):
97 tree_id_attr = NavigationItem._mptt_meta.tree_id_attr
98 level_attr = NavigationItem._mptt_meta.level_attr
100 for navigation in node.navigation_set.all():
101 tree_ids = navigation.roots.values_list(tree_id_attr)
102 items = list(NavigationItem.objects.filter(**{'%s__in' % tree_id_attr: tree_ids, '%s__lt' % level_attr: navigation.depth}).order_by('order', 'lft'))
107 item._is_cached = True
109 if not hasattr(item, '_cached_children'):
110 item._cached_children = []
113 # alternatively, if I don't want to force it to a list, I could keep track of
114 # instances where the parent hasn't yet been met and do this step later for them.
116 item.parent = items[items.index(item.parent)]
117 if not hasattr(item.parent, '_cached_children'):
118 item.parent._cached_children = []
119 item.parent._cached_children.append(item)
121 root_items.append(item)
123 cache[navigation.key] = {
124 'navigation': navigation,
125 'root_items': root_items,
131 def clear_cache_for(self, node):
132 # Clear the cache for this node and all its descendants. The
133 # navigation for this node has probably changed, and for now,
134 # it isn't worth it to only clear the descendants actually
136 if not self.has_cache_for(node):
140 descendants = node.get_descendants(include_self=True)
141 cache = self.__class__._cache[self.db]
142 for node in descendants:
143 cache.pop(node, None)
145 def update_targets_for(self, node):
146 # Manually update a cache's target nodes in case something's changed there.
147 # This should be a less complex operation than reloading the models each
148 # time. Not as good as selective updates... but not much to be done
149 # about that. TODO: Benchmark it.
150 caches = self.__class__._cache[self.db][node].values()
155 target_pks |= set([item.target_node_id for item in cache['items']])
157 # A distinct query is not strictly necessary. TODO: benchmark the efficiency
158 # with/without distinct.
159 targets = list(Node.objects.filter(pk__in=target_pks).distinct())
162 for item in cache['items']:
163 if item.target_node_id:
164 item.target_node = targets[targets.index(item.target_node)]
166 def clear_cache(self):
167 self.__class__._cache.pop(self.db, None)
170 class Navigation(Entity):
171 objects = NavigationManager()
173 node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
174 key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
175 depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
177 def __init__(self, *args, **kwargs):
178 super(Navigation, self).__init__(*args, **kwargs)
179 self._initial_data = model_to_dict(self)
181 def __unicode__(self):
182 return "%s[%s]" % (self.node, self.key)
184 def _has_changed(self):
185 return self._initial_data != model_to_dict(self)
187 def save(self, *args, **kwargs):
188 super(Navigation, self).save(*args, **kwargs)
190 if self._has_changed():
191 Navigation.objects.clear_cache_for(self.node)
192 self._initial_data = model_to_dict(self)
194 def delete(self, *args, **kwargs):
195 super(Navigation, self).delete(*args, **kwargs)
196 Navigation.objects.clear_cache_for(self.node)
199 unique_together = ('node', 'key')
202 class NavigationItemManager(TreeManager):
203 use_for_related = True
205 def get_queryset(self):
206 return NavigationCacheQuerySet(self.model, using=self._db)
209 class NavigationItem(TreeEntity, TargetURLModel):
210 objects = NavigationItemManager()
212 navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
213 text = models.CharField(max_length=50)
215 order = models.PositiveSmallIntegerField(default=0)
217 def __init__(self, *args, **kwargs):
218 super(NavigationItem, self).__init__(*args, **kwargs)
219 self._initial_data = model_to_dict(self)
220 self._is_cached = False
222 def __unicode__(self):
223 return self.get_path(field='text', pathsep=u' › ')
226 super(NavigationItem, self).clean()
227 if bool(self.parent) == bool(self.navigation):
228 raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
230 def is_active(self, request):
231 if self.target_url == request.path:
232 # Handle the `default` case where the target_url and requested path
236 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):
237 # If there's no target_node, double-check whether it's a full-url
241 if self.target_node and not self.url_or_subpath:
242 # If there is a target node and it's targeted simply, but the target URL is not
243 # the same as the request path, check whether the target node is an ancestor
244 # of the requested node. If so, this is active unless the target node
245 # is the same as the ``host node`` for this navigation structure.
247 host_node = self.get_root().navigation.node
248 except AttributeError:
251 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
256 def has_active_descendants(self, request):
257 for child in self.get_children():
258 if child.is_active(request) or child.has_active_descendants(request):
262 def _has_changed(self):
263 if model_to_dict(self) == self._initial_data:
267 def _clear_cache(self):
269 root = self.get_root()
270 if self.get_level() < root.navigation.depth:
271 Navigation.objects.clear_cache_for(self.get_root().navigation.node)
272 except AttributeError:
275 def save(self, *args, **kwargs):
276 super(NavigationItem, self).save(*args, **kwargs)
278 if self._has_changed():
281 def delete(self, *args, **kwargs):
282 super(NavigationItem, self).delete(*args, **kwargs)