Fixing setup.py to reference README instead of README.markdown as otherwise sdist...
[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, TreeEntityManager, 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         A :class:`Navigation`'s :class:`NavigationItem`\ s will be accessible from its related :class:`.Node` and that :class:`.Node`'s descendants through a :class:`NavigationMapper` instance at :attr:`Node.navigation`. Example::
183         
184                 >>> node.navigation_set.all()
185                 []
186                 >>> parent = node.parent
187                 >>> items = parent.navigation_set.get(key='main').roots.all()
188                 >>> parent.navigation["main"] == node.navigation["main"] == list(items)
189                 True
190         
191         """
192         #: A :class:`NavigationManager` instance.
193         objects = NavigationManager()
194         
195         #: 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.
196         node = models.ForeignKey(Node, related_name='navigation_set', help_text="Be available as navigation for this node.")
197         #: 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 }}``.
198         key = models.CharField(max_length=255, validators=[RegexValidator("\w+")], help_text="Must contain one or more alphanumeric characters or underscores.", db_index=True)
199         #: 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.
200         depth = models.PositiveSmallIntegerField(default=DEFAULT_NAVIGATION_DEPTH, validators=[MinValueValidator(1)], help_text="Defines the maximum display depth of this navigation.")
201         
202         def __init__(self, *args, **kwargs):
203                 super(Navigation, self).__init__(*args, **kwargs)
204                 self._initial_data = model_to_dict(self)
205         
206         def __unicode__(self):
207                 return "%s[%s]" % (self.node, self.key)
208         
209         def _has_changed(self):
210                 return self._initial_data != model_to_dict(self)
211         
212         def save(self, *args, **kwargs):
213                 super(Navigation, self).save(*args, **kwargs)
214                 
215                 if self._has_changed():
216                         Navigation.objects.clear_cache_for(self.node)
217                         self._initial_data = model_to_dict(self)
218         
219         def delete(self, *args, **kwargs):
220                 super(Navigation, self).delete(*args, **kwargs)
221                 Navigation.objects.clear_cache_for(self.node)
222         
223         class Meta:
224                 unique_together = ('node', 'key')
225
226
227 class NavigationItemManager(TreeEntityManager):
228         use_for_related = True
229         
230         def get_query_set(self):
231                 """Returns a :class:`NavigationCacheQuerySet` instance."""
232                 return NavigationCacheQuerySet(self.model, using=self._db)
233
234
235 class NavigationItem(TreeEntity, TargetURLModel):
236         #: A :class:`NavigationItemManager` instance
237         objects = NavigationItemManager()
238         
239         #: 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.
240         navigation = models.ForeignKey(Navigation, blank=True, null=True, related_name='roots', help_text="Be a root in this navigation tree.")
241         #: The text which will be displayed in the navigation. This is a :class:`CharField` instance with max length 50.
242         text = models.CharField(max_length=50)
243         
244         #: The order in which the :class:`NavigationItem` will be displayed.
245         order = models.PositiveSmallIntegerField(default=0)
246         
247         def __init__(self, *args, **kwargs):
248                 super(NavigationItem, self).__init__(*args, **kwargs)
249                 self._initial_data = model_to_dict(self)
250                 self._is_cached = False
251         
252         def get_path(self, root=None, pathsep=u' › ', field='text'):
253                 return super(NavigationItem, self).get_path(root, pathsep, field)
254         path = property(get_path)
255         
256         def clean(self):
257                 super(NavigationItem, self).clean()
258                 if bool(self.parent) == bool(self.navigation):
259                         raise ValidationError("Exactly one of `parent` and `navigation` must be defined.")
260         
261         def is_active(self, request):
262                 """Returns ``True`` if the :class:`NavigationItem` is considered active for a given request and ``False`` otherwise."""
263                 if self.target_url == request.path:
264                         # Handle the `default` case where the target_url and requested path
265                         # are identical.
266                         return True
267                 
268                 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):
269                         # If there's no target_node, double-check whether it's a full-url
270                         # match.
271                         return True
272                 
273                 if self.target_node and not self.url_or_subpath:
274                         # If there is a target node and it's targeted simply, but the target URL is not
275                         # the same as the request path, check whether the target node is an ancestor
276                         # of the requested node. If so, this is active unless the target node
277                         # is the same as the ``host node`` for this navigation structure.
278                         try:
279                                 host_node = self.get_root().navigation.node
280                         except AttributeError:
281                                 pass
282                         else:
283                                 if self.target_node != host_node and self.target_node.is_ancestor_of(request.node):
284                                         return True
285                 
286                 return False
287         
288         def has_active_descendants(self, request):
289                 """Returns ``True`` if the :class:`NavigationItem` has active descendants and ``False`` otherwise."""
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()