dee16e942d3d557bb594a924fe89209fc2e43723
[philo.git] / contrib / shipherd / models.py
1 #encoding: utf-8
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
10
11
12 DEFAULT_NAVIGATION_DEPTH = 3
13
14
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):
18                 self.node = node
19         
20         def __getitem__(self, key):
21                 return Navigation.objects.get_cache_for(self.node)[key]['root_items']
22         
23         def keys(self):
24                 return Navigation.objects.get_cache_for(self.node).keys()
25
26
27 def navigation(self):
28         if not hasattr(self, '_navigation'):
29                 self._navigation = NavigationQuerySetMapper(self)
30         return self._navigation
31
32
33 Node.navigation = property(navigation)
34
35
36 class NavigationCacheQuerySet(models.query.QuerySet):
37         """
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.
41         """
42         def update(self, *args, **kwargs):
43                 super(NavigationCacheQuerySet, self).update(*args, **kwargs)
44                 Navigation.objects.clear_cache()
45         
46         def delete(self, *args, **kwargs):
47                 super(NavigationCacheQuerySet, self).delete(*args, **kwargs)
48                 Navigation.objects.clear_cache()
49
50
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
55         _cache = {}
56         
57         def get_queryset(self):
58                 return NavigationCacheQuerySet(self.model, using=self._db)
59         
60         def get_cache_for(self, node, update_targets=True):
61                 created = False
62                 if not self.has_cache_for(node):
63                         self.create_cache_for(node)
64                         created = True
65                 
66                 if update_targets and not created:
67                         self.update_targets_for(node)
68                 
69                 return self.__class__._cache[self.db][node]
70         
71         def has_cache_for(self, node):
72                 return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
73         
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)
77                 
78                 nodes_to_cache = []
79                 
80                 for node in ancestors:
81                         if self.has_cache_for(node):
82                                 cache = self.get_cache_for(node).copy()
83                                 break
84                         else:
85                                 nodes_to_cache.insert(0, node)
86                 else:
87                         cache = {}
88                 
89                 for node in nodes_to_cache:
90                         cache = cache.copy()
91                         cache.update(self._build_cache_for(node))
92                         self.__class__._cache.setdefault(self.db, {})[node] = cache
93         
94         def _build_cache_for(self, node):
95                 cache = {}
96                 tree_id_attr = NavigationItem._mptt_meta.tree_id_attr
97                 level_attr = NavigationItem._mptt_meta.level_attr
98                 
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'))
102                         
103                         root_items = []
104                         
105                         for item in items:
106                                 item._is_cached = True
107                                 
108                                 if not hasattr(item, '_cached_children'):
109                                         item._cached_children = []
110                                 
111                                 if item.parent:
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.
114                                         # delayed action.
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)
119                                 else:
120                                         root_items.append(item)
121                         
122                         cache[navigation.key] = {
123                                 'navigation': navigation,
124                                 'root_items': root_items,
125                                 'items': items
126                         }
127                 
128                 return cache
129         
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
134                 # affected by this.
135                 if not self.has_cache_for(node):
136                         # Already cleared.
137                         return
138                 
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)
143         
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()
150                 
151                 items = []
152                 
153                 for cache in caches:
154                         items += cache['items']
155                 
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=items).distinct())
159                 
160                 for cache in caches:
161                         for item in cache['items']:
162                                 item.target_node = targets[targets.index(item.target_node)]
163         
164         def clear_cache(self):
165                 self.__class__._cache.pop(self.db, None)
166
167
168 class Navigation(Entity):
169         objects = NavigationManager()
170         
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.")
174         
175         def __init__(self, *args, **kwargs):
176                 super(Navigation, self).__init__(*args, **kwargs)
177                 self._initial_data = model_to_dict(self)
178         
179         def __unicode__(self):
180                 return "%s[%s]" % (self.node, self.key)
181         
182         def _has_changed(self):
183                 return self._initial_data != model_to_dict(self)
184         
185         def save(self, *args, **kwargs):
186                 super(Navigation, self).save(*args, **kwargs)
187                 
188                 if self._has_changed():
189                         Navigation.objects.clear_cache_for(self.node)
190                         self._initial_data = model_to_dict(self)
191         
192         def delete(self, *args, **kwargs):
193                 super(Navigation, self).delete(*args, **kwargs)
194                 Navigation.objects.clear_cache_for(self.node)
195         
196         class Meta:
197                 unique_together = ('node', 'key')
198
199
200 class NavigationItemManager(TreeManager):
201         use_for_related = True
202         
203         def get_queryset(self):
204                 return NavigationCacheQuerySet(self.model, using=self._db)
205
206
207 class NavigationItem(TreeEntity):
208         objects = NavigationItemManager()
209         
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)
212         
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.")
216         
217         order = models.PositiveSmallIntegerField(default=0)
218         
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
223         
224         def __unicode__(self):
225                 return self.get_path(field='text', pathsep=u' › ')
226         
227         def clean(self):
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.")
231                 
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.")
234                 
235                 try:
236                         self.get_target_url()
237                 except NoReverseMatch, e:
238                         raise ValidationError(e.message)
239                 
240                 if bool(self.parent) == bool(self.navigation):
241                         raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
242         
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)
252                         else:
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()
259                 else:
260                         return self.url_or_subpath
261         target_url = property(get_target_url)
262         
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
266                         # are identical.
267                         return True
268                 
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
271                         # match.
272                         return True
273                 
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.
279                         try:
280                                 host_node = self.get_root().navigation.node
281                         except AttributeError:
282                                 pass
283                         else:
284                                 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
285                                         return True
286                 
287                 return False
288         
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):
292                                 return True
293                 return False
294         
295         def _has_changed(self):
296                 if model_to_dict(self) == self._initial_data:
297                         return False
298                 return True
299         
300         def _clear_cache(self):
301                 try:
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:
306                         pass
307         
308         def save(self, *args, **kwargs):
309                 super(NavigationItem, self).save(*args, **kwargs)
310                 
311                 if self._has_changed():
312                         self._clear_cache()
313         
314         def delete(self, *args, **kwargs):
315                 super(NavigationItem, self).delete(*args, **kwargs)
316                 self._clear_cache()