a09f385a1bb41986d9382ae6ce29d7111006d23d
[philo.git] / philo / contrib / shipherd / models.py
1 #encoding: utf-8
2 from UserDict import DictMixin
3
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
9
10 from philo.models import TreeEntity, Node, TreeManager, Entity, TargetURLModel
11
12
13 DEFAULT_NAVIGATION_DEPTH = 3
14
15
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):
19                 self.node = node
20         
21         def __getitem__(self, key):
22                 return Navigation.objects.get_cache_for(self.node)[key]['root_items']
23         
24         def keys(self):
25                 return Navigation.objects.get_cache_for(self.node).keys()
26
27
28 def navigation(self):
29         if not hasattr(self, '_navigation'):
30                 self._navigation = NavigationQuerySetMapper(self)
31         return self._navigation
32
33
34 Node.navigation = property(navigation)
35
36
37 class NavigationCacheQuerySet(models.query.QuerySet):
38         """
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.
42         """
43         def update(self, *args, **kwargs):
44                 super(NavigationCacheQuerySet, self).update(*args, **kwargs)
45                 Navigation.objects.clear_cache()
46         
47         def delete(self, *args, **kwargs):
48                 super(NavigationCacheQuerySet, self).delete(*args, **kwargs)
49                 Navigation.objects.clear_cache()
50
51
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
56         _cache = {}
57         
58         def get_queryset(self):
59                 return NavigationCacheQuerySet(self.model, using=self._db)
60         
61         def get_cache_for(self, node, update_targets=True):
62                 created = False
63                 if not self.has_cache_for(node):
64                         self.create_cache_for(node)
65                         created = True
66                 
67                 if update_targets and not created:
68                         self.update_targets_for(node)
69                 
70                 return self.__class__._cache[self.db][node]
71         
72         def has_cache_for(self, node):
73                 return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
74         
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)
78                 
79                 nodes_to_cache = []
80                 
81                 for node in ancestors:
82                         if self.has_cache_for(node):
83                                 cache = self.get_cache_for(node).copy()
84                                 break
85                         else:
86                                 nodes_to_cache.insert(0, node)
87                 else:
88                         cache = {}
89                 
90                 for node in nodes_to_cache:
91                         cache = cache.copy()
92                         cache.update(self._build_cache_for(node))
93                         self.__class__._cache.setdefault(self.db, {})[node] = cache
94         
95         def _build_cache_for(self, node):
96                 cache = {}
97                 tree_id_attr = NavigationItem._mptt_meta.tree_id_attr
98                 level_attr = NavigationItem._mptt_meta.level_attr
99                 
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'))
103                         
104                         root_items = []
105                         
106                         for item in items:
107                                 item._is_cached = True
108                                 
109                                 if not hasattr(item, '_cached_children'):
110                                         item._cached_children = []
111                                 
112                                 if item.parent:
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.
115                                         # delayed action.
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)
120                                 else:
121                                         root_items.append(item)
122                         
123                         cache[navigation.key] = {
124                                 'navigation': navigation,
125                                 'root_items': root_items,
126                                 'items': items
127                         }
128                 
129                 return cache
130         
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
135                 # affected by this.
136                 if not self.has_cache_for(node):
137                         # Already cleared.
138                         return
139                 
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)
144         
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()
151                 
152                 target_pks = set()
153                 
154                 for cache in caches:
155                         target_pks |= set([item.target_node_id for item in cache['items']])
156                 
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())
160                 
161                 for cache in caches:
162                         for item in cache['items']:
163                                 if item.target_node_id:
164                                         item.target_node = targets[targets.index(item.target_node)]
165         
166         def clear_cache(self):
167                 self.__class__._cache.pop(self.db, None)
168
169
170 class Navigation(Entity):
171         objects = NavigationManager()
172         
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.")
176         
177         def __init__(self, *args, **kwargs):
178                 super(Navigation, self).__init__(*args, **kwargs)
179                 self._initial_data = model_to_dict(self)
180         
181         def __unicode__(self):
182                 return "%s[%s]" % (self.node, self.key)
183         
184         def _has_changed(self):
185                 return self._initial_data != model_to_dict(self)
186         
187         def save(self, *args, **kwargs):
188                 super(Navigation, self).save(*args, **kwargs)
189                 
190                 if self._has_changed():
191                         Navigation.objects.clear_cache_for(self.node)
192                         self._initial_data = model_to_dict(self)
193         
194         def delete(self, *args, **kwargs):
195                 super(Navigation, self).delete(*args, **kwargs)
196                 Navigation.objects.clear_cache_for(self.node)
197         
198         class Meta:
199                 unique_together = ('node', 'key')
200
201
202 class NavigationItemManager(TreeManager):
203         use_for_related = True
204         
205         def get_queryset(self):
206                 return NavigationCacheQuerySet(self.model, using=self._db)
207
208
209 class NavigationItem(TreeEntity, TargetURLModel):
210         objects = NavigationItemManager()
211         
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)
214         
215         order = models.PositiveSmallIntegerField(default=0)
216         
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
221         
222         def __unicode__(self):
223                 return self.get_path(field='text', pathsep=u' › ')
224         
225         def clean(self):
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.")
229         
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
233                         # are identical.
234                         return True
235                 
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
238                         # match.
239                         return True
240                 
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.
246                         try:
247                                 host_node = self.get_root().navigation.node
248                         except AttributeError:
249                                 pass
250                         else:
251                                 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
252                                         return True
253                 
254                 return False
255         
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):
259                                 return True
260                 return False
261         
262         def _has_changed(self):
263                 if model_to_dict(self) == self._initial_data:
264                         return False
265                 return True
266         
267         def _clear_cache(self):
268                 try:
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:
273                         pass
274         
275         def save(self, *args, **kwargs):
276                 super(NavigationItem, self).save(*args, **kwargs)
277                 
278                 if self._has_changed():
279                         self._clear_cache()
280         
281         def delete(self, *args, **kwargs):
282                 super(NavigationItem, self).delete(*args, **kwargs)
283                 self._clear_cache()