a95546d47461400b11e5f5e84b661b059353d458
[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.db import models
5 from django.forms.models import model_to_dict
6 from philo.models import TreeEntity, JSONField, Node, TreeManager
7 from philo.validators import RedirectValidator
8
9
10 DEFAULT_NAVIGATION_DEPTH = 3
11
12
13 class NavigationQuerySet(models.query.QuerySet):
14         """
15         This subclass is necessary to trigger cache clearing for Navigation when a mass update
16         or deletion is performed. For now, either action will trigger a clearing of the entire
17         navigation cache, since there's no convenient way to iterate over the changed or
18         deleted instances.
19         """
20         def update(self, *args, **kwargs):
21                 super(NavigationQuerySet, self).update(*args, **kwargs)
22                 Navigation.objects.clear_cache()
23         
24         def delete(self, *args, **kwargs):
25                 super(NavigationQuerySet, self).delete(*args, **kwargs)
26                 Navigation.objects.clear_cache()
27
28
29 class NavigationManager(TreeManager):
30         
31         # Analagous to contenttypes, cache Navigation to avoid repeated lookups all over the place.
32         # Navigation will probably be used frequently.
33         _cache = {}
34         
35         def get_queryset(self):
36                 return NavigationQuerySet(self.model, using=self._db)
37         
38         def closest_navigation(self, node):
39                 """
40                 Returns the set of Navigation objects for a given node's navigation. This
41                 will be the most recent set of defined hosted navigation among the node's
42                 ancestors. Lookups are cached so that subsequent lookups for the same node
43                 don't hit the database.
44                 
45                 TODO: Should this create the auto-generated navigation in "physical" form?
46                 """
47                 try:
48                         return self._get_from_cache(self.db, node)
49                 except KeyError:
50                         # Find the most recent host!
51                         ancestors = node.get_ancestors(ascending=True, include_self=True).annotate(num_navigation=models.Count("hosted_navigation"))
52                         
53                         # Iterate down the ancestors until you find one that:
54                         # a) is cached, or
55                         # b) has hosted navigation.
56                         nodes_to_cache = []
57                         host_node = None
58                         for ancestor in ancestors:
59                                 if self.is_cached(ancestor) or ancestor.num_navigation > 0:
60                                         host_node = ancestor
61                                         break
62                                 else:
63                                         nodes_to_cache.append(ancestor)
64                         
65                         if not self.is_cached(host_node):
66                                 self._add_to_cache(self.db, host_node)
67                         
68                         # Cache the queryset instance for every node that was passed over, as well.
69                         hosted_navigation = self._get_from_cache(self.db, host_node)
70                         for node in nodes_to_cache:
71                                 self._add_to_cache(self.db, node, hosted_navigation)
72                 
73                 return hosted_navigation
74         
75         def is_cached(self, node):
76                 return self._is_cached(self.db, node)
77         
78         def _add_to_cache(self, using, node, qs=None):
79                 key = getattr(node, 'pk', None)
80                 
81                 if qs is None:
82                         if key is None:
83                                 roots = self.none()
84                         else:
85                                 roots = node.hosted_navigation.select_related('target_node')
86                         
87                         for root in roots:
88                                 root_qs = root.get_descendants(include_self=True).complex_filter({'%s__lte' % root._mptt_meta.level_attr: root.get_level() + root.depth}).exclude(depth__isnull=True)
89                                 if qs is None:
90                                         qs = root_qs
91                                 else:
92                                         qs |= root_qs
93                 
94                         if qs is None:
95                                 qs = self.none()
96                 
97                 self.__class__._cache.setdefault(using, {})[key] = qs
98         
99         def _get_from_cache(self, using, node):
100                 key = getattr(node, 'pk', None)
101                 return self.__class__._cache[self.db][key]
102         
103         def _is_cached(self, using, node):
104                 try:
105                         self._get_from_cache(using, node)
106                 except KeyError:
107                         return False
108                 return True
109         
110         def clear_cache(self, navigation=None):
111                 """
112                 Clear out the navigation cache. This needs to happen during database flushes
113                 or if a navigation entry is changed to prevent caching of outdated navigation information.
114                 """
115                 if navigation is None:
116                         self.__class__._cache.clear()
117                 elif self.db in self.__class__._cache:
118                         cache = self.__class__._cache[self.db]
119                         for pk, qs in cache.items():
120                                 if navigation in qs:
121                                         cache.pop(pk)
122
123
124 class Navigation(TreeEntity):
125         objects = NavigationManager()
126         text = models.CharField(max_length=50)
127         
128         hosting_node = models.ForeignKey(Node, blank=True, null=True, related_name='hosted_navigation', help_text="Be part of this node's root navigation.")
129         
130         target_node = models.ForeignKey(Node, blank=True, null=True, related_name='targeting_navigation', help_text="Point to this node's url.")
131         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.")
132         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.")
133         
134         order = models.PositiveSmallIntegerField(blank=True, null=True)
135         depth = models.PositiveSmallIntegerField(blank=True, null=True, default=DEFAULT_NAVIGATION_DEPTH, help_text="For the root of a hosted tree, defines the depth of the tree. A blank depth will hide this section of navigation. Otherwise, depth is ignored.")
136         
137         def __init__(self, *args, **kwargs):
138                 super(Navigation, self).__init__(*args, **kwargs)
139                 self._initial_data = model_to_dict(self)
140         
141         def __unicode__(self):
142                 return self.get_path(field='text', pathsep=u' › ')
143         
144         def clean(self):
145                 # Should this be enforced? Not enforcing it would allow creation of "headers" in the navbar.
146                 if not self.target_node and not self.url_or_subpath:
147                         raise ValidationError("Either a target node or a url must be defined.")
148                 
149                 if self.reversing_parameters and (not self.url_or_subpath or not self.target_node):
150                         raise ValidationError("Reversing parameters require a view name and a target node.")
151                 
152                 try:
153                         self.get_target_url()
154                 except NoReverseMatch, e:
155                         raise ValidationError(e.message)
156         
157         def get_target_url(self):
158                 node = self.target_node
159                 if node is not None and node.accepts_subpath and self.url_or_subpath:
160                         if self.reversing_parameters is not None:
161                                 view_name = self.url_or_subpath
162                                 params = self.reversing_parameters
163                                 args = isinstance(params, list) and params or None
164                                 kwargs = isinstance(params, dict) and params or None
165                                 return node.view.reverse(view_name, args=args, kwargs=kwargs, node=node)
166                         else:
167                                 subpath = self.url_or_subpath
168                                 while subpath and subpath[0] == '/':
169                                         subpath = subpath[1:]
170                                 return '%s%s' % (node.get_absolute_url(), subpath)
171                 elif node is not None:
172                         return node.get_absolute_url()
173                 else:
174                         return self.url_or_subpath
175         target_url = property(get_target_url)
176         
177         def is_active(self, request):
178                 # First check if this particular navigation is active. It is considered active if:
179                 # - the requested node is this instance's target node and its subpath matches the requested path.
180                 # - the requested node is a descendant of this instance's target node and this instance's target
181                 #   node is not the hosting node of this navigation structure.
182                 # - this instance has no target node and the url matches either the request path or the full url.
183                 # - any of this instance's children are active.
184                 node = request.node
185                 
186                 if self.target_node == node:
187                         if self.target_url == request.path:
188                                 return True
189                 elif self.target_node is None:
190                         if self.url_or_subpath == request.path or self.url_or_subpath == "http%s://%s%s" % (request.is_secure() and 's' or '', request.get_host(), request.path):
191                                 return True
192                 elif self.target_node.is_ancestor_of(node) and self.target_node != self.hosting_node:
193                         return True
194                 
195                 # Always fall back to whether the node has active children.
196                 return self.has_active_children(request)
197         
198         def is_cached(self):
199                 """Shortcut method for Navigation.objects.is_cached"""
200                 return Navigation.objects.is_cached(self)
201         
202         def has_active_children(self, request):
203                 for child in self.get_children():
204                         if child.is_active(request):
205                                 return True
206                 return False
207         
208         def _has_changed(self):
209                 if model_to_dict(self) == self._initial_data:
210                         return False
211                 return True
212         
213         def save(self, *args, **kwargs):
214                 super(Navigation, self).save(*args, **kwargs)
215                 
216                 if self._has_changed():
217                         self._initial_data = model_to_dict(self)
218                         if self.is_cached():
219                                 Navigation.objects.clear_cache(self)
220                         else:
221                                 for navigation in self.get_ancestors():
222                                         if navigation.hosting_node and navigation.is_cached() and self.get_level() <= (navigation.get_level() + navigation.depth):
223                                                 Navigation.objects.clear_cache(navigation)
224         
225         def delete(self, *args, **kwargs):
226                 super(Navigation, self).delete(*args, **kwargs)
227                 Navigation.objects.clear_cache(self)
228         
229         class Meta:
230                 ordering = ['order']
231                 verbose_name_plural = 'navigation'