Moving the LICENSE file to the root.
[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.base import TreeEntity, TreeManager, Entity
11 from philo.models.nodes import Node, TargetURLModel
12
13
14 DEFAULT_NAVIGATION_DEPTH = 3
15
16
17 class NavigationMapper(object, DictMixin):
18         """
19         The :class:`NavigationMapper` is a dictionary-like object which allows easy fetching of the root items of a navigation for a node according to a key. The fetching goes through the :class:`NavigationManager` and can thus take advantage of the navigation cache. A :class:`NavigationMapper` instance will be available on each node instance as :attr:`Node.navigation` if :mod:`~philo.contrib.shipherd` is in the :setting:`INSTALLED_APPS`
20         
21         """
22         def __init__(self, node):
23                 self.node = node
24         
25         def __getitem__(self, key):
26                 return Navigation.objects.get_cache_for(self.node)[key]['root_items']
27         
28         def keys(self):
29                 return Navigation.objects.get_cache_for(self.node).keys()
30
31
32 def navigation(self):
33         if not hasattr(self, '_navigation'):
34                 self._navigation = NavigationMapper(self)
35         return self._navigation
36
37
38 Node.navigation = property(navigation)
39
40
41 class NavigationCacheQuerySet(models.query.QuerySet):
42         """
43         This subclass will trigger general cache clearing for Navigation.objects when a mass
44         update or deletion is performed. As there is no convenient way to iterate over the
45         changed or deleted instances, there's no way to be more precise about what gets cleared.
46         
47         """
48         def update(self, *args, **kwargs):
49                 super(NavigationCacheQuerySet, self).update(*args, **kwargs)
50                 Navigation.objects.clear_cache()
51         
52         def delete(self, *args, **kwargs):
53                 super(NavigationCacheQuerySet, self).delete(*args, **kwargs)
54                 Navigation.objects.clear_cache()
55
56
57 class NavigationManager(models.Manager):
58         """
59         Since navigation on a site will be hit frequently, is relatively costly to compute, and is changed relatively infrequently, the NavigationManager maintains a cache which maps nodes to navigations.
60         
61         """
62         use_for_related = True
63         _cache = {}
64         
65         def get_query_set(self):
66                 """
67                 Returns a :class:`NavigationCacheQuerySet` instance.
68                 
69                 """
70                 return NavigationCacheQuerySet(self.model, using=self._db)
71         
72         def get_cache_for(self, node, update_targets=True):
73                 """Returns the navigation cache for a given :class:`.Node`. If update_targets is ``True``, then :meth:`update_targets_for` will be run with the :class:`.Node`."""
74                 created = False
75                 if not self.has_cache_for(node):
76                         self.create_cache_for(node)
77                         created = True
78                 
79                 if update_targets and not created:
80                         self.update_targets_for(node)
81                 
82                 return self.__class__._cache[self.db][node]
83         
84         def has_cache_for(self, node):
85                 """Returns ``True`` if a cache exists for the :class:`.Node` and ``False`` otherwise."""
86                 return self.db in self.__class__._cache and node in self.__class__._cache[self.db]
87         
88         def create_cache_for(self, node):
89                 """This method loops through the :class:`.Node`\ s ancestors and caches all unique navigation keys."""
90                 ancestors = node.get_ancestors(ascending=True, include_self=True)
91                 
92                 nodes_to_cache = []
93                 
94                 for node in ancestors:
95                         if self.has_cache_for(node):
96                                 cache = self.get_cache_for(node).copy()
97                                 break
98                         else:
99                                 nodes_to_cache.insert(0, node)
100                 else:
101                         cache = {}
102                 
103                 for node in nodes_to_cache:
104                         cache = cache.copy()
105                         cache.update(self._build_cache_for(node))
106                         self.__class__._cache.setdefault(self.db, {})[node] = cache
107         
108         def _build_cache_for(self, node):
109                 cache = {}
110                 tree_id_attr = NavigationItem._mptt_meta.tree_id_attr
111                 level_attr = NavigationItem._mptt_meta.level_attr
112                 
113                 for navigation in node.navigation_set.all():
114                         tree_ids = navigation.roots.values_list(tree_id_attr)
115                         items = list(NavigationItem.objects.filter(**{'%s__in' % tree_id_attr: tree_ids, '%s__lt' % level_attr: navigation.depth}).order_by('order', 'lft'))
116                         
117                         root_items = []
118                         
119                         for item in items:
120                                 item._is_cached = True
121                                 
122                                 if not hasattr(item, '_cached_children'):
123                                         item._cached_children = []
124                                 
125                                 if item.parent:
126                                         # alternatively, if I don't want to force it to a list, I could keep track of
127                                         # instances where the parent hasn't yet been met and do this step later for them.
128                                         # delayed action.
129                                         item.parent = items[items.index(item.parent)]
130                                         if not hasattr(item.parent, '_cached_children'):
131                                                 item.parent._cached_children = []
132                                         item.parent._cached_children.append(item)
133                                 else:
134                                         root_items.append(item)
135                         
136                         cache[navigation.key] = {
137                                 'navigation': navigation,
138                                 'root_items': root_items,
139                                 'items': items
140                         }
141                 
142                 return cache
143         
144         def clear_cache_for(self, node):
145                 """Clear the cache for the :class:`.Node` and all its descendants. The navigation for this node has probably changed, and it isn't worth it to figure out which descendants were actually affected by this."""
146                 if not self.has_cache_for(node):
147                         # Already cleared.
148                         return
149                 
150                 descendants = node.get_descendants(include_self=True)
151                 cache = self.__class__._cache[self.db]
152                 for node in descendants:
153                         cache.pop(node, None)
154         
155         def update_targets_for(self, node):
156                 """Manually updates the target nodes for the :class:`.Node`'s cache in case something's changed there. This is a less complex operation than rebuilding the :class:`.Node`'s cache."""
157                 caches = self.__class__._cache[self.db][node].values()
158                 
159                 target_pks = set()
160                 
161                 for cache in caches:
162                         target_pks |= set([item.target_node_id for item in cache['items']])
163                 
164                 # A distinct query is not strictly necessary. TODO: benchmark the efficiency
165                 # with/without distinct.
166                 targets = list(Node.objects.filter(pk__in=target_pks).distinct())
167                 
168                 for cache in caches:
169                         for item in cache['items']:
170                                 if item.target_node_id:
171                                         item.target_node = targets[targets.index(item.target_node)]
172         
173         def clear_cache(self):
174                 """Clears the manager's entire navigation cache."""
175                 self.__class__._cache.pop(self.db, None)
176
177
178 class Navigation(Entity):
179         """
180         :class:`Navigation` represents a group of :class:`NavigationItem`\ s that have an intrinsic relationship in terms of navigating a website. For example, a ``main`` navigation versus a ``side`` navigation, or a ``authenticated`` navigation versus an ``anonymous`` navigation.
181         
182         """
183         #: A :class:`NavigationManager` instance.
184         objects = NavigationManager()
185         
186         #: The :class:`.Node` which the :class:`Navigation` is attached to. The :class:`Navigation` will also be available to all the :class:`.Node`'s descendants and will override any :class:`Navigation` with the same key on any of the :class:`.Node`'s ancestors.
187         node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
188         #: Each :class:`Navigation` has a ``key`` which consists of one or more word characters so that it can easily be accessed in a template as ``{{ node.navigation.this_key }}``.
189         key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
190         #: There is no limit to the depth of a tree of :class:`NavigationItem`\ s, but ``depth`` will limit how much of the tree will be displayed.
191         depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
192         
193         def __init__(self, *args, **kwargs):
194                 super(Navigation, self).__init__(*args, **kwargs)
195                 self._initial_data = model_to_dict(self)
196         
197         def __unicode__(self):
198                 return "%s[%s]" % (self.node, self.key)
199         
200         def _has_changed(self):
201                 return self._initial_data != model_to_dict(self)
202         
203         def save(self, *args, **kwargs):
204                 super(Navigation, self).save(*args, **kwargs)
205                 
206                 if self._has_changed():
207                         Navigation.objects.clear_cache_for(self.node)
208                         self._initial_data = model_to_dict(self)
209         
210         def delete(self, *args, **kwargs):
211                 super(Navigation, self).delete(*args, **kwargs)
212                 Navigation.objects.clear_cache_for(self.node)
213         
214         class Meta:
215                 unique_together = ('node', 'key')
216
217
218 class NavigationItemManager(TreeManager):
219         use_for_related = True
220         
221         def get_query_set(self):
222                 """Returns a :class:`NavigationCacheQuerySet` instance."""
223                 return NavigationCacheQuerySet(self.model, using=self._db)
224
225
226 class NavigationItem(TreeEntity, TargetURLModel):
227         #: A :class:`NavigationItemManager` instance
228         objects = NavigationItemManager()
229         
230         #: A :class:`ForeignKey` to a :class:`Navigation` instance. If this is not null, then the :class:`NavigationItem` will be a root node of the :class:`Navigation` instance.
231         navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
232         #: The text which will be displayed in the navigation. This is a :class:`CharField` instance with max length 50.
233         text = models.CharField(max_length=50)
234         
235         #: The order in which the :class:`NavigationItem` will be displayed.
236         order = models.PositiveSmallIntegerField(default=0)
237         
238         def __init__(self, *args, **kwargs):
239                 super(NavigationItem, self).__init__(*args, **kwargs)
240                 self._initial_data = model_to_dict(self)
241                 self._is_cached = False
242         
243         def __unicode__(self):
244                 return self.get_path(field='text', pathsep=u' › ')
245         
246         def clean(self):
247                 super(NavigationItem, self).clean()
248                 if bool(self.parent) == bool(self.navigation):
249                         raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
250         
251         def is_active(self, request):
252                 """Returns ``True`` if the :class:`NavigationItem` is considered active for a given request and ``False`` otherwise."""
253                 if self.target_url == request.path:
254                         # Handle the `default` case where the target_url and requested path
255                         # are identical.
256                         return True
257                 
258                 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):
259                         # If there's no target_node, double-check whether it's a full-url
260                         # match.
261                         return True
262                 
263                 if self.target_node and not self.url_or_subpath:
264                         # If there is a target node and it's targeted simply, but the target URL is not
265                         # the same as the request path, check whether the target node is an ancestor
266                         # of the requested node. If so, this is active unless the target node
267                         # is the same as the ``host node`` for this navigation structure.
268                         try:
269                                 host_node = self.get_root().navigation.node
270                         except AttributeError:
271                                 pass
272                         else:
273                                 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
274                                         return True
275                 
276                 return False
277         
278         def has_active_descendants(self, request):
279                 """Returns ``True`` if the :class:`NavigationItem` has active descendants and ``False`` otherwise."""
280                 for child in self.get_children():
281                         if child.is_active(request) or child.has_active_descendants(request):
282                                 return True
283                 return False
284         
285         def _has_changed(self):
286                 if model_to_dict(self) == self._initial_data:
287                         return False
288                 return True
289         
290         def _clear_cache(self):
291                 try:
292                         root = self.get_root()
293                         if self.get_level() < root.navigation.depth:
294                                 Navigation.objects.clear_cache_for(self.get_root().navigation.node)
295                 except AttributeError:
296                         pass
297         
298         def save(self, *args, **kwargs):
299                 super(NavigationItem, self).save(*args, **kwargs)
300                 
301                 if self._has_changed():
302                         self._clear_cache()
303         
304         def delete(self, *args, **kwargs):
305                 super(NavigationItem, self).delete(*args, **kwargs)
306                 self._clear_cache()