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.base import TreeEntity, TreeManager, Entity
11 from philo.models.nodes import Node, TargetURLModel
14 DEFAULT_NAVIGATION_DEPTH = 3
17 class NavigationQuerySetMapper(object, DictMixin):
18 """This class exists to prevent setting of items in the navigation cache through node.navigation."""
19 def __init__(self, node):
22 def __getitem__(self, key):
23 return Navigation.objects.get_cache_for(self.node)[key]['root_items']
26 return Navigation.objects.get_cache_for(self.node).keys()
30 if not hasattr(self, '_navigation'):
31 self._navigation = NavigationQuerySetMapper(self)
32 return self._navigation
35 Node.navigation = property(navigation)
38 class NavigationCacheQuerySet(models.query.QuerySet):
40 This subclass will trigger general cache clearing for Navigation.objects when a mass
41 update or deletion is performed. As there is no convenient way to iterate over the
42 changed or deleted instances, there's no way to be more precise about what gets cleared.
44 def update(self, *args, **kwargs):
45 super(NavigationCacheQuerySet, self).update(*args, **kwargs)
46 Navigation.objects.clear_cache()
48 def delete(self, *args, **kwargs):
49 super(NavigationCacheQuerySet, self).delete(*args, **kwargs)
50 Navigation.objects.clear_cache()
53 class NavigationManager(models.Manager):
54 # Since navigation is going to be hit frequently and changed
55 # relatively infrequently, cache it. Analogous to contenttypes.
56 use_for_related = True
59 def get_queryset(self):
60 return NavigationCacheQuerySet(self.model, using=self._db)
62 def get_cache_for(self, node, update_targets=True):
64 if not self.has_cache_for(node):
65 self.create_cache_for(node)
68 if update_targets and not created:
69 self.update_targets_for(node)
71 return self.__class__._cache[self.db][node]
73 def has_cache_for(self, node):
74 return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
76 def create_cache_for(self, node):
77 "This method loops through the nodes ancestors and caches all unique navigation keys."
78 ancestors = node.get_ancestors(ascending=True, include_self=True)
82 for node in ancestors:
83 if self.has_cache_for(node):
84 cache = self.get_cache_for(node).copy()
87 nodes_to_cache.insert(0, node)
91 for node in nodes_to_cache:
93 cache.update(self._build_cache_for(node))
94 self.__class__._cache.setdefault(self.db, {})[node] = cache
96 def _build_cache_for(self, node):
98 tree_id_attr = NavigationItem._mptt_meta.tree_id_attr
99 level_attr = NavigationItem._mptt_meta.level_attr
101 for navigation in node.navigation_set.all():
102 tree_ids = navigation.roots.values_list(tree_id_attr)
103 items = list(NavigationItem.objects.filter(**{'%s__in' % tree_id_attr: tree_ids, '%s__lt' % level_attr: navigation.depth}).order_by('order', 'lft'))
108 item._is_cached = True
110 if not hasattr(item, '_cached_children'):
111 item._cached_children = []
114 # alternatively, if I don't want to force it to a list, I could keep track of
115 # instances where the parent hasn't yet been met and do this step later for them.
117 item.parent = items[items.index(item.parent)]
118 if not hasattr(item.parent, '_cached_children'):
119 item.parent._cached_children = []
120 item.parent._cached_children.append(item)
122 root_items.append(item)
124 cache[navigation.key] = {
125 'navigation': navigation,
126 'root_items': root_items,
132 def clear_cache_for(self, node):
133 # Clear the cache for this node and all its descendants. The
134 # navigation for this node has probably changed, and for now,
135 # it isn't worth it to only clear the descendants actually
137 if not self.has_cache_for(node):
141 descendants = node.get_descendants(include_self=True)
142 cache = self.__class__._cache[self.db]
143 for node in descendants:
144 cache.pop(node, None)
146 def update_targets_for(self, node):
147 # Manually update a cache's target nodes in case something's changed there.
148 # This should be a less complex operation than reloading the models each
149 # time. Not as good as selective updates... but not much to be done
150 # about that. TODO: Benchmark it.
151 caches = self.__class__._cache[self.db][node].values()
156 target_pks |= set([item.target_node_id for item in cache['items']])
158 # A distinct query is not strictly necessary. TODO: benchmark the efficiency
159 # with/without distinct.
160 targets = list(Node.objects.filter(pk__in=target_pks).distinct())
163 for item in cache['items']:
164 if item.target_node_id:
165 item.target_node = targets[targets.index(item.target_node)]
167 def clear_cache(self):
168 self.__class__._cache.pop(self.db, None)
171 class Navigation(Entity):
172 objects = NavigationManager()
174 node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
175 key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
176 depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
178 def __init__(self, *args, **kwargs):
179 super(Navigation, self).__init__(*args, **kwargs)
180 self._initial_data = model_to_dict(self)
182 def __unicode__(self):
183 return "%s[%s]" % (self.node, self.key)
185 def _has_changed(self):
186 return self._initial_data != model_to_dict(self)
188 def save(self, *args, **kwargs):
189 super(Navigation, self).save(*args, **kwargs)
191 if self._has_changed():
192 Navigation.objects.clear_cache_for(self.node)
193 self._initial_data = model_to_dict(self)
195 def delete(self, *args, **kwargs):
196 super(Navigation, self).delete(*args, **kwargs)
197 Navigation.objects.clear_cache_for(self.node)
200 unique_together = ('node', 'key')
203 class NavigationItemManager(TreeManager):
204 use_for_related = True
206 def get_queryset(self):
207 return NavigationCacheQuerySet(self.model, using=self._db)
210 class NavigationItem(TreeEntity, TargetURLModel):
211 objects = NavigationItemManager()
213 navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
214 text = models.CharField(max_length=50)
216 order = models.PositiveSmallIntegerField(default=0)
218 def __init__(self, *args, **kwargs):
219 super(NavigationItem, self).__init__(*args, **kwargs)
220 self._initial_data = model_to_dict(self)
221 self._is_cached = False
223 def __unicode__(self):
224 return self.get_path(field='text', pathsep=u' › ')
227 super(NavigationItem, self).clean()
228 if bool(self.parent) == bool(self.navigation):
229 raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
231 def is_active(self, request):
232 if self.target_url == request.path:
233 # Handle the `default` case where the target_url and requested path
237 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):
238 # If there's no target_node, double-check whether it's a full-url
242 if self.target_node and not self.url_or_subpath:
243 # If there is a target node and it's targeted simply, but the target URL is not
244 # the same as the request path, check whether the target node is an ancestor
245 # of the requested node. If so, this is active unless the target node
246 # is the same as the ``host node`` for this navigation structure.
248 host_node = self.get_root().navigation.node
249 except AttributeError:
252 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
257 def has_active_descendants(self, request):
258 for child in self.get_children():
259 if child.is_active(request) or child.has_active_descendants(request):
263 def _has_changed(self):
264 if model_to_dict(self) == self._initial_data:
268 def _clear_cache(self):
270 root = self.get_root()
271 if self.get_level() < root.navigation.depth:
272 Navigation.objects.clear_cache_for(self.get_root().navigation.node)
273 except AttributeError:
276 def save(self, *args, **kwargs):
277 super(NavigationItem, self).save(*args, **kwargs)
279 if self._has_changed():
282 def delete(self, *args, **kwargs):
283 super(NavigationItem, self).delete(*args, **kwargs)