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